diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 7ed34d1d..e67386a7 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -7,6 +7,131 @@ set -euo pipefail OUTPUT_FILE="$1" TAG_OR_RANGE="${2:-}" +normalize_changelog_type() { + case "${1,,}" in + feat|feature) + echo "feature" + ;; + fix) + echo "fix" + ;; + misc) + echo "misc" + ;; + *) + echo "" + ;; + esac +} + +extract_changelog_type() { + local commit_text="$1" + local trailer_value="" + + trailer_value=$(printf '%s\n' "$commit_text" | + git interpret-trailers --parse | + awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*changelog[[:space:]]*:/ {sub(/^[^:]*:[[:space:]]*/, "", $0); print tolower($0); exit}') + + if [[ -n "$trailer_value" ]]; then + normalize_changelog_type "$trailer_value" + return + fi + + if printf '%s\n' "$commit_text" | grep -iqE 'changelog[[:space:]]*\((feature|feat)\)'; then + echo "feature" + elif printf '%s\n' "$commit_text" | grep -iqE 'changelog[[:space:]]*\((fix)\)'; then + echo "fix" + elif printf '%s\n' "$commit_text" | grep -iqE 'changelog[[:space:]]*\((misc)\)'; then + echo "misc" + elif printf '%s\n' "$commit_text" | grep -iqE '^[[:space:]]*changelog[[:space:]]*:[[:space:]]*(feature|feat)[[:space:]]*$'; then + echo "feature" + elif printf '%s\n' "$commit_text" | grep -iqE '^[[:space:]]*changelog[[:space:]]*:[[:space:]]*(fix)[[:space:]]*$'; then + echo "fix" + elif printf '%s\n' "$commit_text" | grep -iqE '^[[:space:]]*changelog[[:space:]]*:[[:space:]]*(misc)[[:space:]]*$'; then + echo "misc" + else + echo "" + fi +} + +extract_repo_slug() { + if [[ -n "${GITHUB_REPOSITORY:-}" ]]; then + echo "$GITHUB_REPOSITORY" + return + fi + + local remote_url="" + remote_url=$(git config --get remote.origin.url 2>/dev/null || true) + if [[ -z "$remote_url" ]]; then + return + fi + + if [[ "$remote_url" =~ ^git@github\.com:([^/]+/[^/.]+)(\.git)?$ ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ "$remote_url" =~ ^https://github\.com/([^/]+/[^/.]+)/?(\.git)?$ ]]; then + echo "${BASH_REMATCH[1]}" + fi +} + +resolve_github_login() { + local commit_hash="$1" + local repo_slug="$2" + local author_name="$3" + local api_url="${GITHUB_API_URL:-https://api.github.com}" + local login="" + + if [[ -n "$repo_slug" ]] && command -v curl >/dev/null 2>&1; then + local auth_args=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_args=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + elif [[ -n "${GH_TOKEN:-}" ]]; then + auth_args=(-H "Authorization: Bearer ${GH_TOKEN}") + fi + + local response="" + response=$(curl -fsSL "${auth_args[@]}" -H "Accept: application/vnd.github+json" "$api_url/repos/$repo_slug/commits/$commit_hash" 2>/dev/null || true) + if [[ -n "$response" ]]; then + local python_bin="" + python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) + if [[ -n "$python_bin" ]]; then + login=$(printf '%s' "$response" | "$python_bin" -c 'import json,sys; data=json.load(sys.stdin); print(((data.get("author") or {}).get("login")) or "")' 2>/dev/null || true) + fi + fi + fi + + if [[ -z "$login" && "$author_name" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}$ ]]; then + login="$author_name" + fi + + echo "$login" +} + +build_thanks_line() { + local commit_hash="$1" + local repo_slug="$2" + local repo_owner="$3" + local author_name="$4" + local author_email="$5" + local committer_name="$6" + local committer_email="$7" + + local author_login="" + author_login=$(resolve_github_login "$commit_hash" "$repo_slug" "$author_name") + + local is_third_party="false" + if [[ -n "$author_login" && -n "$repo_owner" ]]; then + if [[ "${author_login,,}" != "${repo_owner,,}" ]]; then + is_third_party="true" + fi + elif [[ "${author_name,,}" != "${committer_name,,}" || "${author_email,,}" != "${committer_email,,}" ]]; then + is_third_party="true" + fi + + if [[ "$is_third_party" == "true" && -n "$author_login" ]]; then + printf ' thanks to @%s' "$author_login" + fi +} + mkdir -p "$(dirname "$OUTPUT_FILE")" if [[ -z "$TAG_OR_RANGE" ]]; then @@ -40,18 +165,28 @@ mapfile -t COMMITS < <(git log --pretty=format:'%H' $LOG_RANGE) FEATURES="" FIXES="" MISC="" +REPO_SLUG=$(extract_repo_slug) +REPO_OWNER="${REPO_SLUG%%/*}" for commit_hash in "${COMMITS[@]}"; do commit_msg=$(git log -1 --pretty=format:'%s' "$commit_hash") commit_body=$(git log -1 --pretty=format:'%b' "$commit_hash") - # Extract changelog type using awk (case-insensitive, robust) - changelog_type=$(echo "$commit_body" | awk 'BEGIN{IGNORECASE=1} /changelog[(: ]/ {if ($0 ~ /feature/) print "feature"; else if ($0 ~ /fix/) print "fix"; else if ($0 ~ /misc/) print "misc"; exit}') + commit_text=$(git log -1 --pretty=format:'%B' "$commit_hash") + author_name=$(git log -1 --pretty=format:'%an' "$commit_hash") + author_email=$(git log -1 --pretty=format:'%ae' "$commit_hash") + committer_name=$(git log -1 --pretty=format:'%cn' "$commit_hash") + committer_email=$(git log -1 --pretty=format:'%ce' "$commit_hash") + changelog_type=$(extract_changelog_type "$commit_text") if [ -n "$changelog_type" ]; then - body_content=$(echo "$commit_body" | awk 'BEGIN{IGNORECASE=1} !/changelog[(: ]/ && NF') + body_content=$(echo "$commit_body" | awk 'BEGIN{IGNORECASE=1} !/changelog[(: ]/ && !/^[[:space:]]*co-authored-by:[[:space:]]*/ && NF') + thanks_line=$(build_thanks_line "$commit_hash" "$REPO_SLUG" "$REPO_OWNER" "$author_name" "$author_email" "$committer_name" "$committer_email") entry="- $commit_msg" if [ -n "$body_content" ]; then entry=$(printf "%s\n%s" "$entry" "$(echo "$body_content" | sed 's/^/ /')") fi + if [ -n "$thanks_line" ]; then + entry=$(printf "%s \n%s" "$entry" "$thanks_line") + fi if [ "$changelog_type" = "feature" ]; then FEATURES=$(printf "%s\n%s" "$FEATURES" "$entry") elif [ "$changelog_type" = "fix" ]; then @@ -62,35 +197,27 @@ for commit_hash in "${COMMITS[@]}"; do fi done -# Write changelog { - echo "# Changelog for $VERSION_TITLE" - echo "" - if [[ -z "$TAG_OR_RANGE" ]]; then - echo "This page shows unreleased changes in the development version." - else - echo "Release Date: $(date +'%Y-%m-%d')" - fi echo "" echo "$CONTEXT_MSG" echo "" if [[ -n "$FEATURES" ]]; then - echo "## ✨ New Features" + echo "### ✨ New Features" echo "" echo "$FEATURES" echo "" fi if [[ -n "$FIXES" ]]; then - echo "## 🐛 Fixes" + echo "### 🐛 Fixes" echo "" echo "$FIXES" echo "" fi if [[ -n "$MISC" ]]; then - echo "## 🔧 Miscellaneous" + echo "### 🔧 Miscellaneous" echo "" echo "$MISC" echo "" diff --git a/.github/workflows/PR_check.yml b/.github/workflows/PR_check.yml index 1a11e946..d31f1646 100644 --- a/.github/workflows/PR_check.yml +++ b/.github/workflows/PR_check.yml @@ -1,15 +1,16 @@ name: PR-Check on: - pull_request: - branches: - - '**' + pull_request: + branches: + - "**" permissions: - contents: read + contents: read jobs: - build: - uses: ./.github/workflows/build_base.yml - with: - upload_artifacts: false + build: + uses: ./.github/workflows/build_base.yml + secrets: inherit + with: + upload_artifacts: false diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index c2a54777..d16886bb 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -1,86 +1,227 @@ -name: VIIPER Build Base - -on: - workflow_call: - inputs: - artifact_suffix: - required: false - type: string - default: "" - description: "Suffix to add to artifact names" - upload_artifacts: - required: false - type: boolean - default: false - description: "Whether to upload build artifacts" - -jobs: - test: - name: Test - runs-on: ubuntu-latest - defaults: - run: - working-directory: viiper - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - cache: true - cache-dependency-path: | - viiper/go.sum - - - name: Show Go version - run: go version - - - name: Run tests - working-directory: . - run: make test - - - build: - name: Build binaries (${{ matrix.target.goos }}/${{ matrix.target.goarch }}) - runs-on: ubuntu-latest - needs: test - strategy: - fail-fast: false - matrix: - target: - - { goos: linux, goarch: amd64, ext: "" } - - { goos: linux, goarch: arm64, ext: "" } - - { goos: windows, goarch: amd64, ext: ".exe" } - - { goos: windows, goarch: arm64, ext: ".exe" } - defaults: - run: - working-directory: viiper - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - cache: true - cache-dependency-path: | - viiper/go.sum - - - name: Build - env: - GOOS: ${{ matrix.target.goos }} - GOARCH: ${{ matrix.target.goarch }} - CGO_ENABLED: 0 - run: | - go build -trimpath -ldflags "-s -w" -o ../dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} ./cmd/viiper - ls -la ../dist - - - name: Upload artifact - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} - path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} - if-no-files-found: error +name: VIIPER Build Base + +on: + workflow_call: + inputs: + artifact_suffix: + required: false + type: string + default: "" + description: "Suffix to add to artifact names" + upload_artifacts: + required: false + type: boolean + default: false + description: "Whether to upload build artifacts" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6.4.0 + with: + go-version: stable + cache: true + cache-dependency-path: | + go.sum + + - name: Setup just + uses: extractions/setup-just@v3 + + - name: Install goversioninfo (Windows) + if: ${{ matrix.target.goos == 'windows' }} + shell: pwsh + run: | + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 + + - name: Show Go version + run: go version + + - name: Lint + uses: golangci/golangci-lint-action@v9.2.0 + with: + version: latest + install-mode: goinstall + + - name: Run tests + run: just test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: . + fail_ci_if_error: false + + build: + name: Build binaries (${{ matrix.target.goos }}/${{ matrix.target.goarch }}) + runs-on: ${{ matrix.target.runner }} + needs: test + strategy: + fail-fast: false + matrix: + target: + - { goos: linux, goarch: amd64, ext: "", runner: ubuntu-latest } + - { goos: linux, goarch: arm64, ext: "", runner: ubuntu-latest } + - { goos: windows, goarch: amd64, ext: ".exe", runner: windows-latest } + - { goos: windows, goarch: arm64, ext: ".exe", runner: windows-latest } + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6.4.0 + with: + go-version: 1.26 + cache: true + cache-dependency-path: | + go.sum + + - name: Setup just + uses: extractions/setup-just@v3 + + - name: Install goversioninfo + shell: pwsh + run: | + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 + + - name: Build + env: + GOOS: ${{ matrix.target.goos }} + GOARCH: ${{ matrix.target.goarch }} + CGO_ENABLED: 0 + BUILD_TYPE: Release + OUTPUT_NAME: viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} + run: | + just build Release + + - name: Package (Linux) + if: ${{ matrix.target.goos == 'linux' }} + shell: bash + run: | + set -euo pipefail + rm -rf dist/package + mkdir -p dist/package + cp dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }} dist/package/viiper + cp dist/licenses.txt dist/package/licenses.txt + tar -czf dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.tar.gz -C dist/package viiper licenses.txt + + - name: Package (Windows) + if: ${{ matrix.target.goos == 'windows' }} + shell: pwsh + run: | + Remove-Item -Recurse -Force dist/package -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path dist/package | Out-Null + Copy-Item dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} dist/package/viiper.exe -Force + Copy-Item dist/licenses.txt dist/package/licenses.txt -Force + Compress-Archive -Path dist/package/viiper.exe, dist/package/licenses.txt -DestinationPath dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.zip -Force + + - name: Upload artifact (Linux) + if: ${{ inputs.upload_artifacts && matrix.target.goos == 'linux' }} + uses: actions/upload-artifact@v7 + with: + name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} + path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.tar.gz + if-no-files-found: error + + - name: Upload artifact (Windows) + if: ${{ inputs.upload_artifacts && matrix.target.goos == 'windows' }} + uses: actions/upload-artifact@v7 + with: + name: VIIPER-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ inputs.artifact_suffix }} + path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.zip + if-no-files-found: error + + libviiper-linux: + name: Build libVIIPER (linux/amd64) + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6.4.0 + with: + go-version: 1.26 + cache: true + cache-dependency-path: | + go.sum + + - name: Setup just + uses: extractions/setup-just@v3 + + - name: Build + run: | + just build-libVIIPER Release + + - name: Package + run: cd dist/libVIIPER && zip libVIIPER-linux-amd64.zip libVIIPER.so libVIIPER.h licenses.txt + + - name: Upload artifact + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: libVIIPER-linux-amd64${{ inputs.artifact_suffix }} + path: dist/libVIIPER/libVIIPER-linux-amd64.zip + if-no-files-found: error + + libviiper-windows: + name: Build libVIIPER (windows/amd64) + runs-on: windows-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6.4.0 + with: + go-version: 1.26 + cache: true + cache-dependency-path: | + go.sum + + - name: Setup just + uses: extractions/setup-just@v3 + + - name: Install build tools + shell: pwsh + run: | + winget install -e --id MartinStorsjo.LLVM-MinGW.UCRT --accept-package-agreements --accept-source-agreements + winget install -e --id Kitware.CMake --accept-package-agreements --accept-source-agreements + if (Test-Path "C:\Program Files\LLVM-MinGW\bin") { + "C:\Program Files\LLVM-MinGW\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + } + exit 0 + + - name: Build + shell: bash + run: | + just build-libVIIPER Release + + - name: Package + shell: pwsh + run: | + Compress-Archive -Path dist/libVIIPER/libVIIPER.dll, dist/libVIIPER/libVIIPER.def, dist/libVIIPER/libVIIPER.h, dist/libVIIPER/licenses.txt -DestinationPath dist/libVIIPER/libVIIPER-windows-amd64.zip -Force + + - name: Upload artifact + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: libVIIPER-windows-amd64${{ inputs.artifact_suffix }} + path: dist/libVIIPER/libVIIPER-windows-amd64.zip + if-no-files-found: error diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml new file mode 100644 index 00000000..b216424b --- /dev/null +++ b/.github/workflows/clients_ci.yml @@ -0,0 +1,241 @@ +name: Client Libraries CI + +on: + pull_request: + branches: ["**"] + workflow_call: + inputs: + artifact_suffix: + required: false + type: string + default: "" + description: "Suffix to append to artifact names" + upload_artifacts: + required: false + type: boolean + default: false + description: "Whether to upload client library artifacts" + version: + required: false + type: string + default: "" + description: "Override version injected via ldflags (e.g. tag v1.2.3)" + +permissions: + contents: read + +jobs: + codegen: + name: Code generation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + cache: true + cache-dependency-path: go.sum + + - name: Run code generation + shell: bash + run: | + set -euo pipefail + if [ -n "${{ inputs.version }}" ]; then + echo "Running codegen with injected version: ${{ inputs.version }}" + go run -ldflags "-X github.com/Alia5/VIIPER/internal/codegen/common.Version=${{ inputs.version }}" ./cmd/viiper codegen + else + echo "Running codegen with default dev version" + go run ./cmd/viiper codegen + fi + + - name: Upload generated clients + uses: actions/upload-artifact@v7 + with: + name: generated-clients + path: clients/ + retention-days: 1 + + typescript: + name: TypeScript Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download generated clients + uses: actions/download-artifact@v8 + with: + name: generated-clients + path: clients/ + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + clients/typescript/package-lock.json + examples/typescript/package-lock.json + + - name: Build TypeScript Client Library + working-directory: clients/typescript + run: | + npm install + npm run build + npm pack + + - name: Build TypeScript examples (smoke) + working-directory: examples/typescript + run: | + npm install + npm run build + + - name: Rename TypeScript Client Library tarball + if: ${{ inputs.upload_artifacts }} + working-directory: clients/typescript + run: | + for file in viiperclient-*.tgz; do + if [ -f "$file" ]; then + mv "$file" "viiperclient-typescript-client-library${{ inputs.artifact_suffix }}.tgz" + fi + done + + - name: Upload TypeScript Client Library tarball + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: typescript-client-library${{ inputs.artifact_suffix }} + path: clients/typescript/viiperclient-typescript-client-library${{ inputs.artifact_suffix }}.tgz + if-no-files-found: error + + csharp: + name: C# Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download generated clients + uses: actions/download-artifact@v8 + with: + name: generated-clients + path: clients/ + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "8.0.x" + + - name: Pack C# Client Library + run: dotnet pack clients/csharp/Viiper.Client/Viiper.Client.csproj -c Release -o artifacts/nuget + + - name: Build C# examples (smoke) + run: | + dotnet build examples/csharp/virtual_keyboard/VirtualKeyboard.csproj -c Release + dotnet build examples/csharp/virtual_mouse/VirtualMouse.csproj -c Release + dotnet build examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj -c Release + + - name: Upload C# Client Library nupkg + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: csharp-client-library-nupkg${{ inputs.artifact_suffix }} + path: artifacts/nuget/*.nupkg + if-no-files-found: error + + cpp-sdk: + name: C++ Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download generated clients + uses: actions/download-artifact@v8 + with: + name: generated-clients + path: clients/ + + - name: Set up CMake + uses: jwlawson/actions-setup-cmake@v2.2.0 + with: + cmake-version: "3.26.x" + + - name: Install OpenSSL (libssl-dev) + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev + + - name: Build C++ examples (smoke) + run: | + cmake -S examples/cpp -B examples/cpp/build -DCMAKE_BUILD_TYPE=Release + cmake --build examples/cpp/build --config Release --parallel + + - name: Archive C++ Client Library headers + if: ${{ inputs.upload_artifacts }} + run: | + cd clients/cpp + zip -r ../../cpp-client-library-headers${{ inputs.artifact_suffix }}.zip include/ + + - name: Upload C++ Client Library headers + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: cpp-client-library-headers${{ inputs.artifact_suffix }} + path: cpp-client-library-headers${{ inputs.artifact_suffix }}.zip + if-no-files-found: error + + rust: + name: Rust Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download generated clients + uses: actions/download-artifact@v8 + with: + name: generated-clients + path: clients/ + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build Rust Client Library + working-directory: clients/rust + run: | + cargo build --release + cargo build --release --features async + + - name: Package Rust Client Library + working-directory: clients/rust + run: cargo package --allow-dirty --no-verify + + - name: Build Rust examples (smoke) + working-directory: examples/rust + run: cargo build --release + + - name: Rename Rust Client Library crate + if: ${{ inputs.upload_artifacts }} + working-directory: clients/rust/target/package + run: | + for file in viiper-client-*.crate; do + if [ -f "$file" ]; then + mv "$file" "viiper-client-rust-client-library${{ inputs.artifact_suffix }}.crate" + fi + done + + - name: Upload Rust Client Library crate + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: rust-client-library${{ inputs.artifact_suffix }} + path: clients/rust/target/package/viiper-client-rust-client-library${{ inputs.artifact_suffix }}.crate + if-no-files-found: error diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 5f635601..a576bc01 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,108 +1,123 @@ name: Deploy Documentation on: - push: - branches: - - main - tags: - - 'v*.*.*' + push: + branches: + - main + tags: + - "v*.*.*" permissions: - contents: write + contents: write jobs: - deploy-docs: - name: Deploy Documentation - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Configure Git - run: | - git config user.name github-actions[bot] - git config user.email github-actions[bot]@users.noreply.github.com - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.x - - - name: Install dependencies - run: | - pip install mkdocs-material mike - - - name: Generate changelog for main - if: github.ref == 'refs/heads/main' - run: | - chmod +x .github/scripts/generate-changelog.sh - .github/scripts/generate-changelog.sh docs/changelog/main.md - # Build changelog index page so /changelog/ is a valid route - mkdir -p docs/changelog - { - echo "# Changelog" - echo - echo "This section lists version-specific changelogs." - echo - } > docs/changelog/index.md - for f in docs/changelog/*.md; do - [ -f "$f" ] || continue - base=$(basename "$f" .md) - [ "$base" = "index" ] && continue - if [ "$base" = "main" ]; then - title="Unreleased (main)" - else - title="Version $base" - fi - echo "- [$title](./$base/)" >> docs/changelog/index.md - done - - - name: Deploy main version (main) - if: github.ref == 'refs/heads/main' - run: | - mike deploy --push --update-aliases main latest - - - name: Generate changelog for tagged version - if: startsWith(github.ref, 'refs/tags/') - run: | - chmod +x .github/scripts/generate-changelog.sh - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - .github/scripts/generate-changelog.sh "docs/changelog/$VERSION.md" "$TAG_NAME" - # Build changelog index page so /changelog/ is a valid route - mkdir -p docs/changelog - { - echo "# Changelog" - echo - echo "This section lists version-specific changelogs." - echo - } > docs/changelog/index.md - for f in docs/changelog/*.md; do - [ -f "$f" ] || continue - base=$(basename "$f" .md) - [ "$base" = "index" ] && continue - if [ "$base" = "main" ]; then - title="Unreleased (main)" - else - title="Version $base" - fi - echo "- [$title](./$base/)" >> docs/changelog/index.md - done - - - name: Deploy tagged version - if: startsWith(github.ref, 'refs/tags/') - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - - # Check if this is a pre-release (contains -, alpha, beta, rc) - if [[ "$VERSION" =~ - ]]; then - echo "Deploying pre-release version: $VERSION" - mike deploy --push "$VERSION" - else - echo "Deploying stable version: $VERSION" - mike deploy --push --update-aliases "$VERSION" stable - mike set-default --push stable - fi + deploy-docs: + name: Deploy Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name github-actions[bot] + git config user.email github-actions[bot]@users.noreply.github.com + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Install dependencies + run: | + pip install mkdocs-material mike + + - name: Inject version into install scripts + if: startsWith(github.ref, 'refs/tags/') + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then + echo "Injecting version: $TAG_NAME into install scripts" + sed -i "s/VIIPER_VERSION=\"dev-snapshot\"/VIIPER_VERSION=\"$TAG_NAME\"/" scripts/install.sh + sed -i "s/\\\$viiperVersion = \"dev-snapshot\"/\\\$viiperVersion = \"$TAG_NAME\"/" scripts/install.ps1 + fi + + - name: Copy installation scripts to docs + run: | + cp scripts/install.sh docs/install.sh + cp scripts/install.ps1 docs/install.ps1 + + - name: Generate changelog for main + if: github.ref == 'refs/heads/main' + run: | + chmod +x .github/scripts/generate-changelog.sh + .github/scripts/generate-changelog.sh docs/changelog/main.md + # Build changelog index page with links to all versions + mkdir -p docs/changelog + { + echo "# Changelog" + echo + echo "## Unreleased" + echo + echo "- [Development Version (main)](./main/)" + echo + echo "## Released Versions" + echo + } > docs/changelog/index.md + + # List all version tags and link to their changelog pages + git tag -l 'v*.*.*' --sort=-version:refname | while read tag; do + VERSION=${tag#v} + echo "- [Version $VERSION](../../$VERSION/changelog/$VERSION/)" >> docs/changelog/index.md + done + + - name: Deploy main version (main) + if: github.ref == 'refs/heads/main' + run: | + mike deploy --push --update-aliases main latest + + - name: Generate changelog for tagged version + if: startsWith(github.ref, 'refs/tags/') + run: | + chmod +x .github/scripts/generate-changelog.sh + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + .github/scripts/generate-changelog.sh "docs/changelog/$VERSION.md" "$TAG_NAME" + # Build changelog index page with links to all versions + mkdir -p docs/changelog + { + echo "# Changelog" + echo + echo "## Unreleased" + echo + echo "- [Development Version (main)](../../latest/changelog/main/)" + echo + echo "## Released Versions" + echo + } > docs/changelog/index.md + + # List all version tags and link to their changelog pages + git tag -l 'v*.*.*' --sort=-version:refname | while read tag; do + VER=${tag#v} + echo "- [Version $VER](../../$VER/changelog/$VER/)" >> docs/changelog/index.md + done + + - name: Deploy tagged version + if: startsWith(github.ref, 'refs/tags/') + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + + # Check if this is a pre-release (contains -, alpha, beta, rc) + if [[ "$VERSION" =~ - ]]; then + echo "Deploying pre-release version: $VERSION" + mike deploy --push "$VERSION" + else + echo "Deploying stable version: $VERSION" + mike deploy --push --update-aliases "$VERSION" stable + mike set-default --push stable + fi diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index f78acc0c..64884d7e 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -23,7 +23,7 @@ jobs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -32,14 +32,12 @@ jobs: shell: bash run: | chmod +x .github/scripts/generate-changelog.sh - # Determine output file OUTFILE=changelog_output.md if [[ "${{ inputs.mode }}" == "release" ]]; then .github/scripts/generate-changelog.sh "$OUTFILE" "${{ inputs.tag_name }}" else .github/scripts/generate-changelog.sh "$OUTFILE" fi - # Set output for workflow echo "changelog<> $GITHUB_OUTPUT cat "$OUTFILE" >> $GITHUB_OUTPUT echo "CHANGELOG_EOF" >> $GITHUB_OUTPUT diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bdc726c..5b9a82fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,101 +1,281 @@ name: Release on: - push: - tags: - - 'v*.*.*' + push: + tags: + - "v*.*.*" permissions: - contents: write + contents: write + id-token: write jobs: - build: - uses: ./.github/workflows/build_base.yml - with: - artifact_suffix: "-Release" - upload_artifacts: true - - generate-changelog: - name: Generate Changelog - needs: build - uses: ./.github/workflows/generate-changelog.yml - with: - mode: release - tag_name: ${{ github.ref_name }} - - create-release: - name: Create Release - needs: [build, generate-changelog] - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + build: + uses: ./.github/workflows/build_base.yml + secrets: inherit with: - fetch-depth: 0 + artifact_suffix: "-Release" + upload_artifacts: true - - name: Download all artifacts - uses: actions/download-artifact@v4 + generate-changelog: + name: Generate Changelog + needs: build + uses: ./.github/workflows/generate-changelog.yml with: - path: artifacts - - - name: Organize and rename artifacts - shell: bash - run: | - set -euo pipefail - mkdir -p release_files - for dir in artifacts/*; do - if [ -d "$dir" ]; then - base="$(basename "$dir")" - # Strip optional suffix from artifact name for filenames - name_no_suffix="${base%-Release}" - for file in "$dir"/*; do - if [ -f "$file" ]; then - ext="${file##*.}" - fname="viiper-${name_no_suffix#VIIPER-}" - # Preserve extension (avoid duplicating extension for non-dot cases) - if [[ "$file" == *.* ]]; then - cp "$file" "release_files/${fname}.${ext}" - else - cp "$file" "release_files/${fname}" - fi - fi - done - fi - done - ls -la release_files/ - - - name: Extract build info - id: build_info - shell: bash - run: | - echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT - echo "time=$(date +'%H%M')" >> $GITHUB_OUTPUT - echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT - TAG_NAME=${GITHUB_REF#refs/tags/} - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") - if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - COMMIT_COUNT=$(git rev-list --count HEAD) - GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" - fi - echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT - echo "Version from git: $GIT_VERSION" - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 + mode: release + tag_name: ${{ github.ref_name }} + + client-libraries: + name: Client library smoke builds and pack + uses: ./.github/workflows/clients_ci.yml with: - tag_name: ${{ steps.build_info.outputs.tag_name }} - name: "Release ${{ steps.build_info.outputs.version }}" - body: | - ## VIIPER Release ${{ steps.build_info.outputs.version }} - - Release Date: ${{ steps.build_info.outputs.date }} ${{ steps.build_info.outputs.time }} - - ### Changes - ${{ needs.generate-changelog.outputs.changelog }} - files: release_files/**/* - prerelease: false - draft: true + artifact_suffix: "-Release" + upload_artifacts: true + version: ${{ github.ref_name }} + + create-release: + name: Create Release + needs: [build, generate-changelog, client-libraries] + runs-on: ubuntu-latest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_USER_CONFIGURED: ${{ secrets.NUGET_USER != '' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Organize and rename artifacts + shell: bash + run: | + set -euo pipefail + mkdir -p release_files + for dir in artifacts/*; do + if [ -d "$dir" ]; then + base="$(basename "$dir")" + # Strip optional suffix from artifact name for filenames + name_no_suffix="${base%-Release}" + for file in "$dir"/*; do + if [ -f "$file" ]; then + filename="$(basename "$file")" + if [[ "$filename" == *.tar.gz ]]; then + ext="tar.gz" + else + ext="${filename##*.}" + fi + fname="viiper-${name_no_suffix#VIIPER-}" + # Preserve extension (avoid duplicating extension for non-dot cases) + if [[ "$filename" == *.* ]]; then + cp "$file" "release_files/${fname}.${ext}" + else + cp "$file" "release_files/${fname}" + fi + fi + done + fi + done + ls -la release_files/ + + - name: Verify release artifact version + shell: bash + run: | + set -euo pipefail + ARCHIVE="release_files/viiper-linux-amd64.tar.gz" + EXPECTED_VERSION="${GITHUB_REF_NAME}" + if [ ! -f "$ARCHIVE" ]; then + echo "ERROR: Missing Linux amd64 release archive: $ARCHIVE" >&2 + exit 1 + fi + VERIFY_DIR="$(mktemp -d)" + trap 'rm -rf "$VERIFY_DIR"' EXIT + tar -xzf "$ARCHIVE" -C "$VERIFY_DIR" + ACTUAL_VERSION=$( + "$VERIFY_DIR/viiper" --help | + sed -nE 's/^[[:space:]]*Version:[[:space:]]+([^[:space:]]+).*/\1/p' | + head -1 + ) + if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "ERROR: Release artifact version '$ACTUAL_VERSION' does not match tag '$EXPECTED_VERSION'." >&2 + exit 1 + fi + echo "Verified release artifact version: $ACTUAL_VERSION" + + - name: Extract build info + id: build_info + shell: bash + run: | + set -euo pipefail + TAG_NAME="${GITHUB_REF#refs/tags/}" + GIT_VERSION="$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || true)" + if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + COMMIT_COUNT="$(git rev-list --count HEAD)" + GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" + fi + { + echo "tag_name=$TAG_NAME" + echo "version=$GIT_VERSION" + } >> "$GITHUB_OUTPUT" + echo "Version from git: $GIT_VERSION" + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.build_info.outputs.tag_name }} + name: "VIIPER ${{ steps.build_info.outputs.version }}" + body: | + ${{ needs.generate-changelog.outputs.changelog }} + + --- + + ## Install / Update + +
+ Windows (PowerShell) + + ```powershell + irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex + ``` + Installs / updates to `%LOCALAPPDATA%\VIIPER\viiper.exe` +
+ +
+ Linux + + ```bash + curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh + ``` + Installs / updates to `/usr/local/bin/viiper` +
+ files: release_files/* + prerelease: false + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js (for npm publish) + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org/" + + - name: Set up .NET SDK (for NuGet publish) + if: env.NUGET_USER_CONFIGURED == 'true' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "8.0.x" + + # Acquire short-lived NuGet API key via OIDC Trusted Publishing + - name: NuGet login (OIDC → temp API key) + if: env.NUGET_USER_CONFIGURED == 'true' + id: login + uses: NuGet/login@v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Report skipped NuGet publish + if: env.NUGET_USER_CONFIGURED != 'true' + run: echo "::notice::NUGET_USER is not configured; skipping NuGet publish." + + # Publish client libraries to npm and NuGet using OIDC trusted publishing + - name: Publish TypeScript client library to npm + working-directory: release_files + shell: bash + run: | + set -euo pipefail + TS_TARBALL="viiper-typescript-client-library.tgz" + if [ ! -f "$TS_TARBALL" ]; then + echo "ERROR: Missing TypeScript client library tarball: $TS_TARBALL" >&2 + ls -la + exit 1 + fi + echo "Extracting version from package.json inside tarball..." + PKG_VERSION=$(tar -xOzf "$TS_TARBALL" package/package.json | grep '"version"' | head -1 | sed -E 's/.*"version" *: *"([^"]+)".*/\1/') + PKG_NAME=$(tar -xOzf "$TS_TARBALL" package/package.json | grep '"name"' | head -1 | sed -E 's/.*"name" *: *"([^"]+)".*/\1/') + if [ -z "$PKG_VERSION" ]; then + echo "ERROR: Failed to extract version from TypeScript client library tarball" >&2 + exit 1 + fi + if [ -z "$PKG_NAME" ]; then + echo "ERROR: Failed to extract package name from TypeScript client library tarball" >&2 + exit 1 + fi + echo "Package version: $PKG_VERSION" + echo "Package name: $PKG_NAME" + echo "Checking if $PKG_NAME@$PKG_VERSION already exists on npm..." + if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then + echo "npm package $PKG_NAME@$PKG_VERSION already exists. Skipping npm publish (idempotent re-run)." + exit 0 + fi + # Determine npm dist-tag + if [[ "$PKG_VERSION" == *-* ]]; then + NPM_TAG="dev" + else + NPM_TAG="latest" + fi + echo "Using npm dist-tag: $NPM_TAG" + # Remove deprecated always-auth config if present + npm config delete always-auth || true + npm publish "$TS_TARBALL" --provenance --access public --tag "$NPM_TAG" + + - name: Publish C# client library to NuGet + if: env.NUGET_USER_CONFIGURED == 'true' + working-directory: release_files + shell: bash + run: | + set -euo pipefail + NUPKG="viiper-csharp-client-library-nupkg.nupkg" + if [ ! -f "$NUPKG" ]; then + echo "ERROR: Missing C# client library package: $NUPKG" >&2 + ls -la + exit 1 + fi + echo "Publishing NuGet package: $NUPKG" + dotnet nuget push "$NUPKG" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Set up Rust (for crates.io publish) + uses: dtolnay/rust-toolchain@stable + + - name: Authenticate with crates.io (OIDC) + id: crates_io_auth + uses: rust-lang/crates-io-auth-action@v1.0.4 + + - name: Publish Rust client library to crates.io + working-directory: release_files + shell: bash + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + run: | + set -euo pipefail + CRATE="viiper-rust-client-library.crate" + if [ ! -f "$CRATE" ]; then + echo "ERROR: Missing Rust client library crate: $CRATE" >&2 + ls -la + exit 1 + fi + echo "Extracting crate to publish..." + mkdir -p ../rust-publish + tar -xzf "$CRATE" -C ../rust-publish + CRATE_DIR=$(ls -d ../rust-publish/viiper-client-*) + cd "$CRATE_DIR" + echo "Removing reserved backup files (if any)..." + find . -name 'Cargo.toml.orig' -print -delete || true + CRATE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') + CRATE_NAME="viiper-client" + if [ -z "$CRATE_VERSION" ]; then + echo "ERROR: Failed to extract version from Cargo.toml" >&2 + exit 1 + fi + echo "Crate version: $CRATE_VERSION" + echo "Crate name: $CRATE_NAME" + echo "Checking if $CRATE_NAME@$CRATE_VERSION already exists on crates.io..." + if cargo search "$CRATE_NAME" 2>/dev/null | grep -q "^$CRATE_NAME = \"$CRATE_VERSION\""; then + echo "Crate $CRATE_NAME@$CRATE_VERSION already exists. Skipping cargo publish (idempotent re-run)." + exit 0 + fi + echo "Publishing crate with OIDC trusted publishing..." + cargo publish --allow-dirty diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 6b2bc3d5..915825b2 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -1,100 +1,142 @@ name: Dev Snapshot Build on: - push: - branches: [ "main" ] + push: + branches: ["main", "microphone-rebuild", "ds4-audio-emulation"] permissions: - contents: write + contents: write jobs: - build: - uses: ./.github/workflows/build_base.yml - with: - artifact_suffix: "-Snapshot" - upload_artifacts: true - - generate-changelog: - name: Generate Changelog - needs: build - uses: ./.github/workflows/generate-changelog.yml - with: - mode: snapshot - - create-pre-release: - name: Create Pre-Release - needs: [build, generate-changelog] - runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + calculate-version: + name: Calculate Dev Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Calculate version + id: version + shell: bash + run: | + GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") + if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + COMMIT_COUNT=$(git rev-list --count HEAD) + GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" + fi + echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT + echo "Calculated version: $GIT_VERSION" - - name: Download all artifacts - uses: actions/download-artifact@v4 + build: + needs: calculate-version + uses: ./.github/workflows/build_base.yml + secrets: inherit with: - path: artifacts - - - name: Organize and rename artifacts - shell: bash - run: | - set -euo pipefail - mkdir -p release_files - for dir in artifacts/*; do - if [ -d "$dir" ]; then - echo "Processing artifact: $(basename "$dir")" - for file in "$dir"/*; do - if [ -f "$file" ]; then - cp "$file" "release_files/$(basename "$file")" - fi - done - fi - done - ls -la release_files/ - - - name: Extract build info - id: build_info - shell: bash - run: | - echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT - echo "time=$(date +'%H%M')" >> $GITHUB_OUTPUT - echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT - GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") - if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - COMMIT_COUNT=$(git rev-list --count HEAD) - GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" - fi - echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT - echo "Version from git: $GIT_VERSION" - - - name: Delete existing pre-release - uses: dev-drprasad/delete-tag-and-release@v0.2.1 + artifact_suffix: "-Snapshot" + upload_artifacts: true + + generate-changelog: + name: Generate Changelog + needs: build + uses: ./.github/workflows/generate-changelog.yml with: - tag_name: dev-latest - delete_release: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 + mode: snapshot + + client-libraries: + name: Client library smoke builds and pack + needs: calculate-version + uses: ./.github/workflows/clients_ci.yml with: - tag_name: dev-latest - name: "Dev Build ${{ steps.build_info.outputs.version }} (${{ steps.build_info.outputs.date }})" - body: | - Automated development build. - - Version: ${{ steps.build_info.outputs.version }} - Date: ${{ steps.build_info.outputs.date }} ${{ steps.build_info.outputs.time }} - Commit: [${{ steps.build_info.outputs.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}) - - ⚠️ These are the latest development builds and may contain bugs or unfinished features. - - ### Changes - ${{ needs.generate-changelog.outputs.changelog }} - files: release_files/**/* - prerelease: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + artifact_suffix: "-Snapshot" + upload_artifacts: true + version: ${{ needs.calculate-version.outputs.version }} + + create-pre-release: + name: Create Pre-Release + needs: [build, generate-changelog, client-libraries] + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Organize and rename artifacts + shell: bash + run: | + set -euo pipefail + mkdir -p release_files + for dir in artifacts/*; do + if [ -d "$dir" ]; then + echo "Processing artifact: $(basename "$dir")" + for file in "$dir"/*; do + if [ -f "$file" ]; then + cp "$file" "release_files/$(basename "$file")" + fi + done + fi + done + ls -la release_files/ + + - name: Extract build info + id: build_info + shell: bash + run: | + echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + echo "time=$(date +'%H%M')" >> $GITHUB_OUTPUT + echo "sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + GIT_VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "") + if [[ ! $GIT_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + COMMIT_COUNT=$(git rev-list --count HEAD) + GIT_VERSION="v0.0.0-${COMMIT_COUNT}-${GITHUB_SHA:0:7}" + fi + echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT + echo "Version from git: $GIT_VERSION" + + - name: Update Dev Snapshot Release + uses: andelf/nightly-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: dev-snapshot + name: "Dev Build ${{ steps.build_info.outputs.version }}" + prerelease: true + body: | + Automated development build. + ⚠️ These are the latest development builds and may contain bugs or unfinished features. + + ${{ needs.generate-changelog.outputs.changelog }} + + --- + + ## Install / Update + +
+ Windows (PowerShell) + + ```powershell + irm https://alia5.github.io/VIIPER/main/install.ps1 | iex + ``` + Installs / updates to `%LOCALAPPDATA%\VIIPER\viiper.exe` +
+ +
+ Linux + + ```bash + curl -fsSL https://alia5.github.io/VIIPER/main/install.sh | sh + ``` + Installs / updates to `/usr/local/bin/viiper` +
+ files: | + ./release_files/* diff --git a/.gitignore b/.gitignore index 1165a78e..47ef2bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,12 @@ Thumbs.db # build dist/ +# Windows version info resources +resource.syso +cmd/viiper/resource.syso +versioninfo.tmp.json +!viiper.ico + # Logs logs/ log/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..cbbb5090 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "_testing/e2e/deps/SDL"] + path = _testing/e2e/deps/SDL + url = https://github.com/libsdl-org/SDL + branch = main diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..7301f1ab --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,63 @@ +version: "2" + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - revive + - unconvert + - unparam + + settings: + errcheck: + check-blank: false + check-type-assertions: false + + govet: + disable: + - unsafeptr + + staticcheck: + checks: + - all + - -SA1029 + - -ST1000 + + revive: + rules: + - name: package-comments + disabled: true + - name: exported + disabled: true + + exclusions: + presets: [] + rules: + - path: node_modules + linters: + - govet + - path: _test\.go + linters: + - errcheck + - staticcheck + +formatters: + enable: + - gofmt + + settings: + gofmt: + simplify: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..8e9ef4bc --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD007": { + "indent": 4 + } +} \ No newline at end of file diff --git a/viiper/.vscode/launch.json b/.vscode/launch.json similarity index 90% rename from viiper/.vscode/launch.json rename to .vscode/launch.json index 907b8157..05d40ee7 100644 --- a/viiper/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,8 +12,8 @@ "program": "${workspaceFolder}/cmd/viiper", "env": { "VIIPER_LOG_LEVEL": "debug", - "VIIPER_LOG_FILE": "logs/viiper.log", - "VIIPER_LOG_RAW_FILE": "logs/viiper_raw.log", + // "VIIPER_LOG_FILE": "logs/viiper.log", + // "VIIPER_LOG_RAW_FILE": "logs/viiper_raw.log", }, "args": [ "server" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1afb0c7a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "go.toolsEnvVars": { + "CGO_ENABLED": "1", + "PATH": "${env:PATH};${workspaceFolder}/_testing/e2e/deps/SDL/build/Debug" + } +} diff --git a/Makefile b/Makefile deleted file mode 100644 index 358b72b5..00000000 --- a/Makefile +++ /dev/null @@ -1,116 +0,0 @@ -# VIIPER Makefile -# Cross-platform build automation for VIIPER - -# Variables -BINARY_NAME := viiper -MAIN_PKG := ./cmd/viiper -SRC_DIR := viiper -DIST_DIR := dist -VERSION ?= $(shell git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always 2>nul || echo v0.0.0-dev) -COMMIT := $(shell git rev-parse --short HEAD 2>nul || echo unknown) -BUILD_TIME := $(shell powershell -NoProfile -NonInteractive -Command "Get-Date -Format 'yyyy-MM-dd_HH:mm:ss'") - -# Go build flags -LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" - -# Windows detection and environment setup -ifeq ($(OS),Windows_NT) - EXE_EXT := .exe - export CGO_ENABLED=0 -else - EXE_EXT := - export CGO_ENABLED=0 -endif - -.PHONY: all -all: test build - -.PHONY: help -help: ## Show this help message - @echo VIIPER Makefile - @echo. - @echo Usage: make [target] - @echo. - @echo Targets: - @echo help Show this help message - @echo deps Download Go dependencies - @echo tidy Tidy Go dependencies - @echo build Build for current platform - @echo test Run tests - @echo test-coverage Run tests with coverage - @echo clean Remove build artifacts - @echo fmt Format Go code - @echo vet Run go vet - @echo lint Run golangci-lint - @echo run Build and run VIIPER - @echo run-server Build and run VIIPER server - @echo docs-serve Serve MkDocs documentation locally - @echo docs-build Build MkDocs documentation - @echo docs-deploy Deploy documentation to GitHub Pages - @echo version Show version information - -.PHONY: deps -deps: ## Download Go dependencies - cd $(SRC_DIR) && go mod download - -.PHONY: tidy -tidy: ## Tidy Go dependencies - cd $(SRC_DIR) && go mod tidy - -.PHONY: vet -vet: ## Run go vet - cd $(SRC_DIR) && go vet ./... - -.PHONY: test -test: ## Run tests - cd $(SRC_DIR) && go test -count=1 -v ./... - -.PHONY: test-coverage -test-coverage: ## Run tests with coverage - cd $(SRC_DIR) && go test -count=1 -coverprofile=coverage.out ./... - cd $(SRC_DIR) && go tool cover -html=coverage.out -o coverage.html - -.PHONY: build -build: ## Build for current platform - cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o ../$(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) - -.PHONY: clean -clean: ## Remove build artifacts - -@if exist $(DIST_DIR) rmdir /S /Q $(DIST_DIR) 2>nul - -@if exist $(SRC_DIR)\coverage.out del /Q $(SRC_DIR)\coverage.out 2>nul - -@if exist $(SRC_DIR)\coverage.html del /Q $(SRC_DIR)\coverage.html 2>nul - -.PHONY: fmt -fmt: ## Format Go code - cd $(SRC_DIR) && go fmt ./... - -.PHONY: lint -lint: ## Run golangci-lint (requires golangci-lint installed) - cd $(SRC_DIR) && golangci-lint run - -.PHONY: run -run: ## Build and run VIIPER - cd $(SRC_DIR) && go run $(MAIN_PKG) - -.PHONY: run-server -run-server: ## Build and run VIIPER server with default settings - cd $(SRC_DIR) && go run $(MAIN_PKG) server - -.PHONY: docs-serve -docs-serve: ## Serve MkDocs documentation locally (latest dev version) - mike serve - -.PHONY: docs-build -docs-build: ## Build MkDocs documentation - mkdocs build - -.PHONY: docs-deploy-dev -docs-deploy-dev: ## Deploy dev documentation version to GitHub Pages - mike deploy --push --update-aliases dev latest - -.PHONY: version -version: ## Show version information - @echo Version: $(VERSION) - @echo Commit: $(COMMIT) - @echo Built: $(BUILD_TIME) diff --git a/README.md b/README.md index 214a4323..72136e0b 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,261 @@ - -
+VIIPER logo +# VIIPER -[![Build Status](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml/badge.svg)](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml) -[![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE) -[![Client SDKs: MIT](https://img.shields.io/badge/Client_SDKs-MIT-green)](https://github.com/alia5/VIIPER/blob/main/viiper/internal/codegen/common/license.go) -[![Release](https://img.shields.io/github/v/release/alia5/VIIPER?include_prereleases&sort=semver)](https://github.com/alia5/VIIPER/releases) -[![Issues](https://img.shields.io/github/issues/alia5/VIIPER)](https://github.com/alia5/VIIPER/issues) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/alia5/VIIPER/pulls) +[![Build](https://github.com/hbashton/VIIPER/actions/workflows/snapshots.yml/badge.svg)](https://github.com/hbashton/VIIPER/actions) +[![Release](https://img.shields.io/github/v/release/hbashton/VIIPER?include_prereleases&sort=semver)](https://github.com/hbashton/VIIPER/releases) +[![License](https://img.shields.io/github/license/hbashton/VIIPER)](LICENSE.txt) +**Virtual Input over IP EmulatoR** +VIIPER is a userspace virtual USB device framework built on USBIP. This +hbashton fork is the backend used by the hbashton DS4Windows project for native +virtual controller output, including the ongoing DualSense audio, haptics, and +microphone work. -# VIIPER 🐍 +This repository is forked from [Alia5/VIIPER](https://github.com/Alia5/VIIPER). +The hbashton release channel contains the protocol and USB-audio changes needed +by [hbashton/DS4Windows](https://github.com/hbashton/DS4Windows). Install links +in this README download hbashton builds. -**Virtual** **I**nput over **IP** **E**mulato**R** +> **Windows releases from this fork are x64 only.** x86 Windows and x86 +> DS4Windows builds are not compatible with VIIPER. Use 64-bit Windows and the +> x64 DS4Windows package. -VIIPER is a tool to create virtual input devices using USBIP. +## Install for DS4Windows -## ℹ️ About VIIPER +The simplest and recommended path is through a VIIPER-capable DS4Windows build: -VIIPER creates virtual USB input devices using the USBIP protocol. -These virtual devices appear as real hardware to the operating system and applications, allowing you to emulate controllers, keyboards, and other input devices without physical hardware. +1. Download the newest VIIPER pre-release from + [hbashton/DS4Windows Releases](https://github.com/hbashton/DS4Windows/releases). +2. Open **DS4Windows > Settings**. +3. Under **VIIPER Virtual Controller Support**, click **Install / Repair VIIPER**. +4. Accept the administrator prompt and restart Windows if `usbip-win2` was installed or updated. +5. In a profile, choose a VIIPER output such as **DualSense (VIIPER)**. -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -All devices _can and must be_ controlled programmatically via an API. +DS4Windows installs VIIPER to `%LOCALAPPDATA%\VIIPER\viiper.exe`, installs the +required Windows USBIP driver when necessary, registers startup, and checks that +the local VIIPER API responds. -### ✨ Features +## Install VIIPER directly on Windows -- ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation (virtual device); see [Devices › Xbox 360 Controller](docs/devices/xbox360.md) - - ✅ HID Keyboard with N-key rollover and LED feedback; see [Devices › Keyboard](docs/devices/keyboard.md) - - ✅ HID Mouse with 5 buttons and horizontal/vertical wheel; see [Devices › Mouse](docs/devices/mouse.md) - - 🔜 ??? - 🚧 Extensible architecture allows for more device types (other gamepads, specialized HID) -- ✅ USBIP server mode: expose virtual devices to remote clients -- ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) -- ✅ Cross-platform: works on Linux and Windows -- ✅ Flexible logging (including raw USB packet logs) -- ✅ API server for device/bus management and controlling virtual devices programmatically -- ✅ Multiple client SDKs for easy integration; see [Client SDKs](docs/api/overview.md) - MIT Licensed +Windows x64 users can run the hbashton installer from PowerShell: -## 🔌 Requirements +```powershell +irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex +``` -VIIPER relies on USBIP. -You must have USBIP installed on your system. +The script: -**Linux:** +1. Downloads the latest release from `hbashton/VIIPER`. +2. Accepts either the packaged Windows ZIP or the `viiper.exe` asset used by current releases. +3. Installs VIIPER to `%LOCALAPPDATA%\VIIPER\viiper.exe`. +4. Installs or updates `usbip-win2` when required. +5. Registers VIIPER for startup and starts the local server. -- **Arch Linux:** - - Install: `sudo pacman -S usbip` - - Docs: [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) +You can also download `viiper.exe` manually from the +[latest hbashton release](https://github.com/hbashton/VIIPER/releases/latest). +VIIPER itself is portable, but virtual devices on Windows still require the +[`usbip-win2`](https://github.com/vadimgrn/usbip-win2) kernel driver. -- **Ubuntu:** - - Install: `sudo apt install linux-tools-generic` - - Docs: [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) +## What the hbashton fork adds -**Windows:** +### DS4Windows controller backends -- [usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver). +VIIPER can expose the following virtual USB devices for DS4Windows: -## 🔌 API +- Xbox 360 controller +- DualShock 4 +- DualSense +- DualSense Edge +- Nintendo Switch 2 Pro Controller -VIIPER includes an API for device and bus management, as well as streaming device control. -Each device type exposes its own control interface via the API. +The generic VIIPER keyboard and mouse devices remain available to other feeder +applications. -See the [API documentation](./docs/api) for details (🚧 in progress 🚧). +### Native DualSense input -## 🛠️ Development +The virtual DualSense and DualSense Edge paths carry: -### 🧰 Prerequisites +- Face, shoulder, system, and mute buttons +- Sticks and analog triggers +- Touchpad click and two-finger coordinates +- Gyroscope and accelerometer reports +- DualSense Edge Fn buttons and back paddles +- Battery and controller metadata used by the USB identity -- [Go](https://go.dev/) 1.25 or newer -- USBIP installed -- (Optional) [Make](https://www.gnu.org/software/make/) - - Linux/macOS: Usually pre-installed - - Windows: `winget install ezwinports.make` +### Output reports and adaptive triggers +VIIPER returns host output to DS4Windows instead of reducing every command to +generic rumble. Extended DualSense streams preserve: -### 🔄 Building from Source +- The native USB HID output report `0x02` +- Lightbar, player LED, mute LED, and rumble state +- Native-spaced left and right adaptive-trigger effect blocks +- Optional Bluetooth haptics report `0x32` +- Combined Bluetooth state, haptics, and speaker report `0x36` -```bash -git clone https://github.com/Alia5/VIIPER.git -cd VIIPER -make build -``` +This lets DS4Windows forward game-authored adaptive-trigger commands and other +DualSense output to a compatible physical controller. + +### Advanced haptics and speaker audio + +The virtual DualSense includes the USB Audio Class interfaces expected by games. +The hbashton fork implements USBIP isochronous packet descriptors, completion +pacing, and audio-interface state so those endpoints can carry real data. + +With the matching DS4Windows bridge: + +- A game can open the virtual DualSense playback endpoint. +- DualSense haptics samples are converted into the Bluetooth haptics lane. +- Speaker samples can be forwarded to the physical controller speaker over Bluetooth. +- Haptics and speaker data share the combined Bluetooth report without one path starving the other. + +### Microphone input -The binary will be in `dist/viiper` (or `dist/viiper.exe` on Windows). +The microphone-capable DualSense, DualSense Edge, and DualShock 4 device types +expose virtual Windows recording endpoints. The framed feeder protocol accepts +PCM microphone frames separately from controller input state, and the USBIP +ISO-IN path supplies them to Windows. -For more build options: -```bash -make help # Show all available targets -make test # Run tests +In the DS4Windows integration, microphone audio follows this path: + +1. The physical Bluetooth DualSense or DualShock 4 supplies its encoded + microphone frames. +2. DS4Windows decodes and conditions the signal. +3. DS4Windows converts the PCM to the emulated controller's native format and + sends it to VIIPER. +4. VIIPER presents that PCM through the selected virtual controller's recording + endpoint. + +Transport framing and microphone data are deliberately isolated from HID input +reports. This prevents audio bytes from being interpreted as controller buttons, +keyboard commands, or mouse movement. + +## Architecture + +```text +Physical controller + | + | HID input, audio, and feedback + v +DS4Windows feeder + | + | local framed TCP API + v +VIIPER userspace USB device + | + | USBIP + v +usbip-win2 virtual host controller + | + v +Windows, games, and audio services ``` -## 🤝 Contributing +VIIPER does not emulate a Bluetooth radio and does not make the virtual device +appear wirelessly paired. The game sees a native-style USB controller. DS4Windows +is responsible for translating and forwarding supported feedback between that +virtual USB device and the physical USB or Bluetooth controller. -Contributions are welcome! -Please open issues or pull requests on GitHub. -See the [issues page](https://github.com/Alia5/VIIPER/issues) for bugs and feature requests. +## Requirements -## ❓ FAQ +### Windows -### What is USBIP and why does VIIPER use it? +- Windows 10 or Windows 11 x64 +- [`usbip-win2`](https://github.com/vadimgrn/usbip-win2) +- The hbashton VIIPER executable for the protocol used by your DS4Windows build +- Administrator approval for driver installation and startup registration -USBIP is a protocol that allows USB devices to be shared over a network. -VIIPER uses it because it's already built into Linux and available for Windows, making virtual device emulation possible without writing custom kernel drivers yourself. +The current hbashton release channel prioritizes Windows x64 and DS4Windows. +The underlying VIIPER project remains cross-platform, but binaries and features +available from this fork may differ from upstream. -### Can I use VIIPER for gaming? +### Linux development -Yes! VIIPER can create virtual controllers (currently only Xbox360) that appear as real hardware to games and applications. -This works with Steam, native Windows games, and any other application supporting controllers. +Linux uses the kernel USBIP client and `vhci-hcd` module. Package names vary by +distribution; common starting points are `linux-tools-generic` on Ubuntu and +`usbip` on Arch Linux. -### How is VIIPER different from other controller emulators? +## Server and API -VIIPER uses USBIP to handle the USB protocol layer, so device emulation happens in userspace code instead of kernel drivers. -This means you install USBIP once (built into Linux, usbip-win2 for Windows), and VIIPER can emulate any device type without installing additional drivers. -New device types can be added with pure Go code, no kernel programming required. +The standalone `viiper` executable exposes a lightweight TCP API for bus and +device management. Management requests are null-terminated path/payload messages, +while active devices use persistent binary streams for low-latency input and +feedback. -### Can I add support for other device types? +Localhost feeder applications can create a bus, add a device, open its stream, +send input state, and receive output feedback. VIIPER handles USB descriptors, +USBIP requests, and device attachment. -Yes! VIIPER's architecture is designed to be extensible. -Check the [xbox360 device implementation](./viiper/pkg/device/xbox360/) as a reference for creating new device types. +See: -### What about the proxy mode? +- [API overview](docs/api/overview.md) +- [DualSense protocol](docs/devices/dualsense.md) +- [DualShock 4 protocol](docs/devices/dualshock4.md) +- [Xbox 360 protocol](docs/devices/xbox360.md) +- [Switch 2 Pro protocol](docs/devices/ns2pro.md) +- [libVIIPER overview](docs/libviiper/overview.md) -Proxy mode sits between a USBIP client and a USBIP server (like a Linux machine sharing real USB devices). -VIIPER intercepts and logs all USB traffic passing through, without handling the devices directly. -Useful for reverse engineering USB protocols and understanding how devices communicate. +## Build from source -## 📄 License +### Prerequisites -```license -VIIPER - Virtual Input over IP EmulatoR +- [Go](https://go.dev/) 1.26 or newer +- [just](https://github.com/casey/just), recommended +- USBIP support for the target operating system +- A C compiler only when building `libVIIPER` -Copyright (C) 2025 Peter Repukat +### Build -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. +```powershell +git clone https://github.com/hbashton/VIIPER.git +cd VIIPER +just build Release +``` -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. +The Windows executable is written to `dist/viiper-windows-amd64.exe`. Useful +development commands include: -You should have received a copy of the GNU General Public License -along with this program. If not, see . +```powershell +just test +go test ./... +go run ./cmd/viiper codegen ``` + +Client bindings are generated for TypeScript, C#, C++, and Rust. Run code +generation whenever a public device-state or feedback contract changes, then +build the client examples before publishing the change. + +## Troubleshooting + +- **DS4Windows says VIIPER is unavailable:** run **Install / Repair VIIPER** and + restart Windows if `usbip-win2` was just installed. +- **No virtual controller appears:** confirm `viiper.exe server` is running and + that the USBIP driver is installed. +- **Several stale controllers appear:** stop DS4Windows and VIIPER, start VIIPER + once, then start DS4Windows. Report repeatable lifecycle bugs with both logs. +- **DualSense audio or microphone endpoints are missing:** use matching + hbashton DS4Windows and VIIPER releases; older upstream VIIPER builds do not + contain the same extended device types. +- **Input becomes corrupted while the microphone is active:** stop the test and + report the DS4Windows log plus the VIIPER log. Microphone-capable streams must + use the framed protocol and should never pass audio transport bytes into HID state. + +Report backend issues at +[hbashton/VIIPER Issues](https://github.com/hbashton/VIIPER/issues). Report +controller mapping or DS4Windows UI issues at +[hbashton/DS4Windows Issues](https://github.com/hbashton/DS4Windows/issues). + +## License + +The VIIPER server and core are licensed under GPL-3.0-or-later. Generated client +libraries retain their documented MIT licensing. See [`LICENSE.txt`](LICENSE.txt) +and the individual client packages for details. + +## Credits + +VIIPER was created by Peter Repukat and the Alia5/VIIPER contributors. This fork +builds on that architecture for DS4Windows. It also depends on the USBIP project, +`usbip-win2`, and controller/audio protocol research shared by SDL, SAxense, +DualSense reverse-engineering projects, and the wider open-source community. diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go new file mode 100644 index 00000000..83a2b66c --- /dev/null +++ b/_testing/e2e/bench_test.go @@ -0,0 +1,313 @@ +package e2e_bench_test + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "testing" + "time" + + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/cmd" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register all device handlers + + "github.com/Alia5/VIIPER/_testing/e2e/sdl" +) + +type TimeWhat int + +const ( + TimeWhat_ClientWritePress TimeWhat = iota + TimeWhat_WaitInput + TimeWhat_ClientWriteRelease + TimeWhat_WaitRelease +) + +func Benchmark_Xbox360_Delay(b *testing.B) { + + type bench struct { + name string + timeOn func(tw TimeWhat, b *testing.B) + useEncryption bool + } + benches := []bench{ + { + name: "1 Go-Client-Write (PLAIN)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + }, + { + name: "2 InputDelay-Without-Client (PLAIN)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + }, + { + name: "3 E2E-InputDelay (PLAIN)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + }, + { + name: "4 E2E-PressAndRelease (PLAIN)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + b.StartTimer() + case TimeWhat_WaitRelease: + b.StartTimer() + } + }, + }, + { + name: "1 Go-Client-Write (ENC)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + useEncryption: true, + }, + { + name: "2 InputDelay-Without-Client (ENC)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + useEncryption: true, + }, + { + name: "3 E2E-InputDelay (ENC)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + case TimeWhat_WaitRelease: + } + }, + useEncryption: true, + }, + { + name: "4 E2E-PressAndRelease (ENC)", + timeOn: func(tw TimeWhat, b *testing.B) { + switch tw { + case TimeWhat_ClientWritePress: + b.StartTimer() + case TimeWhat_WaitInput: + b.StartTimer() + case TimeWhat_ClientWriteRelease: + b.StartTimer() + case TimeWhat_WaitRelease: + b.StartTimer() + } + }, + useEncryption: true, + }, + } + + b.SetParallelism(1) + + defer sdl.Quit() + if err := sdl.Init(sdl.InitFlagGamepad); err != nil { + b.Fatalf("SDL init failed: %v", err) + } + + sdl.UpdateGamepads() + existingGamepads, _ := sdl.GetGamepads() + existingGamepadSet := make(map[sdl.GamepadID]bool) + for _, id := range existingGamepads { + existingGamepadSet[id] = true + } + + s := cmd.Server{ + USBServerConfig: usb.ServerConfig{ + Addr: ":3244", + BusCleanupTimeout: 1 * time.Second, + }, + APIServerConfig: api.ServerConfig{ + Addr: ":3245", + AutoAttachLocalClient: true, + DeviceHandlerConnectTimeout: time.Second * 5, + Password: "testpassword1234", + PlatformOpts: api.PlatformOpts{ + AutoAttachWindowsNative: true, + }, + }, + ConnectionTimeout: 5 * time.Second, + } + logger := slog.Default() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + if err := s.StartServer(ctx, logger, nil); err != nil { + panic(err) + } + }() + + var c *viiperclient.Client + + c = viiperclient.New("localhost:3245") + var busResp *viipertypes.BusCreateResponse + var err error + for range 10 { + busResp, err = c.BusCreate(1) + if err == nil { + break + } + time.Sleep(time.Second * 1) + } + if busResp == nil { + b.Fatalf("BusCreate failed: %v", err) + } + busID := busResp.BusID + defer c.BusRemove(busID) + + devInfo, err := c.DeviceAdd(busID, "xbox360", nil) + if err != nil { + b.Fatalf("DeviceAdd failed: %v", err) + } + + devStream, err := c.OpenStream(ctx, busID, devInfo.DevID) + if err != nil { + b.Fatalf("OpenStream failed: %v", err) + } + defer devStream.Close() //nolint:errcheck + + var gamepad *sdl.Gamepad + for range 10 { + sdl.UpdateGamepads() + gIDs, _ := sdl.GetGamepads() + for _, id := range gIDs { + if !existingGamepadSet[id] { + gamepad, err = sdl.OpenGamepad(id) + if err != nil { + b.Fatalf("OpenGamepad failed: %v", err) + } + defer gamepad.Close() + break + } + } + if gamepad != nil { + break + } + time.Sleep(time.Second * 1) + } + if gamepad == nil { + b.Fatalf("No new gamepad found for testing (expected VIIPER virtual device)") + } + padChann := make(chan bool) + prevPadPressed := false + go func() { + defer close(padChann) + for { + select { + case <-ctx.Done(): + return + default: + } + sdl.UpdateGamepads() + pressed := gamepad.GetButton(sdl.GamepadButtonSouth) + if pressed != prevPadPressed { + padChann <- pressed + prevPadPressed = pressed + } + } + }() + + for _, bench := range benches { + if bench.useEncryption { + c = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") + } + b.Run(bench.name, func(b *testing.B) { + for b.Loop() { + b.StopTimer() + bench.timeOn(TimeWhat_ClientWritePress, b) + err = devStream.WriteBinary(&xbox360.InputState{ + Buttons: xbox360.ButtonA, + }) + b.StopTimer() + if err != nil { + b.Fatalf("WriteBinary failed: %v", err) + } + timeout := time.After(1 * time.Second) + + bench.timeOn(TimeWhat_WaitInput, b) + waitForInput(ctx, timeout, padChann, true) + + b.StopTimer() + bench.timeOn(TimeWhat_ClientWriteRelease, b) + err = devStream.WriteBinary(&xbox360.InputState{}) + b.StopTimer() + if err != nil { + b.Fatalf("WriteBinary failed: %v", err) + } + timeout = time.After(10000 * time.Second) + bench.timeOn(TimeWhat_WaitRelease, b) + waitForInput(ctx, timeout, padChann, false) + + b.StartTimer() + } + }) + } +} + +func waitForInput(ctx context.Context, timeout <-chan time.Time, padChann <-chan bool, wantPressed bool) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return context.DeadlineExceeded + case pressed, ok := <-padChann: + if !ok { + return context.Canceled + } + if pressed == wantPressed { + return nil + } + } + } +} diff --git a/_testing/e2e/deps/SDL b/_testing/e2e/deps/SDL new file mode 160000 index 00000000..4f031ea5 --- /dev/null +++ b/_testing/e2e/deps/SDL @@ -0,0 +1 @@ +Subproject commit 4f031ea5da370762469f977ee115a4d874d3de63 diff --git a/_testing/e2e/scripts/lat_bench.go b/_testing/e2e/scripts/lat_bench.go new file mode 100644 index 00000000..bf9ca11d --- /dev/null +++ b/_testing/e2e/scripts/lat_bench.go @@ -0,0 +1,393 @@ +package main + +// lat_bench.go +// Utility to run (or parse) Xbox360E2E benchmarks and emit enriched latency tables. +// Supports markdown, plain table and JSON output. +// IMPORTANT: The underlying benchmark MUST NOT run in parallel; the benchmark +// itself calls b.SetParallelism(1). This tool does not force parallel execution. +// +// Usage examples: +// # Run benchmarks (count=5) and emit markdown +// go run ./_testing/e2e/scripts/lat_bench.go -format markdown -count 5 > latency.md +// +// # Parse existing benchmark output instead of running (offline mode) +// go run ./_testing/e2e/scripts/lat_bench.go -format table -input bench.txt +// +// # Produce JSON for CI consumption +// go run ./_testing/e2e/scripts/lat_bench.go -format json -count 3 +// +// # Fixed iteration benchtime (5000 operations per sub benchmark) +// go run ./_testing/e2e/scripts/lat_bench.go -benchtime 5000x -format markdown > latency.md +// +// # Time based benchtime (2 seconds per benchmark) +// go run ./_testing/e2e/scripts/lat_bench.go -benchtime 2s -format table +// +// # Default benchtime when not specified is 1000x (fixed iterations) +// go run ./_testing/e2e/scripts/lat_bench.go -format table # implicit -benchtime=1000x +// +// # Filter benchmarks by encryption status +// go run ./_testing/e2e/scripts/lat_bench.go -encryption plain # only unencrypted (default) +// go run ./_testing/e2e/scripts/lat_bench.go -encryption encrypted # only encrypted +// go run ./_testing/e2e/scripts/lat_bench.go -encryption both # all benchmarks +// +// The tool always: +// * Groups repeated benchmark cycles when count > 1 +// * Omits memory statistics (B/op, allocs/op) +// * Uses E2E-InputDelay as 100% baseline for %Full column +// +// If running benchmarks on systems without the required gamepad/server setup +// you can capture output elsewhere and use -input parsing locally. + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "text/tabwriter" + "time" +) + +var ( + flagFormat = flag.String("format", "table", "Output format: markdown|table|json") + flagCount = flag.Int("count", 1, "Number of benchmark runs when executing go test") + flagInput = flag.String("input", "", "Optional file path with pre-recorded benchmark output to parse instead of running") + flagOutFile = flag.String("out", "", "Optional output file path. If empty prints to stdout") + flagBenchtime = flag.String("benchtime", "", "Optional benchtime argument passed to 'go test' (e.g. 2s or 5000x, defaults to 1000x)") + flagTestFlags = flag.String("testflags", "", "Arbitrary additional flags passed verbatim to 'go test' (e.g. -testflags='-benchtime=5000x -timeout=120s'). Overrides -benchtime if it includes a benchtime.") + flagPkg = flag.String("pkg", ".", "Package path passed to 'go test'. Default '.' (current directory).") + flagEncryption = flag.String("encryption", "plain", "Filter benchmarks by encryption: plain (default, unencrypted only), encrypted (encrypted only), or both (no filtering)") +) + +func main() { + flag.Parse() + var raw string + if *flagInput != "" { + data, err := os.ReadFile(*flagInput) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read input file: %v\n", err) + os.Exit(1) + } + raw = string(data) + } else { + var err error + raw, err = runBench(context.Background(), *flagPkg, *flagCount) + if err != nil { + fmt.Fprintf(os.Stderr, "benchmark execution error: %v\n", err) + os.Exit(1) + } + } + lines, err := parseLines(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "parse error: %v\n", err) + os.Exit(1) + } + lines = filterByEncryption(lines, *flagEncryption) + runs := groupRuns(lines) + td := tableData{Timestamp: time.Now(), Count: *flagCount} + for i, r := range runs { + metrics, notes := deriveRun(r) + td.Runs = append(td.Runs, runData{Index: i, Lines: metrics, Notes: notes}) + } + if out, err := exec.Command("go", "version").Output(); err == nil { + td.GoVersion = strings.TrimSpace(string(out)) + } + var outStr string + switch strings.ToLower(*flagFormat) { + case "markdown", "md": + outStr = outputMarkdown(td) + case "table": + outStr = outputTable(td) + case "json": + js, err := json.MarshalIndent(td, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err) + os.Exit(1) + } + outStr = string(js) + default: + fmt.Fprintf(os.Stderr, "unknown format: %s\n", *flagFormat) + os.Exit(1) + } + if *flagOutFile != "" { + if werr := os.WriteFile(*flagOutFile, []byte(outStr), 0644); werr != nil { + fmt.Fprintf(os.Stderr, "failed to write output: %v\n", werr) + os.Exit(1) + } + } else { + fmt.Print(outStr) + } +} + +type benchLine struct { + Name string `json:"name"` + BaseName string `json:"base_name"` + Threads int `json:"threads"` + Iterations int `json:"iterations"` + NsPerOp float64 `json:"ns_per_op"` +} + +type derivedMetrics struct { + benchLine + PercentOfFull float64 `json:"percent_of_full"` + ClientShare float64 `json:"client_share_pct"` + LatencyShare float64 `json:"latency_share_pct"` +} + +type runData struct { + Index int `json:"index"` + Lines []derivedMetrics `json:"lines"` + Notes []string `json:"notes"` +} + +type tableData struct { + Timestamp time.Time `json:"timestamp"` + GoVersion string `json:"go_version"` + Count int `json:"run_count"` + Runs []runData `json:"runs"` +} + +var benchRegexp = regexp.MustCompile( + `^Benchmark([^\s]+(?:/[^\s]+)*)-(\d+)\s+(\d+)\s+(\d+) ns/op\s+(\d+) B/op\s+(\d+) allocs/op$`, +) + +func parseLines(in string) ([]benchLine, error) { + var results []benchLine + scanner := bufio.NewScanner(strings.NewReader(in)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + m := benchRegexp.FindStringSubmatch(line) + if m == nil { + continue + } + parts := strings.Split(m[1], "/") + bl := benchLine{ + Name: m[1], + BaseName: parts[len(parts)-1], + } + fmt.Sscanf(m[2], "%d", &bl.Threads) + fmt.Sscanf(m[3], "%d", &bl.Iterations) + fmt.Sscanf(m[4], "%f", &bl.NsPerOp) + results = append(results, bl) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(results) == 0 { + return nil, errors.New("no benchmark lines parsed – ensure benchmark ran successfully") + } + return results, nil +} + +func runBench(ctx context.Context, pkg string, count int) (string, error) { + args := []string{"test", "-bench=.", "-run", "NONE", "-benchmem", fmt.Sprintf("-count=%d", count)} + if *flagTestFlags != "" { + for _, f := range strings.Fields(*flagTestFlags) { + args = append(args, f) + } + } else if *flagBenchtime != "" { + args = append(args, fmt.Sprintf("-benchtime=%s", *flagBenchtime)) + } else { + args = append(args, "-benchtime=1000x") + } + args = append(args, pkg) + cmd := exec.CommandContext(ctx, "go", args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + return buf.String(), fmt.Errorf("go test failed: %w\nOutput:\n%s", err, buf.String()) + } + return buf.String(), nil +} + +func deriveRun(lines []benchLine) (out []derivedMetrics, notes []string) { + var client, delay, e2e, press *benchLine + for i := range lines { + lb := strings.ToLower(lines[i].BaseName) + if client == nil && strings.Contains(lb, "client") && strings.Contains(lb, "write") { + client = &lines[i] + } + if delay == nil && strings.Contains(lb, "without-client") { + delay = &lines[i] + } + if e2e == nil && strings.Contains(lb, "e2e") && strings.Contains(lb, "inputdelay") { + e2e = &lines[i] + } + if press == nil && strings.Contains(lb, "e2e") && strings.Contains(lb, "press") { + press = &lines[i] + } + } + full := 0.0 + if e2e != nil { + full = e2e.NsPerOp + } else { + for i := range lines { + if lines[i].NsPerOp > full { + full = lines[i].NsPerOp + } + } + } + for _, l := range lines { + dm := derivedMetrics{benchLine: l} + if full > 0 { + dm.PercentOfFull = l.NsPerOp / full * 100.0 + } + if client != nil && l.BaseName == client.BaseName { + dm.ClientShare = 100.0 + dm.LatencyShare = 0.0 + } else if delay != nil && l.BaseName == delay.BaseName { + dm.ClientShare = 0.0 + dm.LatencyShare = 100.0 + } else if e2e != nil && client != nil && l.BaseName == e2e.BaseName { + dm.ClientShare = client.NsPerOp / e2e.NsPerOp * 100.0 + dm.LatencyShare = (e2e.NsPerOp - client.NsPerOp) / e2e.NsPerOp * 100.0 + } else if press != nil && client != nil && l.BaseName == press.BaseName { + clientTotal := 2 * client.NsPerOp + dm.ClientShare = clientTotal / press.NsPerOp * 100.0 + dm.LatencyShare = (press.NsPerOp - clientTotal) / press.NsPerOp * 100.0 + } + out = append(out, dm) + } + missing := []string{} + if client == nil { + missing = append(missing, "client-write") + } + if delay == nil { + missing = append(missing, "delay-without-client") + } + if e2e == nil { + missing = append(missing, "e2e-inputdelay") + } + if press == nil { + missing = append(missing, "e2e-pressandrelease") + } + if len(missing) > 0 { + notes = append(notes, "Missing roles: "+strings.Join(missing, ", ")) + } + return +} + +func filterByEncryption(lines []benchLine, mode string) []benchLine { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "both" || mode == "all" { + return lines + } + + wantEncrypted := mode == "encrypted" || mode == "enc" + filtered := []benchLine{} + for _, line := range lines { + // Check if benchmark name contains (ENC) or (PLAIN) + isEncrypted := strings.Contains(strings.ToUpper(line.Name), "(ENC)") + isPlain := strings.Contains(strings.ToUpper(line.Name), "(PLAIN)") + + if wantEncrypted && isEncrypted { + filtered = append(filtered, line) + } else if !wantEncrypted && isPlain { + filtered = append(filtered, line) + } else if !isEncrypted && !isPlain { + // If no encryption marker, include in plain mode by default + if !wantEncrypted { + filtered = append(filtered, line) + } + } + } + return filtered +} + +func groupRuns(lines []benchLine) [][]benchLine { + if len(lines) == 0 { + return nil + } + occ := make(map[string][]benchLine) + order := []string{} + for _, l := range lines { + if _, ok := occ[l.BaseName]; !ok { + order = append(order, l.BaseName) + } + occ[l.BaseName] = append(occ[l.BaseName], l) + } + minCount := len(occ[order[0]]) + for _, name := range order[1:] { + if c := len(occ[name]); c < minCount { + minCount = c + } + } + runs := make([][]benchLine, 0, minCount) + for i := 0; i < minCount; i++ { + var run []benchLine + for _, name := range order { + if i < len(occ[name]) { + run = append(run, occ[name][i]) + } + } + runs = append(runs, run) + } + return runs +} + +func outputMarkdown(td tableData) string { + var b strings.Builder + actualRuns := len(td.Runs) + if actualRuns == 1 { + b.WriteString(fmt.Sprintf("_Run count: %d (requested: %d)_\n\n", actualRuns, td.Count)) + } else { + b.WriteString(fmt.Sprintf("_Runs parsed: %d (requested: %d)_\n", actualRuns, td.Count)) + } + + for _, run := range td.Runs { + if actualRuns > 1 { + b.WriteString(fmt.Sprintf("\n### Run %d\n\n", run.Index+1)) + } + b.WriteString("| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % |\n") + b.WriteString("|-----------|-------|-------|-----------|----------------|-----------------|\n") + for _, l := range run.Lines { + b.WriteString(fmt.Sprintf("| %s | %d | %.0f | %.2f | %.2f | %.2f |\n", l.BaseName, l.Iterations, l.NsPerOp, l.PercentOfFull, l.ClientShare, l.LatencyShare)) + } + if len(run.Notes) > 0 { + b.WriteString("\n**Notes**:\n") + for _, n := range run.Notes { + b.WriteString("- " + n + "\n") + } + } + } + return b.String() +} + +func outputTable(td tableData) string { + var b strings.Builder + actualRuns := len(td.Runs) + if actualRuns == 1 { + b.WriteString(fmt.Sprintf("Run count: %d (requested: %d)\n", actualRuns, td.Count)) + } else { + b.WriteString(fmt.Sprintf("Runs parsed: %d (requested: %d)\n", actualRuns, td.Count)) + } + + for _, run := range td.Runs { + if actualRuns > 1 { + b.WriteString(fmt.Sprintf("Run %d\n", run.Index+1)) + } + w := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) + fmt.Fprintf(w, "Benchmark\tCount\tNs/op\t%%Full\tClientShare%%\tLatencyShare%%\n") + for _, l := range run.Lines { + fmt.Fprintf(w, "%s\t%d\t%.0f\t%.2f\t%.2f\t%.2f\n", l.BaseName, l.Iterations, l.NsPerOp, l.PercentOfFull, l.ClientShare, l.LatencyShare) + } + w.Flush() + if len(run.Notes) > 0 { + b.WriteString("Notes:\n") + for _, n := range run.Notes { + b.WriteString(" - " + n + "\n") + } + } + if actualRuns > 1 { + b.WriteString("\n") + } + } + return b.String() +} diff --git a/_testing/e2e/sdl/error.go b/_testing/e2e/sdl/error.go new file mode 100644 index 00000000..b8169f5b --- /dev/null +++ b/_testing/e2e/sdl/error.go @@ -0,0 +1,30 @@ +package sdl + +/* +#cgo CFLAGS: -I${SRCDIR}/../deps/SDL/include + +#include + +#include +*/ +import "C" + +// SDLError wraps the SDL error message for the current thread. +type SDLError struct { + eStr string +} + +// Error returns the message with information about the specific error that occurred. +func (e *SDLError) Error() string { + return e.eStr +} + +// GetError retrieves a message about the last error that occurred on the current thread. +// +// It is possible for multiple errors to occur before calling SDL_GetError(). +// Only the last error is returned. +func GetError() *SDLError { + return &SDLError{ + eStr: C.GoString(C.SDL_GetError()), + } +} diff --git a/_testing/e2e/sdl/gamepad.go b/_testing/e2e/sdl/gamepad.go new file mode 100644 index 00000000..893412da --- /dev/null +++ b/_testing/e2e/sdl/gamepad.go @@ -0,0 +1,881 @@ +package sdl + +/* +#cgo CFLAGS: -I${SRCDIR}/../deps/SDL/include + +#include + +#include +#include + +static inline int gamepad_binding_input_button(const SDL_GamepadBinding *b) +{ + return b->input.button; +} + +static inline int gamepad_binding_input_axis(const SDL_GamepadBinding *b) +{ + return b->input.axis.axis; +} + +static inline int gamepad_binding_input_axis_min(const SDL_GamepadBinding *b) +{ + return b->input.axis.axis_min; +} + +static inline int gamepad_binding_input_axis_max(const SDL_GamepadBinding *b) +{ + return b->input.axis.axis_max; +} + +static inline int gamepad_binding_input_hat(const SDL_GamepadBinding *b) +{ + return b->input.hat.hat; +} + +static inline int gamepad_binding_input_hat_mask(const SDL_GamepadBinding *b) +{ + return b->input.hat.hat_mask; +} + +static inline int gamepad_binding_output_button(const SDL_GamepadBinding *b) +{ + return (int)b->output.button; +} + +static inline int gamepad_binding_output_axis(const SDL_GamepadBinding *b) +{ + return (int)b->output.axis.axis; +} + +static inline int gamepad_binding_output_axis_min(const SDL_GamepadBinding *b) +{ + return b->output.axis.axis_min; +} + +static inline int gamepad_binding_output_axis_max(const SDL_GamepadBinding *b) +{ + return b->output.axis.axis_max; +} +*/ +import "C" + +import "unsafe" + +type GamepadID int32 + +// GamepadType standard gamepad types. +type GamepadType int32 + +// GamepadAxis the list of axes available on a gamepad. +type GamepadAxis int32 + +// GamepadButton the list of buttons available on a gamepad. +type GamepadButton int32 + +// GamepadButtonLabel the set of gamepad button labels. +type GamepadButtonLabel int32 + +// GamepadBindingType describes the type of a gamepad control binding. +type GamepadBindingType int32 + +type SensorType int32 + +const ( + GamepadTypeUnknown GamepadType = C.SDL_GAMEPAD_TYPE_UNKNOWN + GamepadTypeStandard GamepadType = C.SDL_GAMEPAD_TYPE_STANDARD + GamepadTypeXbox360 GamepadType = C.SDL_GAMEPAD_TYPE_XBOX360 + GamepadTypeXboxOne GamepadType = C.SDL_GAMEPAD_TYPE_XBOXONE + GamepadTypePS3 GamepadType = C.SDL_GAMEPAD_TYPE_PS3 + GamepadTypePS4 GamepadType = C.SDL_GAMEPAD_TYPE_PS4 + GamepadTypePS5 GamepadType = C.SDL_GAMEPAD_TYPE_PS5 + GamepadTypeNintendoSwitchPro GamepadType = C.SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO + GamepadTypeNintendoSwitchJoyconLeft GamepadType = C.SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT + GamepadTypeNintendoSwitchJoyconRight GamepadType = C.SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT + GamepadTypeNintendoSwitchJoyconPair GamepadType = C.SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR + GamepadTypeGameCube GamepadType = C.SDL_GAMEPAD_TYPE_GAMECUBE +) + +const ( + GamepadAxisInvalid GamepadAxis = C.SDL_GAMEPAD_AXIS_INVALID + GamepadAxisLeftX GamepadAxis = C.SDL_GAMEPAD_AXIS_LEFTX + GamepadAxisLeftY GamepadAxis = C.SDL_GAMEPAD_AXIS_LEFTY + GamepadAxisRightX GamepadAxis = C.SDL_GAMEPAD_AXIS_RIGHTX + GamepadAxisRightY GamepadAxis = C.SDL_GAMEPAD_AXIS_RIGHTY + GamepadAxisLeftTrigger GamepadAxis = C.SDL_GAMEPAD_AXIS_LEFT_TRIGGER + GamepadAxisRightTrigger GamepadAxis = C.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER +) + +const ( + GamepadButtonInvalid GamepadButton = C.SDL_GAMEPAD_BUTTON_INVALID + GamepadButtonSouth GamepadButton = C.SDL_GAMEPAD_BUTTON_SOUTH + GamepadButtonEast GamepadButton = C.SDL_GAMEPAD_BUTTON_EAST + GamepadButtonWest GamepadButton = C.SDL_GAMEPAD_BUTTON_WEST + GamepadButtonNorth GamepadButton = C.SDL_GAMEPAD_BUTTON_NORTH + GamepadButtonBack GamepadButton = C.SDL_GAMEPAD_BUTTON_BACK + GamepadButtonGuide GamepadButton = C.SDL_GAMEPAD_BUTTON_GUIDE + GamepadButtonStart GamepadButton = C.SDL_GAMEPAD_BUTTON_START + GamepadButtonLeftStick GamepadButton = C.SDL_GAMEPAD_BUTTON_LEFT_STICK + GamepadButtonRightStick GamepadButton = C.SDL_GAMEPAD_BUTTON_RIGHT_STICK + GamepadButtonLeftShoulder GamepadButton = C.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER + GamepadButtonRightShoulder GamepadButton = C.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER + GamepadButtonDpadUp GamepadButton = C.SDL_GAMEPAD_BUTTON_DPAD_UP + GamepadButtonDpadDown GamepadButton = C.SDL_GAMEPAD_BUTTON_DPAD_DOWN + GamepadButtonDpadLeft GamepadButton = C.SDL_GAMEPAD_BUTTON_DPAD_LEFT + GamepadButtonDpadRight GamepadButton = C.SDL_GAMEPAD_BUTTON_DPAD_RIGHT + GamepadButtonMisc1 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC1 + GamepadButtonRightPaddle1 GamepadButton = C.SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1 + GamepadButtonLeftPaddle1 GamepadButton = C.SDL_GAMEPAD_BUTTON_LEFT_PADDLE1 + GamepadButtonRightPaddle2 GamepadButton = C.SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2 + GamepadButtonLeftPaddle2 GamepadButton = C.SDL_GAMEPAD_BUTTON_LEFT_PADDLE2 + GamepadButtonTouchpad GamepadButton = C.SDL_GAMEPAD_BUTTON_TOUCHPAD + GamepadButtonMisc2 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC2 + GamepadButtonMisc3 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC3 + GamepadButtonMisc4 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC4 + GamepadButtonMisc5 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC5 + GamepadButtonMisc6 GamepadButton = C.SDL_GAMEPAD_BUTTON_MISC6 +) + +const ( + GamepadButtonLabelUnknown GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_UNKNOWN + GamepadButtonLabelA GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_A + GamepadButtonLabelB GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_B + GamepadButtonLabelX GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_X + GamepadButtonLabelY GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_Y + GamepadButtonLabelCross GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_CROSS + GamepadButtonLabelCircle GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_CIRCLE + GamepadButtonLabelSquare GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_SQUARE + GamepadButtonLabelTriangle GamepadButtonLabel = C.SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE +) + +const ( + GamepadBindingTypeNone GamepadBindingType = C.SDL_GAMEPAD_BINDTYPE_NONE + GamepadBindingTypeButton GamepadBindingType = C.SDL_GAMEPAD_BINDTYPE_BUTTON + GamepadBindingTypeAxis GamepadBindingType = C.SDL_GAMEPAD_BINDTYPE_AXIS + GamepadBindingTypeHat GamepadBindingType = C.SDL_GAMEPAD_BINDTYPE_HAT +) + +const ( + SensorTypeAccelerometer SensorType = C.SDL_SENSOR_ACCEL + SensorTypeGyroscope SensorType = C.SDL_SENSOR_GYRO +) + +// GamepadBinding describes one joystick-layer binding for a gamepad. +type GamepadBinding struct { + InputType GamepadBindingType + InputButton int + InputAxis int + InputAxisMin int + InputAxisMax int + InputHat int + InputHatMask int + + OutputType GamepadBindingType + OutputButton GamepadButton + OutputAxis GamepadAxis + OutputAxisMin int + OutputAxisMax int +} + +// Gamepad the structure used to identify an SDL gamepad. +type Gamepad struct { + cGamepad *C.SDL_Gamepad +} + +func InitGamepadSubSystem() error { + return InitSubSystem(InitFlagGamepad) +} + +func QuitGamepadSubSystem() { + QuitSubSystem(InitFlagGamepad) +} + +// HasGamepad returns whether a gamepad is currently connected. +func HasGamepad() bool { + return bool(C.SDL_HasGamepad()) +} + +// GetGamepads returns a list of currently connected gamepads. +func GetGamepads() ([]GamepadID, error) { + var count C.int + cIDs := C.SDL_GetGamepads(&count) + if cIDs == nil { + if count == 0 { + return []GamepadID{}, nil + } + return nil, GetError() + } + defer C.SDL_free(unsafe.Pointer(cIDs)) + + cSlice := unsafe.Slice(cIDs, int(count)) + ids := make([]GamepadID, 0, int(count)) + for _, id := range cSlice { + ids = append(ids, GamepadID(id)) + } + return ids, nil +} + +// IsGamepad checks if the given joystick is supported by the gamepad interface. +func IsGamepad(id GamepadID) bool { + return bool(C.SDL_IsGamepad(C.SDL_JoystickID(id))) +} + +// GetGamepadNameForID gets the implementation dependent name of a gamepad. +// +// This can be called before any gamepads are opened. +func GetGamepadNameForID(id GamepadID) string { + cName := C.SDL_GetGamepadNameForID(C.SDL_JoystickID(id)) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// OpenGamepad opens a gamepad for use. +func OpenGamepad(id GamepadID) (*Gamepad, error) { + cg := C.SDL_OpenGamepad(C.SDL_JoystickID(id)) + if cg == nil { + return nil, GetError() + } + return &Gamepad{cGamepad: cg}, nil +} + +// GetGamepadFromID gets the SDL_Gamepad associated with a joystick instance ID, if it has been opened. +func GetGamepadFromID(id GamepadID) (*Gamepad, bool) { + cg := C.SDL_GetGamepadFromID(C.SDL_JoystickID(id)) + if cg == nil { + return nil, false + } + return &Gamepad{cGamepad: cg}, true +} + +// SetGamepadEventsEnabled sets the state of gamepad event processing. +func SetGamepadEventsEnabled(enabled bool) { + C.SDL_SetGamepadEventsEnabled(C.bool(enabled)) +} + +// GamepadEventsEnabled queries the state of gamepad event processing. +func GamepadEventsEnabled() bool { + return bool(C.SDL_GamepadEventsEnabled()) +} + +// UpdateGamepads manually pumps gamepad updates if not using the loop. +func UpdateGamepads() { + C.SDL_UpdateGamepads() +} + +// Close closes a gamepad previously opened with SDL_OpenGamepad(). +func (g *Gamepad) Close() { + if g == nil || g.cGamepad == nil { + return + } + C.SDL_CloseGamepad(g.cGamepad) + g.cGamepad = nil +} + +// Connected checks if a gamepad has been opened and is currently connected. +func (g *Gamepad) Connected() bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GamepadConnected(g.cGamepad)) +} + +// ID gets the instance ID of an opened gamepad. +func (g *Gamepad) ID() GamepadID { + if g == nil || g.cGamepad == nil { + return 0 + } + return GamepadID(C.SDL_GetGamepadID(g.cGamepad)) +} + +// Name gets the implementation-dependent name for an opened gamepad. +func (g *Gamepad) Name() string { + if g == nil || g.cGamepad == nil { + return "" + } + cName := C.SDL_GetGamepadName(g.cGamepad) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// Type gets the type of an opened gamepad. +func (g *Gamepad) Type() GamepadType { + if g == nil || g.cGamepad == nil { + return GamepadTypeUnknown + } + return GamepadType(C.SDL_GetGamepadType(g.cGamepad)) +} + +// HasAxis queries whether a gamepad has a given axis. +func (g *Gamepad) HasAxis(axis GamepadAxis) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GamepadHasAxis(g.cGamepad, C.SDL_GamepadAxis(axis))) +} + +// GetAxis gets the current state of an axis control on a gamepad. +func (g *Gamepad) GetAxis(axis GamepadAxis) int16 { + if g == nil || g.cGamepad == nil { + return 0 + } + return int16(C.SDL_GetGamepadAxis(g.cGamepad, C.SDL_GamepadAxis(axis))) +} + +// HasButton queries whether a gamepad has a given button. +func (g *Gamepad) HasButton(button GamepadButton) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GamepadHasButton(g.cGamepad, C.SDL_GamepadButton(button))) +} + +// GetButton gets the current state of a button on a gamepad. +func (g *Gamepad) GetButton(button GamepadButton) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GetGamepadButton(g.cGamepad, C.SDL_GamepadButton(button))) +} + +// GetButtonLabel gets the label of a button on a gamepad. +func (g *Gamepad) GetButtonLabel(button GamepadButton) GamepadButtonLabel { + if g == nil || g.cGamepad == nil { + return GamepadButtonLabelUnknown + } + return GamepadButtonLabel(C.SDL_GetGamepadButtonLabel(g.cGamepad, C.SDL_GamepadButton(button))) +} + +// SetPlayerIndex sets the player index of an opened gamepad. +func (g *Gamepad) SetPlayerIndex(index int) error { + if g == nil || g.cGamepad == nil { + return &SDLError{eStr: "invalid gamepad handle"} + } + if !C.SDL_SetGamepadPlayerIndex(g.cGamepad, C.int(index)) { + return GetError() + } + return nil +} + +// GetPlayerIndex gets the player index of an opened gamepad. +func (g *Gamepad) GetPlayerIndex() int { + if g == nil || g.cGamepad == nil { + return -1 + } + return int(C.SDL_GetGamepadPlayerIndex(g.cGamepad)) +} + +// GetSteamHandle gets the Steam Input handle for an opened gamepad, if available. +// +// Returns 0 when unavailable. +func (g *Gamepad) GetSteamHandle() uint64 { + if g == nil || g.cGamepad == nil { + return 0 + } + return uint64(C.SDL_GetGamepadSteamHandle(g.cGamepad)) +} + +// AddGamepadMapping adds or updates a gamepad mapping string. +func AddGamepadMapping(mapping string) (int, error) { + cMapping := C.CString(mapping) + defer C.free(unsafe.Pointer(cMapping)) + + res := int(C.SDL_AddGamepadMapping(cMapping)) + if res < 0 { + return res, GetError() + } + return res, nil +} + +// AddGamepadMappingsFromIO loads gamepad mappings from an SDL_IOStream. +func AddGamepadMappingsFromIO(src unsafe.Pointer, closeIO bool) (int, error) { + res := int(C.SDL_AddGamepadMappingsFromIO((*C.SDL_IOStream)(src), C.bool(closeIO))) + if res < 0 { + return res, GetError() + } + return res, nil +} + +// AddGamepadMappingsFromFile loads gamepad mappings from a file. +func AddGamepadMappingsFromFile(file string) (int, error) { + cFile := C.CString(file) + defer C.free(unsafe.Pointer(cFile)) + + res := int(C.SDL_AddGamepadMappingsFromFile(cFile)) + if res < 0 { + return res, GetError() + } + return res, nil +} + +// ReloadGamepadMappings reinitializes the gamepad mapping database. +func ReloadGamepadMappings() error { + if !C.SDL_ReloadGamepadMappings() { + return GetError() + } + return nil +} + +// GetGamepadMappings returns all current gamepad mapping strings. +func GetGamepadMappings() ([]string, error) { + var count C.int + cMappings := C.SDL_GetGamepadMappings(&count) + if cMappings == nil { + if count == 0 { + return []string{}, nil + } + return nil, GetError() + } + defer C.SDL_free(unsafe.Pointer(cMappings)) + + cSlice := unsafe.Slice(cMappings, int(count)) + mappings := make([]string, 0, int(count)) + for _, m := range cSlice { + if m == nil { + mappings = append(mappings, "") + continue + } + mappings = append(mappings, C.GoString(m)) + } + + return mappings, nil +} + +// GetGamepadMappingForGUID gets the mapping string for a gamepad GUID. +func GetGamepadMappingForGUID(guid GUID) string { + cg := *(*C.SDL_GUID)(unsafe.Pointer(&guid)) + cMapping := C.SDL_GetGamepadMappingForGUID(cg) + if cMapping == nil { + return "" + } + defer C.SDL_free(unsafe.Pointer(cMapping)) + return C.GoString(cMapping) +} + +// GetGamepadPathForID gets the implementation dependent path of a gamepad. +func GetGamepadPathForID(id GamepadID) string { + cPath := C.SDL_GetGamepadPathForID(C.SDL_JoystickID(id)) + if cPath == nil { + return "" + } + return C.GoString(cPath) +} + +// GetGamepadPlayerIndexForID gets the player index of a gamepad. +func GetGamepadPlayerIndexForID(id GamepadID) int { + return int(C.SDL_GetGamepadPlayerIndexForID(C.SDL_JoystickID(id))) +} + +// GetGamepadGUIDForID gets the implementation-dependent GUID of a gamepad. +func GetGamepadGUIDForID(id GamepadID) GUID { + cg := C.SDL_GetGamepadGUIDForID(C.SDL_JoystickID(id)) + return *(*GUID)(unsafe.Pointer(&cg)) +} + +// GetGamepadVendorForID gets the USB vendor ID of a gamepad, if available. +func GetGamepadVendorForID(id GamepadID) uint16 { + return uint16(C.SDL_GetGamepadVendorForID(C.SDL_JoystickID(id))) +} + +// GetGamepadProductForID gets the USB product ID of a gamepad, if available. +func GetGamepadProductForID(id GamepadID) uint16 { + return uint16(C.SDL_GetGamepadProductForID(C.SDL_JoystickID(id))) +} + +// GetGamepadProductVersionForID gets the product version of a gamepad, if available. +func GetGamepadProductVersionForID(id GamepadID) uint16 { + return uint16(C.SDL_GetGamepadProductVersionForID(C.SDL_JoystickID(id))) +} + +// GetGamepadTypeForID gets the type of a gamepad. +func GetGamepadTypeForID(id GamepadID) GamepadType { + return GamepadType(C.SDL_GetGamepadTypeForID(C.SDL_JoystickID(id))) +} + +// GetRealGamepadTypeForID gets the type of a gamepad, ignoring mapping overrides. +func GetRealGamepadTypeForID(id GamepadID) GamepadType { + return GamepadType(C.SDL_GetRealGamepadTypeForID(C.SDL_JoystickID(id))) +} + +// GetGamepadMappingForID gets the mapping string for a gamepad ID. +func GetGamepadMappingForID(id GamepadID) string { + cMapping := C.SDL_GetGamepadMappingForID(C.SDL_JoystickID(id)) + if cMapping == nil { + return "" + } + defer C.SDL_free(unsafe.Pointer(cMapping)) + return C.GoString(cMapping) +} + +// GetGamepadFromPlayerIndex gets the SDL_Gamepad associated with a player index. +func GetGamepadFromPlayerIndex(playerIndex int) (*Gamepad, bool) { + cg := C.SDL_GetGamepadFromPlayerIndex(C.int(playerIndex)) + if cg == nil { + return nil, false + } + return &Gamepad{cGamepad: cg}, true +} + +// SetGamepadMapping sets the current mapping of a joystick or gamepad. +// +// Pass an empty mapping string to clear the mapping. +func SetGamepadMapping(id GamepadID, mapping string) error { + var cMapping *C.char + if mapping != "" { + cMapping = C.CString(mapping) + defer C.free(unsafe.Pointer(cMapping)) + } + + if !C.SDL_SetGamepadMapping(C.SDL_JoystickID(id), cMapping) { + return GetError() + } + return nil +} + +// GetGamepadTypeFromString converts a string to a GamepadType. +func GetGamepadTypeFromString(s string) GamepadType { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + return GamepadType(C.SDL_GetGamepadTypeFromString(cStr)) +} + +// GetGamepadStringForType converts a GamepadType to a string. +func GetGamepadStringForType(t GamepadType) string { + cStr := C.SDL_GetGamepadStringForType(C.SDL_GamepadType(t)) + if cStr == nil { + return "" + } + return C.GoString(cStr) +} + +// Name gets the string name of a gamepad type. +func (t GamepadType) Name() string { + return GetGamepadStringForType(t) +} + +// GetGamepadAxisFromString converts a string to a GamepadAxis. +func GetGamepadAxisFromString(s string) GamepadAxis { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + return GamepadAxis(C.SDL_GetGamepadAxisFromString(cStr)) +} + +// GetGamepadStringForAxis converts a GamepadAxis to a string. +func GetGamepadStringForAxis(axis GamepadAxis) string { + cStr := C.SDL_GetGamepadStringForAxis(C.SDL_GamepadAxis(axis)) + if cStr == nil { + return "" + } + return C.GoString(cStr) +} + +// GetGamepadButtonFromString converts a string to a GamepadButton. +func GetGamepadButtonFromString(s string) GamepadButton { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + return GamepadButton(C.SDL_GetGamepadButtonFromString(cStr)) +} + +// GetGamepadStringForButton converts a GamepadButton to a string. +func GetGamepadStringForButton(button GamepadButton) string { + cStr := C.SDL_GetGamepadStringForButton(C.SDL_GamepadButton(button)) + if cStr == nil { + return "" + } + return C.GoString(cStr) +} + +// GetGamepadButtonLabelForType gets the button label for a button on a gamepad type. +func GetGamepadButtonLabelForType(t GamepadType, button GamepadButton) GamepadButtonLabel { + return GamepadButtonLabel(C.SDL_GetGamepadButtonLabelForType(C.SDL_GamepadType(t), C.SDL_GamepadButton(button))) +} + +// Path gets the implementation-dependent path for an opened gamepad. +func (g *Gamepad) Path() string { + if g == nil || g.cGamepad == nil { + return "" + } + cPath := C.SDL_GetGamepadPath(g.cGamepad) + if cPath == nil { + return "" + } + return C.GoString(cPath) +} + +// RealType gets the type of an opened gamepad, ignoring mapping override. +func (g *Gamepad) RealType() GamepadType { + if g == nil || g.cGamepad == nil { + return GamepadTypeUnknown + } + return GamepadType(C.SDL_GetRealGamepadType(g.cGamepad)) +} + +// Vendor gets the USB vendor ID of an opened gamepad, if available. +func (g *Gamepad) Vendor() uint16 { + if g == nil || g.cGamepad == nil { + return 0 + } + return uint16(C.SDL_GetGamepadVendor(g.cGamepad)) +} + +// Product gets the USB product ID of an opened gamepad, if available. +func (g *Gamepad) Product() uint16 { + if g == nil || g.cGamepad == nil { + return 0 + } + return uint16(C.SDL_GetGamepadProduct(g.cGamepad)) +} + +// ProductVersion gets the product version of an opened gamepad, if available. +func (g *Gamepad) ProductVersion() uint16 { + if g == nil || g.cGamepad == nil { + return 0 + } + return uint16(C.SDL_GetGamepadProductVersion(g.cGamepad)) +} + +// FirmwareVersion gets the firmware version of an opened gamepad, if available. +func (g *Gamepad) FirmwareVersion() uint16 { + if g == nil || g.cGamepad == nil { + return 0 + } + return uint16(C.SDL_GetGamepadFirmwareVersion(g.cGamepad)) +} + +// Serial gets the serial number of an opened gamepad, if available. +func (g *Gamepad) Serial() string { + if g == nil || g.cGamepad == nil { + return "" + } + cSerial := C.SDL_GetGamepadSerial(g.cGamepad) + if cSerial == nil { + return "" + } + return C.GoString(cSerial) +} + +// ConnectionState gets the connection state of an opened gamepad. +func (g *Gamepad) ConnectionState() JoystickConnectionState { + if g == nil || g.cGamepad == nil { + return JoystickConnectionInvalid + } + return JoystickConnectionState(C.SDL_GetGamepadConnectionState(g.cGamepad)) +} + +// PowerInfo gets the battery state of an opened gamepad. +func (g *Gamepad) PowerInfo() (int, int) { + if g == nil || g.cGamepad == nil { + return -1, -1 + } + var percent C.int + state := C.SDL_GetGamepadPowerInfo(g.cGamepad, &percent) + return int(state), int(percent) +} + +// Joystick gets the underlying joystick from an opened gamepad. +func (g *Gamepad) Joystick() *Joystick { + if g == nil || g.cGamepad == nil { + return nil + } + cj := C.SDL_GetGamepadJoystick(g.cGamepad) + if cj == nil { + return nil + } + return &Joystick{cJoystick: cj} +} + +// Mapping gets the current mapping of an opened gamepad. +func (g *Gamepad) Mapping() string { + if g == nil || g.cGamepad == nil { + return "" + } + cMapping := C.SDL_GetGamepadMapping(g.cGamepad) + if cMapping == nil { + return "" + } + defer C.SDL_free(unsafe.Pointer(cMapping)) + return C.GoString(cMapping) +} + +// GetProperties gets the properties associated with an opened gamepad. +func (g *Gamepad) GetProperties() uintptr { + if g == nil || g.cGamepad == nil { + return 0 + } + return uintptr(C.SDL_GetGamepadProperties(g.cGamepad)) +} + +// NumTouchpads gets the number of touchpads on a gamepad. +func (g *Gamepad) NumTouchpads() int { + if g == nil || g.cGamepad == nil { + return -1 + } + return int(C.SDL_GetNumGamepadTouchpads(g.cGamepad)) +} + +// NumTouchpadFingers gets the number of simultaneous fingers supported on a gamepad touchpad. +func (g *Gamepad) NumTouchpadFingers(touchpad int) int { + if g == nil || g.cGamepad == nil { + return -1 + } + return int(C.SDL_GetNumGamepadTouchpadFingers(g.cGamepad, C.int(touchpad))) +} + +// GetTouchpadFinger gets the current state of a finger on a gamepad touchpad. +func (g *Gamepad) GetTouchpadFinger(touchpad, finger int) (bool, bool, float32, float32, float32) { + if g == nil || g.cGamepad == nil { + return false, false, 0, 0, 0 + } + + var down C.bool + var x C.float + var y C.float + var pressure C.float + ok := C.SDL_GetGamepadTouchpadFinger(g.cGamepad, C.int(touchpad), C.int(finger), &down, &x, &y, &pressure) + return bool(ok), bool(down), float32(x), float32(y), float32(pressure) +} + +// HasSensor queries whether a gamepad has a given sensor type. +func (g *Gamepad) HasSensor(sensorType SensorType) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GamepadHasSensor(g.cGamepad, C.SDL_SensorType(sensorType))) +} + +// SetSensorEnabled sets whether sensor data reporting is enabled for a gamepad sensor. +func (g *Gamepad) SetSensorEnabled(sensorType SensorType, enabled bool) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_SetGamepadSensorEnabled(g.cGamepad, C.SDL_SensorType(sensorType), C.bool(enabled))) +} + +// SensorEnabled queries whether sensor data reporting is enabled for a gamepad sensor. +func (g *Gamepad) SensorEnabled(sensorType SensorType) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_GamepadSensorEnabled(g.cGamepad, C.SDL_SensorType(sensorType))) +} + +// SensorDataRate gets the data rate of a gamepad sensor. +func (g *Gamepad) SensorDataRate(sensorType SensorType) float32 { + if g == nil || g.cGamepad == nil { + return 0 + } + return float32(C.SDL_GetGamepadSensorDataRate(g.cGamepad, C.SDL_SensorType(sensorType))) +} + +// GetSensorData gets the current state of a gamepad sensor. +func (g *Gamepad) GetSensorData(sensorType SensorType, values []float32) bool { + if g == nil || g.cGamepad == nil { + return false + } + if len(values) == 0 { + return bool(C.SDL_GetGamepadSensorData(g.cGamepad, C.SDL_SensorType(sensorType), nil, 0)) + } + return bool(C.SDL_GetGamepadSensorData(g.cGamepad, C.SDL_SensorType(sensorType), (*C.float)(unsafe.Pointer(&values[0])), C.int(len(values)))) +} + +// Rumble starts a rumble effect on a gamepad. +func (g *Gamepad) Rumble(lowFrequencyRumble, highFrequencyRumble uint16, durationMs uint32) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_RumbleGamepad(g.cGamepad, C.Uint16(lowFrequencyRumble), C.Uint16(highFrequencyRumble), C.Uint32(durationMs))) +} + +// RumbleTriggers starts a rumble effect in a gamepad's triggers. +func (g *Gamepad) RumbleTriggers(leftRumble, rightRumble uint16, durationMs uint32) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_RumbleGamepadTriggers(g.cGamepad, C.Uint16(leftRumble), C.Uint16(rightRumble), C.Uint32(durationMs))) +} + +// SetLED updates a gamepad's LED color. +func (g *Gamepad) SetLED(red, green, blue uint8) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_SetGamepadLED(g.cGamepad, C.Uint8(red), C.Uint8(green), C.Uint8(blue))) +} + +// SendEffect sends a gamepad-specific effect packet. +func (g *Gamepad) SendEffect(data unsafe.Pointer, size int) bool { + if g == nil || g.cGamepad == nil { + return false + } + return bool(C.SDL_SendGamepadEffect(g.cGamepad, data, C.int(size))) +} + +// AppleSFSymbolsNameForButton gets the Apple sfSymbolsName for a gamepad button. +func (g *Gamepad) AppleSFSymbolsNameForButton(button GamepadButton) string { + if g == nil || g.cGamepad == nil { + return "" + } + cName := C.SDL_GetGamepadAppleSFSymbolsNameForButton(g.cGamepad, C.SDL_GamepadButton(button)) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// AppleSFSymbolsNameForAxis gets the Apple sfSymbolsName for a gamepad axis. +func (g *Gamepad) AppleSFSymbolsNameForAxis(axis GamepadAxis) string { + if g == nil || g.cGamepad == nil { + return "" + } + cName := C.SDL_GetGamepadAppleSFSymbolsNameForAxis(g.cGamepad, C.SDL_GamepadAxis(axis)) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// GetBindings gets joystick-layer bindings for an opened gamepad. +func (g *Gamepad) GetBindings() ([]GamepadBinding, error) { + if g == nil || g.cGamepad == nil { + return nil, &SDLError{eStr: "invalid gamepad handle"} + } + + var count C.int + cBindings := C.SDL_GetGamepadBindings(g.cGamepad, &count) + if cBindings == nil { + if count == 0 { + return []GamepadBinding{}, nil + } + return nil, GetError() + } + defer C.SDL_free(unsafe.Pointer(cBindings)) + + cSlice := unsafe.Slice(cBindings, int(count)) + bindings := make([]GamepadBinding, 0, int(count)) + for _, cb := range cSlice { + if cb == nil { + continue + } + + bindings = append(bindings, GamepadBinding{ + InputType: GamepadBindingType(cb.input_type), + InputButton: int(C.gamepad_binding_input_button(cb)), + InputAxis: int(C.gamepad_binding_input_axis(cb)), + InputAxisMin: int(C.gamepad_binding_input_axis_min(cb)), + InputAxisMax: int(C.gamepad_binding_input_axis_max(cb)), + InputHat: int(C.gamepad_binding_input_hat(cb)), + InputHatMask: int(C.gamepad_binding_input_hat_mask(cb)), + + OutputType: GamepadBindingType(cb.output_type), + OutputButton: GamepadButton(C.gamepad_binding_output_button(cb)), + OutputAxis: GamepadAxis(C.gamepad_binding_output_axis(cb)), + OutputAxisMin: int(C.gamepad_binding_output_axis_min(cb)), + OutputAxisMax: int(C.gamepad_binding_output_axis_max(cb)), + }) + } + + return bindings, nil +} diff --git a/_testing/e2e/sdl/joystick.go b/_testing/e2e/sdl/joystick.go new file mode 100644 index 00000000..905d5a1a --- /dev/null +++ b/_testing/e2e/sdl/joystick.go @@ -0,0 +1,590 @@ +package sdl + +/* +#cgo CFLAGS: -I${SRCDIR}/../deps/SDL/include + +#include + +#include +#include +*/ +import "C" + +import ( + "unsafe" +) + +// GUID is a 128-bit identifier for an input device that identifies that device across runs of SDL programs on the same platform. +type GUID [16]byte + +// String converts a GUID to an ASCII string representation. +func (g GUID) String() string { + var buf [33]C.char + cg := *(*C.SDL_GUID)(unsafe.Pointer(&g)) + C.SDL_GUIDToString(cg, &buf[0], C.int(len(buf))) + return C.GoString(&buf[0]) +} + +// StringToGUID converts a GUID string into a GUID structure. +func StringToGUID(s string) GUID { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + cg := C.SDL_StringToGUID(cStr) + return *(*GUID)(unsafe.Pointer(&cg)) +} + +// JoystickID is a unique ID for a joystick for the time it is connected to the system, and is never reused for the lifetime of the application. +type JoystickID uint32 + +// JoystickType is an enum of some common joystick types. +type JoystickType uint32 + +// JoystickConnectionState is the possible connection states for a joystick device. +type JoystickConnectionState int32 + +// JoystickType values report common low-level joystick types. +const ( + JoystickTypeUnknown JoystickType = C.SDL_JOYSTICK_TYPE_UNKNOWN + JoystickTypeGamepad JoystickType = C.SDL_JOYSTICK_TYPE_GAMEPAD + JoystickTypeWheel JoystickType = C.SDL_JOYSTICK_TYPE_WHEEL + JoystickTypeArcadeStick JoystickType = C.SDL_JOYSTICK_TYPE_ARCADE_STICK + JoystickTypeFlightStick JoystickType = C.SDL_JOYSTICK_TYPE_FLIGHT_STICK + JoystickTypeDancePad JoystickType = C.SDL_JOYSTICK_TYPE_DANCE_PAD + JoystickTypeGuitar JoystickType = C.SDL_JOYSTICK_TYPE_GUITAR + JoystickTypeDrumKit JoystickType = C.SDL_JOYSTICK_TYPE_DRUM_KIT + JoystickTypeArcadePad JoystickType = C.SDL_JOYSTICK_TYPE_ARCADE_PAD + JoystickTypeThrottle JoystickType = C.SDL_JOYSTICK_TYPE_THROTTLE +) + +// JoystickConnectionState values report how a joystick is connected to the system. +const ( + JoystickConnectionInvalid JoystickConnectionState = C.SDL_JOYSTICK_CONNECTION_INVALID + JoystickConnectionUnknown JoystickConnectionState = C.SDL_JOYSTICK_CONNECTION_UNKNOWN + JoystickConnectionWired JoystickConnectionState = C.SDL_JOYSTICK_CONNECTION_WIRED + JoystickConnectionWireless JoystickConnectionState = C.SDL_JOYSTICK_CONNECTION_WIRELESS +) + +// JoystickAxisMax and JoystickAxisMin are the largest and smallest values a joystick axis can report. +const ( + JoystickAxisMax = C.SDL_JOYSTICK_AXIS_MAX + JoystickAxisMin = C.SDL_JOYSTICK_AXIS_MIN +) + +// Joystick is the joystick structure used to identify an SDL joystick. +// +// This is opaque data. +type Joystick struct { + cJoystick *C.SDL_Joystick +} + +// InitJoystickSubSystem initializes the joystick subsystem. +// +// The joystick subsystem must be initialized before a joystick can be opened for use. +func InitJoystickSubSystem() error { + return InitSubSystem(InitFlagJoystick) +} + +// QuitJoystickSubSystem shuts down the joystick subsystem. +func QuitJoystickSubSystem() { + QuitSubSystem(InitFlagJoystick) +} + +// LockJoysticks locks the joysticks while processing. +func LockJoysticks() { + C.SDL_LockJoysticks() +} + +// TryLockJoysticks attempts to lock the joysticks while processing. +func TryLockJoysticks() bool { + return bool(C.SDL_TryLockJoysticks()) +} + +// UnlockJoysticks unlocks the joysticks. +func UnlockJoysticks() { + C.SDL_UnlockJoysticks() +} + +// HasJoystick returns whether a joystick is currently connected. +func HasJoystick() bool { + return bool(C.SDL_HasJoystick()) +} + +// GetJoysticks returns a list of currently connected joysticks. +func GetJoysticks() ([]JoystickID, error) { + var count C.int + cIDs := C.SDL_GetJoysticks(&count) + if cIDs == nil { + if count == 0 { + return []JoystickID{}, nil + } + return nil, GetError() + } + defer C.SDL_free(unsafe.Pointer(cIDs)) + + cSlice := unsafe.Slice(cIDs, int(count)) + ids := make([]JoystickID, 0, int(count)) + for _, id := range cSlice { + ids = append(ids, JoystickID(id)) + } + return ids, nil +} + +// GetJoystickNameForID gets the implementation dependent name of a joystick. +// +// This can be called before any joysticks are opened. +func GetJoystickNameForID(instanceID JoystickID) string { + cName := C.SDL_GetJoystickNameForID(C.SDL_JoystickID(instanceID)) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// GetJoystickPathForID gets the implementation dependent path of a joystick. +// +// This can be called before any joysticks are opened. +func GetJoystickPathForID(instanceID JoystickID) string { + cPath := C.SDL_GetJoystickPathForID(C.SDL_JoystickID(instanceID)) + if cPath == nil { + return "" + } + return C.GoString(cPath) +} + +// GetJoystickPlayerIndexForID gets the player index of a joystick. +// +// This can be called before any joysticks are opened. +func GetJoystickPlayerIndexForID(instanceID JoystickID) int { + return int(C.SDL_GetJoystickPlayerIndexForID(C.SDL_JoystickID(instanceID))) +} + +// GetJoystickGUIDForID gets the implementation-dependent GUID of a joystick. +// +// This can be called before any joysticks are opened. +func GetJoystickGUIDForID(instanceID JoystickID) GUID { + cg := C.SDL_GetJoystickGUIDForID(C.SDL_JoystickID(instanceID)) + return *(*GUID)(unsafe.Pointer(&cg)) +} + +// GetJoystickVendorForID gets the USB vendor ID of a joystick, if available. +// +// This can be called before any joysticks are opened. +func GetJoystickVendorForID(instanceID JoystickID) uint16 { + return uint16(C.SDL_GetJoystickVendorForID(C.SDL_JoystickID(instanceID))) +} + +// GetJoystickProductForID gets the USB product ID of a joystick, if available. +// +// This can be called before any joysticks are opened. +func GetJoystickProductForID(instanceID JoystickID) uint16 { + return uint16(C.SDL_GetJoystickProductForID(C.SDL_JoystickID(instanceID))) +} + +// GetJoystickProductVersionForID gets the product version of a joystick, if available. +// +// This can be called before any joysticks are opened. +func GetJoystickProductVersionForID(instanceID JoystickID) uint16 { + return uint16(C.SDL_GetJoystickProductVersionForID(C.SDL_JoystickID(instanceID))) +} + +// GetJoystickTypeForID gets the type of a joystick, if available. +// +// This can be called before any joysticks are opened. +func GetJoystickTypeForID(instanceID JoystickID) JoystickType { + return JoystickType(C.SDL_GetJoystickTypeForID(C.SDL_JoystickID(instanceID))) +} + +// OpenJoystick opens a joystick for use. +// +// The joystick subsystem must be initialized before a joystick can be opened for use. +func OpenJoystick(instanceID JoystickID) (*Joystick, error) { + cj := C.SDL_OpenJoystick(C.SDL_JoystickID(instanceID)) + if cj == nil { + return nil, GetError() + } + return &Joystick{cJoystick: cj}, nil +} + +// GetJoystickFromID gets the SDL_Joystick associated with an instance ID, if it has been opened. +func GetJoystickFromID(instanceID JoystickID) (*Joystick, bool) { + cj := C.SDL_GetJoystickFromID(C.SDL_JoystickID(instanceID)) + if cj == nil { + return nil, false + } + return &Joystick{cJoystick: cj}, true +} + +// GetJoystickFromPlayerIndex gets the SDL_Joystick associated with a player index. +func GetJoystickFromPlayerIndex(playerIndex int) (*Joystick, bool) { + cj := C.SDL_GetJoystickFromPlayerIndex(C.int(playerIndex)) + if cj == nil { + return nil, false + } + return &Joystick{cJoystick: cj}, true +} + +// AttachVirtualJoystick attaches a new virtual joystick. +func AttachVirtualJoystick(desc unsafe.Pointer) JoystickID { + return JoystickID(C.SDL_AttachVirtualJoystick((*C.SDL_VirtualJoystickDesc)(desc))) +} + +// DetachVirtualJoystick detaches a virtual joystick. +func DetachVirtualJoystick(instanceID JoystickID) bool { + return bool(C.SDL_DetachVirtualJoystick(C.SDL_JoystickID(instanceID))) +} + +// IsJoystickVirtual queries whether or not a joystick is virtual. +func IsJoystickVirtual(instanceID JoystickID) bool { + return bool(C.SDL_IsJoystickVirtual(C.SDL_JoystickID(instanceID))) +} + +// SetJoystickEventsEnabled sets the state of joystick event processing. +func SetJoystickEventsEnabled(enabled bool) { + C.SDL_SetJoystickEventsEnabled(C.bool(enabled)) +} + +// JoystickEventsEnabled queries the state of joystick event processing. +func JoystickEventsEnabled() bool { + return bool(C.SDL_JoystickEventsEnabled()) +} + +// UpdateJoysticks updates the current state of the open joysticks. +func UpdateJoysticks() { + C.SDL_UpdateJoysticks() +} + +// Close closes a joystick previously opened with SDL_OpenJoystick(). +func (j *Joystick) Close() { + if j == nil || j.cJoystick == nil { + return + } + C.SDL_CloseJoystick(j.cJoystick) + j.cJoystick = nil +} + +// Connected gets the status of a specified joystick. +func (j *Joystick) Connected() bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_JoystickConnected(j.cJoystick)) +} + +// ID gets the instance ID of an opened joystick. +func (j *Joystick) ID() JoystickID { + if j == nil || j.cJoystick == nil { + return 0 + } + return JoystickID(C.SDL_GetJoystickID(j.cJoystick)) +} + +// Name gets the implementation dependent name of a joystick. +func (j *Joystick) Name() string { + if j == nil || j.cJoystick == nil { + return "" + } + cName := C.SDL_GetJoystickName(j.cJoystick) + if cName == nil { + return "" + } + return C.GoString(cName) +} + +// Path gets the implementation dependent path of a joystick. +func (j *Joystick) Path() string { + if j == nil || j.cJoystick == nil { + return "" + } + cPath := C.SDL_GetJoystickPath(j.cJoystick) + if cPath == nil { + return "" + } + return C.GoString(cPath) +} + +// PlayerIndex gets the player index of an opened joystick. +func (j *Joystick) PlayerIndex() int { + if j == nil || j.cJoystick == nil { + return -1 + } + return int(C.SDL_GetJoystickPlayerIndex(j.cJoystick)) +} + +// SetPlayerIndex sets the player index of an opened joystick. +func (j *Joystick) SetPlayerIndex(playerIndex int) error { + if j == nil || j.cJoystick == nil { + return &SDLError{eStr: "invalid joystick handle"} + } + if !C.SDL_SetJoystickPlayerIndex(j.cJoystick, C.int(playerIndex)) { + return GetError() + } + return nil +} + +// GUID gets the implementation-dependent GUID for the joystick. +func (j *Joystick) GUID() GUID { + if j == nil || j.cJoystick == nil { + return GUID{} + } + cg := C.SDL_GetJoystickGUID(j.cJoystick) + return *(*GUID)(unsafe.Pointer(&cg)) +} + +// Vendor gets the USB vendor ID of an opened joystick, if available. +func (j *Joystick) Vendor() uint16 { + if j == nil || j.cJoystick == nil { + return 0 + } + return uint16(C.SDL_GetJoystickVendor(j.cJoystick)) +} + +// Product gets the USB product ID of an opened joystick, if available. +func (j *Joystick) Product() uint16 { + if j == nil || j.cJoystick == nil { + return 0 + } + return uint16(C.SDL_GetJoystickProduct(j.cJoystick)) +} + +// ProductVersion gets the product version of an opened joystick, if available. +func (j *Joystick) ProductVersion() uint16 { + if j == nil || j.cJoystick == nil { + return 0 + } + return uint16(C.SDL_GetJoystickProductVersion(j.cJoystick)) +} + +// FirmwareVersion gets the firmware version of an opened joystick, if available. +func (j *Joystick) FirmwareVersion() uint16 { + if j == nil || j.cJoystick == nil { + return 0 + } + return uint16(C.SDL_GetJoystickFirmwareVersion(j.cJoystick)) +} + +// Serial gets the serial number of an opened joystick, if available. +func (j *Joystick) Serial() string { + if j == nil || j.cJoystick == nil { + return "" + } + cSerial := C.SDL_GetJoystickSerial(j.cJoystick) + if cSerial == nil { + return "" + } + return C.GoString(cSerial) +} + +// Type gets the type of an opened joystick. +func (j *Joystick) Type() JoystickType { + if j == nil || j.cJoystick == nil { + return JoystickTypeUnknown + } + return JoystickType(C.SDL_GetJoystickType(j.cJoystick)) +} + +// NumAxes gets the number of general axis controls on a joystick. +func (j *Joystick) NumAxes() int { + if j == nil || j.cJoystick == nil { + return -1 + } + return int(C.SDL_GetNumJoystickAxes(j.cJoystick)) +} + +// NumBalls gets the number of trackballs on a joystick. +func (j *Joystick) NumBalls() int { + if j == nil || j.cJoystick == nil { + return -1 + } + return int(C.SDL_GetNumJoystickBalls(j.cJoystick)) +} + +// NumHats gets the number of POV hats on a joystick. +func (j *Joystick) NumHats() int { + if j == nil || j.cJoystick == nil { + return -1 + } + return int(C.SDL_GetNumJoystickHats(j.cJoystick)) +} + +// NumButtons gets the number of buttons on a joystick. +func (j *Joystick) NumButtons() int { + if j == nil || j.cJoystick == nil { + return -1 + } + return int(C.SDL_GetNumJoystickButtons(j.cJoystick)) +} + +// GetAxis gets the current state of an axis control on a joystick. +func (j *Joystick) GetAxis(axis int) int16 { + if j == nil || j.cJoystick == nil { + return 0 + } + return int16(C.SDL_GetJoystickAxis(j.cJoystick, C.int(axis))) +} + +// GetAxisInitialState gets the initial state of an axis control on a joystick. +func (j *Joystick) GetAxisInitialState(axis int) (bool, int16) { + if j == nil || j.cJoystick == nil { + return false, 0 + } + var state C.Sint16 + hasState := C.SDL_GetJoystickAxisInitialState(j.cJoystick, C.int(axis), &state) + return bool(hasState), int16(state) +} + +// GetBall gets the ball axis change since the last poll. +func (j *Joystick) GetBall(ball int) (bool, int, int) { + if j == nil || j.cJoystick == nil { + return false, 0, 0 + } + var dx, dy C.int + ok := C.SDL_GetJoystickBall(j.cJoystick, C.int(ball), &dx, &dy) + return bool(ok), int(dx), int(dy) +} + +// GetHat gets the current state of a POV hat on a joystick. +func (j *Joystick) GetHat(hat int) uint8 { + if j == nil || j.cJoystick == nil { + return 0 + } + return uint8(C.SDL_GetJoystickHat(j.cJoystick, C.int(hat))) +} + +// GetButton gets the current state of a button on a joystick. +func (j *Joystick) GetButton(button int) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_GetJoystickButton(j.cJoystick, C.int(button))) +} + +// Rumble starts a rumble effect. +func (j *Joystick) Rumble(lowFrequencyRumble, highFrequencyRumble uint16, durationMs uint32) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_RumbleJoystick(j.cJoystick, C.Uint16(lowFrequencyRumble), C.Uint16(highFrequencyRumble), C.Uint32(durationMs))) +} + +// RumbleTriggers starts a rumble effect in the joystick's triggers. +func (j *Joystick) RumbleTriggers(leftRumble, rightRumble uint16, durationMs uint32) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_RumbleJoystickTriggers(j.cJoystick, C.Uint16(leftRumble), C.Uint16(rightRumble), C.Uint32(durationMs))) +} + +// SetLED updates a joystick's LED color. +func (j *Joystick) SetLED(red, green, blue uint8) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickLED(j.cJoystick, C.Uint8(red), C.Uint8(green), C.Uint8(blue))) +} + +// SendEffect sends a joystick specific effect packet. +func (j *Joystick) SendEffect(data unsafe.Pointer, size int) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SendJoystickEffect(j.cJoystick, data, C.int(size))) +} + +// ConnectionState gets the connection state of a joystick. +func (j *Joystick) ConnectionState() JoystickConnectionState { + if j == nil || j.cJoystick == nil { + return JoystickConnectionInvalid + } + return JoystickConnectionState(C.SDL_GetJoystickConnectionState(j.cJoystick)) +} + +// PowerInfo gets the battery state of a joystick. +func (j *Joystick) PowerInfo() (int, int) { + if j == nil || j.cJoystick == nil { + return -1, -1 + } + var percent C.int + state := C.SDL_GetJoystickPowerInfo(j.cJoystick, &percent) + return int(state), int(percent) +} + +// GetProperties gets the properties associated with a joystick. +func (j *Joystick) GetProperties() uintptr { + if j == nil || j.cJoystick == nil { + return 0 + } + return uintptr(C.SDL_GetJoystickProperties(j.cJoystick)) +} + +// GetJoystickGUIDInfo gets the device information encoded in a GUID structure. +func GetJoystickGUIDInfo(guid GUID) (vendor, product, version, crc16 uint16) { + var cVendor, cProduct, cVersion, cCRC16 C.Uint16 + cg := *(*C.SDL_GUID)(unsafe.Pointer(&guid)) + C.SDL_GetJoystickGUIDInfo(cg, &cVendor, &cProduct, &cVersion, &cCRC16) + return uint16(cVendor), uint16(cProduct), uint16(cVersion), uint16(cCRC16) +} + +// SetVirtualAxis sets the state of an axis on an opened virtual joystick. +func (j *Joystick) SetVirtualAxis(axis int, value int16) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickVirtualAxis(j.cJoystick, C.int(axis), C.Sint16(value))) +} + +// SetVirtualBall generates ball motion on an opened virtual joystick. +func (j *Joystick) SetVirtualBall(ball int, xRel, yRel int16) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickVirtualBall(j.cJoystick, C.int(ball), C.Sint16(xRel), C.Sint16(yRel))) +} + +// SetVirtualButton sets the state of a button on an opened virtual joystick. +func (j *Joystick) SetVirtualButton(button int, down bool) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickVirtualButton(j.cJoystick, C.int(button), C.bool(down))) +} + +// SetVirtualHat sets the state of a hat on an opened virtual joystick. +func (j *Joystick) SetVirtualHat(hat int, value uint8) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickVirtualHat(j.cJoystick, C.int(hat), C.Uint8(value))) +} + +// SetVirtualTouchpad sets touchpad finger state on an opened virtual joystick. +func (j *Joystick) SetVirtualTouchpad(touchpad, finger int, down bool, x, y, pressure float32) bool { + if j == nil || j.cJoystick == nil { + return false + } + return bool(C.SDL_SetJoystickVirtualTouchpad( + j.cJoystick, + C.int(touchpad), + C.int(finger), + C.bool(down), + C.float(x), + C.float(y), + C.float(pressure), + )) +} + +// SendVirtualSensorData sends a sensor update for an opened virtual joystick. +func (j *Joystick) SendVirtualSensorData(sensorType int32, sensorTimestamp uint64, values []float32) bool { + if j == nil || j.cJoystick == nil { + return false + } + if len(values) == 0 { + return bool(C.SDL_SendJoystickVirtualSensorData(j.cJoystick, C.SDL_SensorType(sensorType), C.Uint64(sensorTimestamp), nil, 0)) + } + return bool(C.SDL_SendJoystickVirtualSensorData( + j.cJoystick, + C.SDL_SensorType(sensorType), + C.Uint64(sensorTimestamp), + (*C.float)(unsafe.Pointer(&values[0])), + C.int(len(values)), + )) +} diff --git a/_testing/e2e/sdl/sdl.go b/_testing/e2e/sdl/sdl.go new file mode 100644 index 00000000..bbbfa33d --- /dev/null +++ b/_testing/e2e/sdl/sdl.go @@ -0,0 +1,80 @@ +package sdl + +/* +#cgo CFLAGS: -I${SRCDIR}/../deps/SDL/include +#cgo LDFLAGS: -L${SRCDIR}/../deps/SDL/build/Debug -lSDL3 + +#include + +#include +#include +*/ +import "C" +import "runtime" + +func init() { + runtime.LockOSThread() +} + +// InitFlags for SDL_Init and/or SDL_InitSubSystem. +// +// These are the flags which may be passed to SDL_Init(). You should specify +// the subsystems which you will be using in your application. +type InitFlags uint32 + +// These flags may be passed to SDL_Init(). +const ( + InitFlagAudio InitFlags = C.SDL_INIT_AUDIO + InitFlagVideo InitFlags = C.SDL_INIT_VIDEO + InitFlagJoystick InitFlags = C.SDL_INIT_JOYSTICK + InitFlagHaptic InitFlags = C.SDL_INIT_HAPTIC + InitFlagGamepad InitFlags = C.SDL_INIT_GAMEPAD + InitFlagEvents InitFlags = C.SDL_INIT_EVENTS + InitFlagSensor InitFlags = C.SDL_INIT_SENSOR + InitFlagCamera InitFlags = C.SDL_INIT_CAMERA +) + +// Init initializes the SDL library. +// +// Init simply forwards to calling SDL_InitSubSystem(). Therefore, the +// two may be used interchangeably. +// +// Subsystem initialization is ref-counted; call QuitSubSystem() for each +// InitSubSystem(), or call Quit() to force shutdown. +// +// This function should only be called on the main thread. +func Init(flags InitFlags) error { + res := C.SDL_Init(C.Uint32(flags)) + if !res { + return GetError() + } + return nil +} + +// InitSubSystem compatibility function to initialize the SDL library. +// +// This function and SDL_Init() are interchangeable. +// +// This function should only be called on the main thread. +func InitSubSystem(flags InitFlags) error { + res := C.SDL_InitSubSystem(C.Uint32(flags)) + if !res { + return GetError() + } + return nil +} + +// QuitSubSystem shuts down specific SDL subsystems. +// +// You still need to call SDL_Quit() even if you close all open subsystems +// with SDL_QuitSubSystem(). +func QuitSubSystem(flags InitFlags) { + C.SDL_QuitSubSystem(C.Uint32(flags)) +} + +// Quit cleans up all initialized subsystems. +// +// This function should only be called on the main thread. +func Quit() { + C.SDL_Quit() +} diff --git a/_testing/test_server.go b/_testing/test_server.go new file mode 100644 index 00000000..d8ab6e8a --- /dev/null +++ b/_testing/test_server.go @@ -0,0 +1,83 @@ +package testing + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/cmd" + "github.com/Alia5/VIIPER/internal/config" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" +) + +// IntegrationTimeout covers the real localhost API and USB/IP hops used by +// device tests. Hosted runners can legitimately schedule either side late. +const IntegrationTimeout = 2 * time.Second + +type MockServer struct { + ApiServer *api.Server + UsbServer *usb.Server +} + +func NewTestServerWithConfig(t *testing.T, cfg *config.CLI) *MockServer { + t.Helper() + + logger := slog.Default() + + usbServer := usb.New(cfg.Server.USBServerConfig, logger, nil) + + usbErrCh := make(chan error, 1) + go func() { + usbErrCh <- usbServer.ListenAndServe() + }() + select { + case <-usbServer.Ready(): + // ok + case err := <-usbErrCh: + if err == nil { + err = io.ErrUnexpectedEOF + } + t.Fatalf("USB server failed to start: %v", err) + case <-time.After(2 * time.Second): + t.Fatalf("USB server did not become ready") + } + + return &MockServer{ + UsbServer: usbServer, + ApiServer: api.New( + usbServer, + cfg.Server.APIServerConfig.Addr, + cfg.Server.APIServerConfig, + logger, + ), + } +} + +func NewTestServer(t *testing.T) *MockServer { + t.Helper() + + cfg := TestServerConfig(t) + return NewTestServerWithConfig(t, cfg) +} + +func TestServerConfig(t *testing.T) *config.CLI { + t.Helper() + + return &config.CLI{ + Server: cmd.Server{ + USBServerConfig: usb.ServerConfig{ + Addr: "localhost:0", + ConnectionTimeout: 1 * time.Second, + BusCleanupTimeout: 1 * time.Second, + }, + APIServerConfig: api.ServerConfig{ + Addr: "localhost:0", + DeviceHandlerConnectTimeout: 1 * time.Second, + ConnectionTimeout: 1 * time.Second, + AutoAttachLocalClient: false, + }, + }, + } +} diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go new file mode 100644 index 00000000..31c356fc --- /dev/null +++ b/_testing/usbip_client.go @@ -0,0 +1,389 @@ +package testing + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/Alia5/VIIPER/usbip" +) + +type TestUsbIpClient struct { + address string + seq uint32 +} + +type Device struct { + Path string + BusID string + BusNum uint32 + DeviceNum uint32 + Speed uint32 + IDVendor uint16 + IDProduct uint16 + BcdDevice uint16 + Class uint8 + SubClass uint8 + Protocol uint8 + ConfigVal uint8 + NumConfigs uint8 + NumIfaces uint8 + Interfaces []usbip.InterfaceDesc +} + +type ImportResult struct { + Conn net.Conn + Exported Device + RawDescriptor []byte +} + +func NewUsbIpClient(t *testing.T, addr string) *TestUsbIpClient { + t.Helper() + + return &TestUsbIpClient{ + address: addr, + } +} + +func (c *TestUsbIpClient) nextSeq() uint32 { + // USBIP seqnum only needs to be unique within the session; tests use a single + // client per test and the server doesn't require a specific starting value. + return atomic.AddUint32(&c.seq, 1) - 1 +} + +func (c *TestUsbIpClient) ListDevices() ([]Device, error) { + conn, err := net.Dial("tcp", c.address) + if err != nil { + return nil, err + } + defer conn.Close() //nolint:errcheck + + if err := (&usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpReqDevlist}).Write(conn); err != nil { + return nil, err + } + + var hdr [12]byte + if err := usbip.ReadExactly(conn, hdr[:]); err != nil { + return nil, err + } + + if v := binary.BigEndian.Uint16(hdr[0:2]); v != usbip.Version { + return nil, fmt.Errorf("unexpected usbip version %x", v) + } + if cmd := binary.BigEndian.Uint16(hdr[2:4]); cmd != usbip.OpRepDevlist { + return nil, fmt.Errorf("unexpected reply command %x", cmd) + } + + n := binary.BigEndian.Uint32(hdr[8:12]) + devices := make([]Device, 0, n) + for i := uint32(0); i < n; i++ { + dev, err := readExportedDevice(conn) + if err != nil { + return nil, err + } + devices = append(devices, dev) + } + + return devices, nil +} + +func (c *TestUsbIpClient) AttachDevice(busID string) (*ImportResult, error) { + conn, err := net.Dial("tcp", c.address) + if err != nil { + return nil, err + } + + if err := (&usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpReqImport}).Write(conn); err != nil { + conn.Close() + return nil, err + } + + var bus [32]byte + copy(bus[:], busID) + if _, err := conn.Write(bus[:]); err != nil { + conn.Close() + return nil, err + } + + var hdr [8]byte + if err := usbip.ReadExactly(conn, hdr[:]); err != nil { + conn.Close() + return nil, err + } + if v := binary.BigEndian.Uint16(hdr[0:2]); v != usbip.Version { + conn.Close() + return nil, fmt.Errorf("unexpected usbip version %x", v) + } + if cmd := binary.BigEndian.Uint16(hdr[2:4]); cmd != usbip.OpRepImport { + conn.Close() + return nil, fmt.Errorf("unexpected reply command %x", cmd) + } + + dev, raw, err := readExportedDeviceImportWithRaw(conn) + if err != nil { + conn.Close() + return nil, err + } + + return &ImportResult{Conn: conn, Exported: dev, RawDescriptor: raw}, nil +} + +func readExportedDevice(r net.Conn) (Device, error) { + dev, _, err := readExportedDeviceWithRaw(r) + return dev, err +} + +func readExportedDeviceImportWithRaw(r net.Conn) (Device, []byte, error) { + return readExportedDeviceWithRawInternal(r, false) +} + +func readExportedDeviceWithRaw(r net.Conn) (Device, []byte, error) { + return readExportedDeviceWithRawInternal(r, true) +} + +func readExportedDeviceWithRawInternal(r net.Conn, readIfaces bool) (Device, []byte, error) { + var base [312]byte + if err := usbip.ReadExactly(r, base[:]); err != nil { + return Device{}, nil, err + } + + pathField := base[0:256] + busField := base[256:288] + + pathEnd := bytes.IndexByte(pathField, 0) + if pathEnd == -1 { + pathEnd = len(pathField) + } + busEnd := bytes.IndexByte(busField, 0) + if busEnd == -1 { + busEnd = len(busField) + } + + busNum := binary.BigEndian.Uint32(base[288:292]) + devNum := binary.BigEndian.Uint32(base[292:296]) + speed := binary.BigEndian.Uint32(base[296:300]) + idVendor := binary.BigEndian.Uint16(base[300:302]) + idProduct := binary.BigEndian.Uint16(base[302:304]) + bcdDevice := binary.BigEndian.Uint16(base[304:306]) + class := base[306] + subClass := base[307] + proto := base[308] + confVal := base[309] + nConf := base[310] + nIf := base[311] + + ifaces := make([]usbip.InterfaceDesc, 0, nIf) + if readIfaces && nIf > 0 { + ifaceBuf := make([]byte, int(nIf)*4) + if err := usbip.ReadExactly(r, ifaceBuf); err != nil { + return Device{}, nil, err + } + for i := 0; i < int(nIf); i++ { + o := i * 4 + ifaces = append(ifaces, usbip.InterfaceDesc{ + Class: ifaceBuf[o], + SubClass: ifaceBuf[o+1], + Protocol: ifaceBuf[o+2], + }) + } + } + + return Device{ + Path: string(pathField[:pathEnd]), + BusID: string(busField[:busEnd]), + BusNum: busNum, + DeviceNum: devNum, + Speed: speed, + IDVendor: idVendor, + IDProduct: idProduct, + BcdDevice: bcdDevice, + Class: class, + SubClass: subClass, + Protocol: proto, + ConfigVal: confVal, + NumConfigs: nConf, + NumIfaces: nIf, + Interfaces: ifaces, + }, base[:], nil +} + +func (c *TestUsbIpClient) Submit(conn net.Conn, dir uint32, ep uint32, outPayload []byte, setup *[8]byte) error { + return c.SubmitWithTimeout(conn, dir, ep, outPayload, setup, IntegrationTimeout) +} + +func (c *TestUsbIpClient) SubmitWithTimeout(conn net.Conn, dir uint32, ep uint32, outPayload []byte, setup *[8]byte, timeout time.Duration) error { + if conn == nil { + return io.ErrUnexpectedEOF + } + + var setupBytes [8]byte + if setup != nil { + setupBytes = *setup + } + + cur := c.nextSeq() + + cmd := usbip.CmdSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.CmdSubmitCode, Seqnum: cur, Devid: 0, Dir: dir, Ep: ep}, + TransferFlags: 0, + TransferBufferLen: uint32(len(outPayload)), + StartFrame: 0, + NumberOfPackets: -1, + Interval: 0, + Setup: setupBytes, + } + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if err := cmd.Write(conn); err != nil { + return err + } + if len(outPayload) > 0 { + if _, err := conn.Write(outPayload); err != nil { + return err + } + } + + var retHdr [48]byte + if err := usbip.ReadExactly(conn, retHdr[:]); err != nil { + return err + } + if gotCmd := binary.BigEndian.Uint32(retHdr[0:4]); gotCmd != usbip.RetSubmitCode { + return fmt.Errorf("unexpected ret cmd %x", gotCmd) + } + status := int32(binary.BigEndian.Uint32(retHdr[20:24])) + actual := binary.BigEndian.Uint32(retHdr[24:28]) + if status != 0 { + return fmt.Errorf("ret status %d", status) + } + + if dir == usbip.DirIn && actual > 0 { + discard := make([]byte, int(actual)) + if err := usbip.ReadExactly(conn, discard); err != nil { + return err + } + } + _ = conn.SetDeadline(time.Time{}) + return nil +} + +func (c *TestUsbIpClient) ReadInputReport(conn net.Conn) ([]byte, error) { + return c.ReadInputReportWithTimeout(conn, 250*time.Millisecond) +} + +func (c *TestUsbIpClient) ReadInputReportWithTimeout(conn net.Conn, timeout time.Duration) ([]byte, error) { + if conn == nil { + return nil, io.ErrUnexpectedEOF + } + cur := c.nextSeq() + + // Request a buffer large enough for all current VIIPER HID devices. + // (Keyboard reports are 34 bytes; mouse/xbox360 are smaller.) + const inMax = 255 + + cmd := usbip.CmdSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.CmdSubmitCode, Seqnum: cur, Devid: 0, Dir: usbip.DirIn, Ep: 1}, + TransferFlags: 0, + TransferBufferLen: inMax, + StartFrame: 0, + NumberOfPackets: -1, + Interval: 0, + Setup: [8]byte{}, + } + _ = conn.SetDeadline(time.Now().Add(timeout)) + if err := cmd.Write(conn); err != nil { + return nil, err + } + + var retHdr [48]byte + if err := usbip.ReadExactly(conn, retHdr[:]); err != nil { + return nil, err + } + if gotCmd := binary.BigEndian.Uint32(retHdr[0:4]); gotCmd != usbip.RetSubmitCode { + return nil, fmt.Errorf("unexpected ret cmd %x", gotCmd) + } + status := int32(binary.BigEndian.Uint32(retHdr[20:24])) + actual := binary.BigEndian.Uint32(retHdr[24:28]) + if status != 0 { + return nil, fmt.Errorf("ret status %d", status) + } + data := make([]byte, int(actual)) + if actual > 0 { + if err := usbip.ReadExactly(conn, data); err != nil { + return nil, err + } + } + _ = conn.SetDeadline(time.Time{}) + return data, nil +} + +func (c *TestUsbIpClient) PollInputReport(conn net.Conn, want []byte, timeout time.Duration) ([]byte, error) { + wantAllZero := true + for _, b := range want { + if b != 0 { + wantAllZero = false + break + } + } + if wantAllZero { + deadline := time.Now().Add(timeout) + var last []byte + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return last, nil + } + got, err := c.ReadInputReportWithTimeout(conn, remaining) + if err != nil { + return nil, err + } + last = got + if len(got) == len(want) && bytes.Equal(got, want) { + return got, nil + } + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + } + } + + deadline := time.Now().Add(timeout) + var last []byte + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return last, nil + } + got, err := c.ReadInputReportWithTimeout(conn, remaining) + if err != nil { + return nil, err + } + last = got + if len(got) == len(want) && bytes.Equal(got, want) { + return got, nil + } + allZero := true + for _, b := range got { + if b != 0 { + allZero = false + break + } + } + if !allZero { + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + continue + } + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + } +} diff --git a/cmd/viiper/ascii_braille_colored.txt b/cmd/viiper/ascii_braille_colored.txt new file mode 100644 index 00000000..5c2827fd --- /dev/null +++ b/cmd/viiper/ascii_braille_colored.txt @@ -0,0 +1,31 @@ + ⣀⣀⣤⣤⣤⣀ + ⣀⣤⣶⣾⣿⣿⣿⣿⡛⠻⢿⣿⣾⣿⣷⣦⡄ + ⢀⣴⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⡐⢷⡍⢛⣿⣿⣿⣷⣄ + ⣰⣿⡿⠋⣁⡄⢀⣤⣄ ⡙⠛⠻⠿⣶⣾⣿⣿⣭⣿⣿⣿ + ⢰⣿⡿⣡⣿⡏⣠⣜⠿⣿⣧⣈⠻⣆ ⣤⣄⡉⣉⡅ ⣶⡄ + ⢸⣿⣷⣿⣿ ⣿⣿⣿⣶⣮⣭⣤⡌⠣⡘⣿⡇⣿⠃ ⠸⠁ + ⠈⣿⣿⣿⣿⡄⣬⣝⡛⠿⠿⠿⠿⢟⡀⠈⠪⣃⣐⣃⡀ + ⠘⢿⣿⣿⣧⠘⣿⣿⣿⣿⣿⣿⣿⣷ + ⠈⠻⣿⣿⣷⣄⠉⢩⣛⣛⣋⣭⣥⣶⣄ + ⠙⠻⣿⣿⣦⣍⡛⠿⠿⠿⠟⣛⣡⣤⣤⣀ + ⠉⠙⠻⢿⣷⣦⣤⣙⠛⠿⢟⣫⣥⣶⣶⣶⣦⡤ + ⠈⠉⠛⢿⣿⣷⣦⣍⡛⠛⣩⣴⣶⣾⣿⣿⣿⣶⣦⣄ ⠈⢷⡄ + ⠈⠛⢿⣿⣿⣶⣌⠻⠿⣛⣩⣥⣶⣶⣶⣶⣦⣄ ⣾⣿ + ⣀⣠⣤⣤⡐⢶⣶⣶⣦⣄⠲⣶⣦⣄⠙⣿⣿⣿⣷⡄⢻⣿⣿⣿⣿⣿⣿⣿⡿⠧⠐⣿⣶⠂⣤⣤⣄⣘⠻⠿ + ⣠⣴⣶⣶⢌⣿⣿⣿⢣⣾⣿⣿⣿⠏⣴⣿⣿⣿⡆⠈⣿⣿⣿⣿⡆⢡⣤⣤⣤⣶⣶⣶⣶⣶⡆⢹⣿⡗⢸⣿⣿⣿⠁⣤⣄ + ⢀⣀⣛⢻⣿⣿⡇⢾⣿⣿⣿⡈⠻⠿⠟⠛⠂⠙⠻⠿⠿⠟ ⣿⣿⣿⣿⡷⠸⢿⣿⣿⣿⣿⣿⣿⣿⡇⢈⣉ ⠛⠿⠿⠟⢰⣿⣿⡷⠄ + ⢀⣴⣿⣿⣿⠸⣿⣿⣷⠄⠉⠉⣁⣀⠠⣶⣶⣶⣿⡶⠶⠖⢀⣠⣾⣿⣿⣿⣿⠃⣼⣷⣶⣤⣭⣭⣭⣭⣷ ⣨⠵⣒⡒⢤⣀⠐⢿⣿⣿⢃⣾⣦⡀ + ⢀⣬⣭⢸⣿⣿⣷⣬⠉ ⠐⠛⣛⣀⣈⣠⣤⣤⣤⣤⣴⣶⣾⣿⣿⣿⣿⣿⠟⢁⣌⡻⢿⣿⣿⣿⣿⣿⣿⡏⢰⣿⢸⣿⣿⢾⣿⣧⡀⢠⣴⣾⣿⣿⠿⠄ + ⣾⣿⣿⣦⡛⠿⠟⢁⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⢋⣁⡰⣿⣿⣿⣷⣦⣬⣭⣭⣭⠉⣤⣶⣌⢶⣭⣥⢎⣴⣦⡅ ⠻⣿⡿⢃⣾⣷⡀ + ⢈⣭⡘⣿⣿⣿⡿⢀⣾⣿⣿⣿⡿⠿⠛⠛⣋⣉⣉⢩⣭⣥⡄⣤⣶⣶⡌⢿⣿⣿⣮⣙⠻⠿⣿⣿⣿⠟⢁⢠⡻⠿⢋⡾⢿⠿⣌⠿⠿⣣ ⠐⣶⣾⣿⣿⣿⣇ + ⣾⣿⣿⣬⣙⣋⡀⢸⣿⡿⠟⢁⣴⣶⣿⢸⣿⣿⣿⠸⣿⣿⣿⡘⣿⣿⣿⣮⡻⣿⣿⣿⣿⣷⠶⠂⣀⣐⣛⠈⢿⣿⣿⢸⣿⣿⢜⣿⣿⠟⣴ ⠻⣿⡿⢋⣤⣍ + ⡿⠛⣿⣿⣿⣿⡇⢸⡟⢡⣶⡜⣿⣿⣿⣦⢻⣿⡿⢃⣙⠛⠿⣿⣌⠻⣿⣿⣿⠶⠉⠉⠉⣠⣶⠟⠛⣛⠛⠿⣦⡉⠛⠦⠍⠩⠾⢛⣥⣾⡇⢰⣶⣶⣶⣿⣿⣿ + ⢰⣷⣌⠻⠿⠟⠁⠈⠐⣿⣿⣿⣎⡻⣿⡿⠗⢀⣾⣿⣿⣿⣶⡈⣉⡄⣠⣤⣶⣶⣿⡿⢠⡿⢡⣾⣿⣿⣿⣦⠘⣿⠈⣿⣷⣾⣿⣿⣿⡟ ⠸⣿⣿⡿⠟⡙⡿ + ⢸⣿⣿⣿⣿⣿⣿⡀⢸⣎⠻⣿⣿⣿⠂⢠⣆⠸⣿⣿⣿⣿⣿⠇⣹⠇ ⢸⡇⠸⣿⣿⣿⣿⣿⠃⣿⠃⣿⣿⣿⣿⣿⠏ ⣾⣷⣮⣥⣶⣿⣿ + ⠿⣉⠻⢿⣿⣿⠃⡈⢿⣿⣶⣭⡅ ⠉ ⠙⠻⠿⠟⠋ ⠙⠻⠿⠟⠋ ⠁ ⠈⠿⠿⠛⢁⣤⠐⣿⣿⣿⠿⢿⣿⠋ + ⢿⣿⣶⣤⣴⣾⣷⡄⠙⣛⣛⣃ ⢀⣤ ⣠⡴ ⣤⣴⣶⢠⣿⣿⣿⣶⣤⣶⣶⠘⠁ + ⢰⡌⠻⣿⠿⣿⣿⣿⠇⣤⡈⠻⠿⣀ ⢀⣠⡉⠿⠁ ⠐⢿⣇⢸⣿⣿⣿⣦⣝⣛⡻⣿⣿⠟⢁⣴⡇ + ⠈⢿⣆⠉⢰⣶⣶⣶⣾⣿⣿ ⣤⣉⡀ ⢀⣀ ⢤⣤⡌⣻⡿ ⠙⠃⠻⣿⣿⣿⡿⠟⠁⠋ ⢸⡿⠇ + ⠙⠂ ⠈⠻⠿⢛⣛⣫⣼⣿⣿⣿⠈⣿⣿⡷⢸⣿⠃⠉ ⠐⠋⠁ + ⠈⠉⠛⠻⠿⠋⠡⠾⠿⠛ ⠉ + diff --git a/cmd/viiper/ascii_braille_colored_sm.txt b/cmd/viiper/ascii_braille_colored_sm.txt new file mode 100644 index 00000000..a764f490 --- /dev/null +++ b/cmd/viiper/ascii_braille_colored_sm.txt @@ -0,0 +1,19 @@ + ⢀⣀⣤⣤⣶⣶⣤⣠⣄⡀ + ⢀⣴⣾⠿⠿⠿⢿⣿⡖⠯⣛⣿⣿⣤ + ⣾⢏⡥⢀⣾⣷⡀⢌⠉⡙⠛⣓⠉⣉ + ⠘⣿⣼⡇⠾⣿⣶⣿⣦⡁⠘⠇⠏ ⠃ + ⠹⣿⣿⡹⣷⣾⣿⣷⣦ + ⠈⠻⢿⣦⣘⠿⣿⣾⡷⢄⣀ + ⠈⠙⠛⠶⣤⣟⡛⢯⣵⣶⠶⣦⣀⣀⡀ ⢀ + ⠉⠻⢷⣮⣜⠿⣿⣻⣿⣭⣧⣄ ⢀⣷ + ⣀⡠⢴⣶⡔⣶⣶⡦⣲⣶⣄⠻⣿⣷⡙⣿⣿⣛⣛⣛⣃⢻⣧⢲⣶⡬⢛ + ⣠⣴⢻⣿⡇⣿⠿⠘⠛⠛⠃⠙⠛⠛⢀⣿⣿⡇⣻⠿⣿⣿⣿⡿⠈⠁⠘⠛⢃⣿⣷⢄ + ⣘⢻⣿⣌⠛⠁⠤⢄⣂⣉⣉⣉⣩⣥⣶⣿⣿⠟⣱⢿⣿⣿⣿⣿⡏⡔⣴⣦⢢⡈⠙⣣⣿⡷⡀ + ⠾⢿⣧⣝⢋⣴⣾⣿⣿⣿⠿⠿⠿⠿⢟⣛⣫⣥⣼⢿⣿⣶⣿⣿⠋⣶⡦⣮⡕⢴⣶ ⢙⣛⣼⣷ +⢠⣿⣮⣛⡃⢸⡿⠋⣥⣶⣰⣿⣧⣿⣿⡼⣿⣷⣿⢿⣿⣾⠯⣉⣐⡀⢶⡆⢴⣶⢰⡶⡀⠘⠿⢟⣽⡀ +⠘⣭⠻⣿⠇⠈⣴⣿⣽⣿⣷⠟⣯⣬⣭⡻⠎⢛⣛⣋⡤⢠⢋⣥⣤⣍⢳⠈⣄⣠⣥⣶⠃⢸⣿⡿⢿⡇ + ⣿⣿⣶⣿⡀⣮⣻⢿⠇⠠⠘⢿⣿⣿⠇⠏ ⠡⠸⣿⣿⡿ ⠁⢿⣿⠿⠁⣰⣶⣿⣶⡷ + ⠈⣶⣭⣭⣴⡌⢻⣿⡀ ⠉ ⢀ ⠉ ⣀ ⣠⡄⣰⣷⣬⣭⣭⠛ + ⠰⡎⠛⣛⣛⣣⣤⠉⠳ ⢤⡌ ⠳⠜⣿⣿⣾⡭⠽⠛⢡⡾ + ⠈ ⠈⠻⠟⣿⣴⣿⡶⣰⣶⢸⠟⠈ ⠈⠉⠁ ⠈ + ⠉⠁⠈⠉⠁ diff --git a/cmd/viiper/meta.go b/cmd/viiper/meta.go new file mode 100644 index 00000000..d61c62bf --- /dev/null +++ b/cmd/viiper/meta.go @@ -0,0 +1,72 @@ +package main + +import ( + _ "embed" + "fmt" + "runtime/debug" + "time" +) + +//go:embed ascii_braille_colored_sm.txt +var asciiBrailleColoredSmall string + +//go:embed ascii_braille_colored.txt +var asciiBrailleColoredBig string + +var ( + Version = "" + Commit = "" + Date = "" +) + +var descriptionTemplate = ` +Virtual Input over IP EmulatoR + Version: %s (%s) + %s + Source: https://github.com/Alia5/VIIPER + License: GPLv3 +` + +func Description() string { + return fmt.Sprintf(descriptionTemplate, Version, Commit, Date) +} + +func init() { + if info, ok := debug.ReadBuildInfo(); ok { + if Version == "" { + Version = info.Main.Version + if Version == "" || Version == "(devel)" { + Version = "dev" + } + } + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + if Commit == "" { + if len(setting.Value) > 7 { + Commit = setting.Value[:7] + } else { + Commit = setting.Value + } + } + case "vcs.time": + if Date == "" { + if t, err := time.Parse(time.RFC3339, setting.Value); err == nil { + Date = t.Format("2006-01-02") + } else { + Date = setting.Value + } + } + } + } + } + if Version == "" { + Version = "dev" + } + if Commit == "" { + Commit = "unknown" + } + if Date == "" { + Date = "unknown" + } +} diff --git a/cmd/viiper/startup.go b/cmd/viiper/startup.go new file mode 100644 index 00000000..dd7538d7 --- /dev/null +++ b/cmd/viiper/startup.go @@ -0,0 +1,3 @@ +//go:build !windows + +package main diff --git a/cmd/viiper/startup_windows.go b/cmd/viiper/startup_windows.go new file mode 100644 index 00000000..ad23c22f --- /dev/null +++ b/cmd/viiper/startup_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import ( + "log/slog" + "os" + + "github.com/Alia5/VIIPER/internal/util" +) + +func init() { + if util.IsRunFromGUI() { + args := os.Args + if len(args) < 2 { + slog.Info("Detected GUI startup, injecting 'server' argument") + slog.Warn("Run from a CLI for more options!") + newArgs := make([]string, 0, len(args)+1) + newArgs = append(newArgs, args[0], "server") + newArgs = append(newArgs, args[1:]...) + os.Args = newArgs + } + } +} diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go new file mode 100644 index 00000000..7107912e --- /dev/null +++ b/cmd/viiper/viiper.go @@ -0,0 +1,240 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/Alia5/VIIPER/internal/config" + "github.com/Alia5/VIIPER/internal/configpaths" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/updater" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register all device handlers + + "github.com/alecthomas/kong" + kongtoml "github.com/alecthomas/kong-toml" + kongyaml "github.com/alecthomas/kong-yaml" + "golang.org/x/term" +) + +func main() { + handlePlainHelpFlag() + + userCfg := findUserConfig(os.Args[1:]) + jsonPaths, yamlPaths, tomlPaths := configpaths.ConfigCandidatePaths(userCfg) + + var cli config.CLI + ctx := kong.Parse(&cli, + kong.Name("VIIPER"), + kong.Description(Description()), + kong.UsageOnError(), + kong.Help(helpWithASCIIArt), + // Load configuration from JSON/YAML/TOML in priority order; flags/env override config values. + kong.Configuration(kong.JSON, jsonPaths...), + kong.Configuration(kongyaml.Loader, yamlPaths...), + kong.Configuration(kongtoml.Loader, tomlPaths...), + ) + + logger, closeFiles, err := log.SetupLogger(cli.Log.Level, cli.Log.File) // nolint + if err != nil { + fmt.Fprintln(os.Stderr, "failed to setup logger:", err) + os.Exit(2) + } + defer func() { + for _, c := range closeFiles { + _ = c.Close() + } + }() + + rawLogger := setupRawLogger(&cli, logger, &closeFiles) + + ctx.Bind(logger) + ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) + + if cli.UpdateNotify != config.UpdateNotifyNone { + go func() { + time.Sleep(10 * time.Second) + updater.CheckUpdate(Version, cli.UpdateNotify) + for range time.NewTicker(1 * time.Hour).C { + updater.CheckUpdate(Version, cli.UpdateNotify) + } + }() + } + + err = ctx.Run() + ctx.FatalIfErrorf(err) +} + +func handlePlainHelpFlag() { + for i, arg := range os.Args[1:] { + if arg == "-p" { + os.Setenv("VIIPER_HELP_STYLE", "plain") // nolint + os.Args[i+1] = "-h" + return + } + } +} + +func findUserConfig(args []string) string { + for i, a := range args { + if strings.HasPrefix(a, "--config=") { + return a[len("--config="):] + } + if a == "--config" && i+1 < len(args) { + return args[i+1] + } + } + return os.Getenv("VIIPER_CONFIG") +} + +func setupRawLogger(cli *config.CLI, logger *slog.Logger, closeFiles *[]io.Closer) log.RawLogger { + if cli.Log.RawFile != "" { // nolint + f, err := os.OpenFile(cli.Log.RawFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) // nolint + if err != nil { + logger.Error("failed to open raw log file", "file", cli.Log.RawFile, "error", err) // nolint + return log.NewRaw(nil) + } + *closeFiles = append(*closeFiles, f) + return log.NewRaw(f) + } + if cli.Log.Level == "trace" { // nolint + return log.NewRaw(os.Stdout) + } + return log.NewRaw(nil) +} + +func helpWithASCIIArt(options kong.HelpOptions, ctx *kong.Context) error { + // VIIPER_HELP_STYLE env var: "plain", "big", "small", or auto-detect + helpStyle := strings.ToLower(os.Getenv("VIIPER_HELP_STYLE")) + if helpStyle == "" { + helpStyle = detectHelpStyle() + } + if helpStyle == "plain" { + return kong.DefaultHelpPrinter(options, ctx) + } + + helpText := captureHelpOutput(options, ctx) + + art := asciiBrailleColoredSmall + if helpStyle == "big" { + art = asciiBrailleColoredBig + } + + output := mergeArtWithHelp(normalizeLineEndings(art), normalizeLineEndings(helpText)) + _, err := fmt.Fprint(ctx.Stdout, output) + return err +} + +func captureHelpOutput(options kong.HelpOptions, ctx *kong.Context) string { + var buf bytes.Buffer + origStdout := ctx.Stdout + ctx.Stdout = &buf + _ = kong.DefaultHelpPrinter(options, ctx) + ctx.Stdout = origStdout + return buf.String() +} + +func normalizeLineEndings(s string) string { + s = strings.TrimRight(s, "\r\n") + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + +func mergeArtWithHelp(art, help string) string { + artLines := strings.Split(art, "\n") + helpLines := strings.Split(help, "\n") + + artWidth := maxVisibleWidth(artLines) + 2 + + maxLines := max(len(artLines), len(helpLines)) + artOffset := (len(helpLines) - len(artLines)) / 2 + if artOffset < 0 { + artOffset = 0 + } + + var out strings.Builder + for i := range maxLines { + artLine := "" + if idx := i - artOffset; idx >= 0 && idx < len(artLines) { + artLine = artLines[idx] + } + + helpLine := "" + if i < len(helpLines) { + helpLine = helpLines[i] + } + + padding := artWidth - visibleWidth(artLine) + out.WriteString(artLine) + out.WriteString(strings.Repeat(" ", padding)) + out.WriteString(helpLine) + out.WriteString("\n") + } + return out.String() +} + +func maxVisibleWidth(lines []string) int { + maxWidth := 0 + for _, line := range lines { + if w := visibleWidth(line); w > maxWidth { + maxWidth = w + } + } + return maxWidth +} + +func visibleWidth(s string) int { + inEscape := false + width := 0 + for _, r := range s { + if r == '\x1b' { + inEscape = true + continue + } + if inEscape { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEscape = false + } + continue + } + width++ + } + return width +} + +func detectHelpStyle() string { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + fd = int(os.Stderr.Fd()) + if !term.IsTerminal(fd) { + return "plain" + } + } + + if os.Getenv("TERM") == "dumb" { + return "plain" + } + + width, _, err := term.GetSize(fd) + if err != nil || width <= 0 { + return "small" + } + + const ( + bigThreshold = 140 + smallThreshold = 110 + ) + switch { + case width >= bigThreshold: + return "big" + case width >= smallThreshold: + return "small" + default: + return "plain" + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..4ce10cb6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +ignore: + - "examples/go/**/*" + - "**/_testing/**/*" + - "cmd/viiper/**/*" + - "docs/**/*" + - "tests/e2e/**/*" + - "internal/codegen/**/*" + +coverage: + status: + project: + default: + informational: true + diff --git a/viiper/pkg/device/context.go b/device/context.go similarity index 93% rename from viiper/pkg/device/context.go rename to device/context.go index 3367e43a..60fd5f27 100644 --- a/viiper/pkg/device/context.go +++ b/device/context.go @@ -1,33 +1,34 @@ -// Package device provides common interfaces and utilities for virtual USB devices. -package device - -import ( - "context" - "time" - "viiper/pkg/usbip" -) - -type contextKey int - -const ( - ExportMetaKey contextKey = iota - ConnTimerKey -) - -// GetDeviceMeta extracts the device metadata from a device context. -// Returns nil if the context doesn't contain device metadata. -func GetDeviceMeta(ctx context.Context) *usbip.ExportMeta { - if meta, ok := ctx.Value(ExportMetaKey).(*usbip.ExportMeta); ok { - return meta - } - return nil -} - -// GetConnTimer extracts the connection timer from a device context. -// Returns nil if the context doesn't contain the timer. -func GetConnTimer(ctx context.Context) *time.Timer { - if timer, ok := ctx.Value(ConnTimerKey).(*time.Timer); ok { - return timer - } - return nil -} +// Package device provides common interfaces and utilities for virtual USB devices. +package device + +import ( + "context" + "time" + + "github.com/Alia5/VIIPER/usbip" +) + +type contextKey int + +const ( + ExportMetaKey contextKey = iota + ConnTimerKey +) + +// GetDeviceMeta extracts the device metadata from a device context. +// Returns nil if the context doesn't contain device metadata. +func GetDeviceMeta(ctx context.Context) *usbip.ExportMeta { + if meta, ok := ctx.Value(ExportMetaKey).(*usbip.ExportMeta); ok { + return meta + } + return nil +} + +// GetConnTimer extracts the connection timer from a device context. +// Returns nil if the context doesn't contain the timer. +func GetConnTimer(ctx context.Context) *time.Timer { + if timer, ok := ctx.Value(ConnTimerKey).(*time.Timer); ok { + return timer + } + return nil +} diff --git a/device/device_attach_test.go b/device/device_attach_test.go new file mode 100644 index 00000000..25f350a7 --- /dev/null +++ b/device/device_attach_test.go @@ -0,0 +1,105 @@ +package device_test + +import ( + "context" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" + "github.com/stretchr/testify/assert" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register devices +) + +func TestDeviceAttach(t *testing.T) { + + deviceTypes := api.ListDeviceTypes() + assert.NotEmpty(t, deviceTypes) + + type testCase struct { + deviceType string + } + + cases := make([]testCase, len(deviceTypes)) + for i, dt := range deviceTypes { + cases[i] = testCase{deviceType: dt} + } + + for _, tc := range cases { + t.Run(tc.deviceType, func(t *testing.T) { + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() // nolint + defer s.ApiServer.Close() // nolint + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() // nolint + err = s.UsbServer.AddBus(b) + if err != nil { + t.Fatalf("Failed to add bus to USB server: %v", err) + } + + c := viiperclient.New(s.ApiServer.Addr()) + + stream, addResp, err := c.AddDeviceAndConnect(context.Background(), b.BusID(), tc.deviceType, nil) + if !assert.NoError(t, err) { + t.Fatal() + } + assert.NotNil(t, stream) + assert.NotNil(t, addResp) + assert.Equal(t, tc.deviceType, addResp.Type) + assert.Equal(t, b.BusID(), addResp.BusID) + assert.Equal(t, "1", addResp.DevID) + + if stream != nil { + defer stream.Close() //nolint:errcheck + } + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + + var devs []viiperTesting.Device + ok := assert.Eventually(t, func() bool { + list, err := usbipClient.ListDevices() + if err != nil { + return false + } + devs = list + return len(devs) == 1 + }, 1*time.Second, 10*time.Millisecond) + if !ok { + return + } + + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if !assert.NotNil(t, imp) { + return + } + if imp.Conn != nil { + defer imp.Conn.Close() // nolint + } + if !assert.NotNil(t, imp.Conn) { + return + } + + }) + } + +} diff --git a/device/dualsense/audio_control.go b/device/dualsense/audio_control.go new file mode 100644 index 00000000..01936c4e --- /dev/null +++ b/device/dualsense/audio_control.go @@ -0,0 +1,359 @@ +package dualsense + +import ( + "encoding/binary" + "math" + "sync" +) + +const ( + audioClassRequestSetCurrent = 0x01 + audioClassRequestGetCurrent = 0x81 + audioClassRequestGetMinimum = 0x82 + audioClassRequestGetMaximum = 0x83 + audioClassRequestGetResolution = 0x84 + + audioClassInterfaceOut = 0x21 + audioClassInterfaceIn = 0xA1 + audioClassEndpointOut = 0x22 + audioClassEndpointIn = 0xA2 + + audioControlMute = 0x01 + audioControlVolume = 0x02 + audioControlSamplingFrequency = 0x01 + + audioEntitySpeakerFeatureUnit = 0x02 + audioEntityMicrophoneFeature = 0x05 + + // The physical-style UAC feature controls use signed Q8.8 decibels. + audioSpeakerVolumeMinimum int16 = -100 * 256 + audioSpeakerVolumeMaximum int16 = 0 + audioSpeakerVolumeResolution int16 = 1 * 256 + audioSpeakerVolumeDefault int16 = audioSpeakerVolumeMaximum + + audioMicrophoneVolumeMinimum int16 = 0 + audioMicrophoneVolumeMaximum int16 = 48 * 256 + audioMicrophoneVolumeResolution int16 = 0x007A + audioMicrophoneVolumeDefault int16 = audioMicrophoneVolumeMaximum + + // Five milliseconds is long enough to remove a hard discontinuity while + // remaining imperceptible as control latency. + audioGainRampFrames = USBMicrophoneSampleRate / 200 +) + +var audioGainBufferPool sync.Pool + +type audioGainBuffer struct { + data []byte +} + +type audioFeatureState struct { + mute bool + volume int16 + minimum int16 + maximum int16 + resolution int16 + defaultVolume int16 + applyVolume bool + gain audioGainRamp +} + +type audioGainRamp struct { + current float64 + target float64 + framesRemaining int +} + +func newAudioFeatureState(minimum, maximum, resolution, defaultVolume int16, applyVolume bool) audioFeatureState { + return audioFeatureState{ + volume: defaultVolume, + minimum: minimum, + maximum: maximum, + resolution: resolution, + defaultVolume: defaultVolume, + applyVolume: applyVolume, + gain: audioGainRamp{ + current: 1.0, + target: 1.0, + }, + } +} + +func newSpeakerAudioFeatureState() audioFeatureState { + return newAudioFeatureState( + audioSpeakerVolumeMinimum, + audioSpeakerVolumeMaximum, + audioSpeakerVolumeResolution, + audioSpeakerVolumeDefault, + true, + ) +} + +func newMicrophoneAudioFeatureState() audioFeatureState { + return newAudioFeatureState( + audioMicrophoneVolumeMinimum, + audioMicrophoneVolumeMaximum, + audioMicrophoneVolumeResolution, + audioMicrophoneVolumeDefault, + false, + ) +} + +func (s *audioFeatureState) setMute(mute bool) { + if s.mute == mute { + return + } + s.mute = mute + s.beginGainTransition() +} + +func (s *audioFeatureState) setVolume(volume int16) { + volume = max(s.minimum, min(s.maximum, volume)) + if s.volume == volume { + return + } + s.volume = volume + if s.applyVolume { + s.beginGainTransition() + } +} + +func (s *audioFeatureState) beginGainTransition() { + s.gain.target = s.targetGain() + s.gain.framesRemaining = audioGainRampFrames +} + +func (s *audioFeatureState) resetStreamGain() { + s.gain.target = s.targetGain() + s.gain.current = s.gain.target + s.gain.framesRemaining = 0 +} + +// targetGain is relative to the feature unit's default value for render PCM. +// The DualSense microphone's physical-style 0..+48 dB feature range describes +// hardware ADC gain. Windows initializes that control to 0 dB even when its +// endpoint slider reads 100%; applying it again to already captured client PCM +// attenuates the virtual microphone by exactly 48 dB. Match the physical device +// and DS5 Bridge boundary: retain and round-trip that host control, but leave +// client-provided capture PCM at unity. Mute remains effective for both paths. +func (s *audioFeatureState) targetGain() float64 { + if s.mute { + return 0 + } + if !s.applyVolume { + return 1 + } + return math.Pow(10, float64(int(s.volume)-int(s.defaultVolume))/(256.0*20.0)) +} + +func (s *audioFeatureState) needsPCMProcessing() bool { + return s.gain.framesRemaining != 0 || s.gain.current != 1.0 || s.gain.target != 1.0 +} + +// applyPCM applies one master feature-unit gain to interleaved signed S16LE +// PCM. The caller must serialize access to the feature state. The returned +// release function must be called once the synchronous consumer has copied the +// returned bytes. +func (s *audioFeatureState) applyPCM(src []byte, channels int) ([]byte, func()) { + if len(src) == 0 || channels <= 0 || !s.needsPCMProcessing() { + return src, nil + } + + buffer := acquireAudioGainBuffer(len(src)) + dst := buffer.data + copy(dst, src) + s.applyPCMInPlace(dst, channels) + + return dst, func() { releaseAudioGainBuffer(buffer) } +} + +// applyPCMInPlace is used for freshly allocated USB capture packets. Applying +// microphone controls here, after the jitter buffer, makes a control change +// affect every not-yet-presented sample, including PCM queued before the SET. +func (s *audioFeatureState) applyPCMInPlace(pcm []byte, channels int) { + if len(pcm) == 0 || channels <= 0 || !s.needsPCMProcessing() { + return + } + + frameSize := channels * 2 + for frameOffset := 0; frameOffset+frameSize <= len(pcm); frameOffset += frameSize { + gain := s.gain.next() + for channel := 0; channel < channels; channel++ { + offset := frameOffset + channel*2 + sample := int16(binary.LittleEndian.Uint16(pcm[offset : offset+2])) + scaled := int64(math.Round(float64(sample) * gain)) + scaled = max(int64(math.MinInt16), min(int64(math.MaxInt16), scaled)) + binary.LittleEndian.PutUint16(pcm[offset:offset+2], uint16(int16(scaled))) + } + } +} + +func (r *audioGainRamp) next() float64 { + if r.framesRemaining <= 0 { + r.current = r.target + return r.current + } + + r.current += (r.target - r.current) / float64(r.framesRemaining) + r.framesRemaining-- + if r.framesRemaining == 0 { + r.current = r.target + } + return r.current +} + +func acquireAudioGainBuffer(length int) *audioGainBuffer { + var buffer *audioGainBuffer + if pooled := audioGainBufferPool.Get(); pooled != nil { + buffer = pooled.(*audioGainBuffer) + } else { + buffer = &audioGainBuffer{} + } + if cap(buffer.data) < length { + buffer.data = make([]byte, length) + } else { + buffer.data = buffer.data[:length] + } + return buffer +} + +func releaseAudioGainBuffer(buffer *audioGainBuffer) { + if buffer != nil { + buffer.data = buffer.data[:0] + audioGainBufferPool.Put(buffer) + } +} + +func (d *DualSense) handleAudioControlRequest( + bmRequestType, bRequest uint8, + wValue, wIndex, wLength uint16, + data []byte, +) ([]byte, bool) { + if response, handled := d.handleAudioFeatureControlRequest( + bmRequestType, bRequest, wValue, wIndex, wLength, data, + ); handled { + return response, true + } + + return handleAudioEndpointControlRequest( + bmRequestType, bRequest, wValue, wIndex, wLength, data, + ) +} + +func (d *DualSense) handleAudioFeatureControlRequest( + bmRequestType, bRequest uint8, + wValue, wIndex, wLength uint16, + data []byte, +) ([]byte, bool) { + if uint8(wIndex) != InterfaceAudioControl || uint8(wValue) != 0 { + return nil, false + } + + entity := uint8(wIndex >> 8) + selector := uint8(wValue >> 8) + + d.mtx.Lock() + defer d.mtx.Unlock() + + var state *audioFeatureState + switch entity { + case audioEntitySpeakerFeatureUnit: + state = &d.speakerAudioFeature + case audioEntityMicrophoneFeature: + state = &d.microphoneAudioFeature + default: + return nil, false + } + + switch bmRequestType { + case audioClassInterfaceIn: + switch selector { + case audioControlMute: + if bRequest != audioClassRequestGetCurrent || wLength != 1 { + return nil, false + } + if state.mute { + return []byte{1}, true + } + return []byte{0}, true + case audioControlVolume: + if wLength != 2 { + return nil, false + } + var value int16 + switch bRequest { + case audioClassRequestGetCurrent: + value = state.volume + case audioClassRequestGetMinimum: + value = state.minimum + case audioClassRequestGetMaximum: + value = state.maximum + case audioClassRequestGetResolution: + value = state.resolution + default: + return nil, false + } + return int16LittleEndian(value), true + } + case audioClassInterfaceOut: + if bRequest != audioClassRequestSetCurrent { + return nil, false + } + switch selector { + case audioControlMute: + if wLength != 1 || len(data) != 1 { + return nil, false + } + state.setMute(data[0] != 0) + return nil, true + case audioControlVolume: + if wLength != 2 || len(data) != 2 { + return nil, false + } + state.setVolume(int16(binary.LittleEndian.Uint16(data))) + return nil, true + } + } + + return nil, false +} + +// handleAudioEndpointControlRequest is a compatibility path for hosts that +// probe sampling frequency despite the fixed 48 kHz format and the endpoint's +// advertised lack of a sampling-frequency control. +func handleAudioEndpointControlRequest( + bmRequestType, bRequest uint8, + wValue, wIndex, wLength uint16, + data []byte, +) ([]byte, bool) { + endpoint := uint8(wIndex) + if uint8(wIndex>>8) != 0 || + (endpoint != EndpointHapticsAudioOut && endpoint != EndpointMicrophoneIn) || + uint8(wValue>>8) != audioControlSamplingFrequency || uint8(wValue) != 0 || + wLength != 3 { + return nil, false + } + + switch bmRequestType { + case audioClassEndpointIn: + switch bRequest { + case audioClassRequestGetCurrent, audioClassRequestGetMinimum, audioClassRequestGetMaximum: + return []byte{0x80, 0xBB, 0x00}, true + case audioClassRequestGetResolution: + return []byte{0x00, 0x00, 0x00}, true + } + case audioClassEndpointOut: + if bRequest == audioClassRequestSetCurrent && + len(data) == 3 && data[0] == 0x80 && data[1] == 0xBB && data[2] == 0x00 { + return nil, true + } + } + + return nil, false +} + +func int16LittleEndian(value int16) []byte { + result := make([]byte, 2) + binary.LittleEndian.PutUint16(result, uint16(value)) + return result +} diff --git a/device/dualsense/audio_control_test.go b/device/dualsense/audio_control_test.go new file mode 100644 index 00000000..b9e33670 --- /dev/null +++ b/device/dualsense/audio_control_test.go @@ -0,0 +1,408 @@ +package dualsense + +import ( + "bytes" + "context" + "encoding/binary" + "testing" + + "github.com/Alia5/VIIPER/usbip" +) + +func TestDualSenseAudioFeatureControlsMatchDS5Bridge(t *testing.T) { + constructors := map[string]func(*testing.T) *DualSense{ + "DualSense": func(t *testing.T) *DualSense { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + return dev + }, + "DualSense Edge": func(t *testing.T) *DualSense { + dev, err := NewEdge(nil) + if err != nil { + t.Fatalf("NewEdge returned error: %v", err) + } + return dev + }, + } + + for name, construct := range constructors { + t.Run(name, func(t *testing.T) { + dev := construct(t) + assertAudioFeatureState(t, dev, audioEntitySpeakerFeatureUnit, + audioSpeakerVolumeDefault, audioSpeakerVolumeMinimum, + audioSpeakerVolumeMaximum, audioSpeakerVolumeResolution) + assertAudioFeatureState(t, dev, audioEntityMicrophoneFeature, + audioMicrophoneVolumeDefault, audioMicrophoneVolumeMinimum, + audioMicrophoneVolumeMaximum, audioMicrophoneVolumeResolution) + + setAudioMute(t, dev, audioEntitySpeakerFeatureUnit, true) + got, handled := dev.HandleControl(audioClassInterfaceIn, + audioClassRequestGetCurrent, uint16(audioControlMute)<<8, + uint16(audioEntitySpeakerFeatureUnit)<<8, 1, nil) + if !handled || !bytes.Equal(got, []byte{1}) { + t.Fatalf("speaker mute did not round-trip: handled=%t got=% x", handled, got) + } + + setAudioVolume(t, dev, audioEntitySpeakerFeatureUnit, -50*256) + assertAudioControlValue(t, dev, audioEntitySpeakerFeatureUnit, + audioClassRequestGetCurrent, -50*256) + + // Out-of-range values are safely clamped to the exact advertised range. + setAudioVolume(t, dev, audioEntitySpeakerFeatureUnit, -32768) + assertAudioControlValue(t, dev, audioEntitySpeakerFeatureUnit, + audioClassRequestGetCurrent, audioSpeakerVolumeMinimum) + setAudioVolume(t, dev, audioEntityMicrophoneFeature, -1) + assertAudioControlValue(t, dev, audioEntityMicrophoneFeature, + audioClassRequestGetCurrent, audioMicrophoneVolumeMinimum) + }) + } +} + +func TestDualSenseAudioControlHandlersAgreeWithBothDescriptors(t *testing.T) { + constructors := map[string]func(*testing.T) *DualSense{ + "DualSense": func(t *testing.T) *DualSense { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + return dev + }, + "DualSense Edge": func(t *testing.T) *DualSense { + dev, err := NewEdge(nil) + if err != nil { + t.Fatalf("NewEdge returned error: %v", err) + } + return dev + }, + } + + for name, construct := range constructors { + t.Run(name, func(t *testing.T) { + desc := construct(t).GetDescriptor() + controlInterface, ok := desc.Interface(InterfaceAudioControl) + if !ok || controlInterface.Descriptor.BInterfaceClass != 0x01 || + controlInterface.Descriptor.BInterfaceSubClass != 0x01 { + t.Fatalf("audio-control interface mismatch: %+v", controlInterface.Descriptor) + } + + features := map[uint8]byte{} + for _, classDescriptor := range controlInterface.ClassDescriptors { + payload := classDescriptor.Payload + if classDescriptor.DescriptorType == 0x24 && len(payload) >= 5 && payload[0] == 0x06 { + features[payload[1]] = payload[4] + } + } + for _, entity := range []uint8{ + audioEntitySpeakerFeatureUnit, + audioEntityMicrophoneFeature, + } { + if controls, ok := features[entity]; !ok || controls&0x03 != 0x03 { + t.Fatalf("entity %d does not advertise master mute+volume: controls=%#x present=%t", + entity, controls, ok) + } + } + + for _, endpointAddress := range []uint8{ + EndpointHapticsAudioOut, + EndpointMicrophoneIn, + } { + endpointFound := false + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress != endpointAddress { + continue + } + endpointFound = true + if len(endpoint.ClassDescriptors) != 1 || + len(endpoint.ClassDescriptors[0].Payload) < 2 || + endpoint.ClassDescriptors[0].Payload[0] != 0x01 || + endpoint.ClassDescriptors[0].Payload[1] != 0x00 { + t.Fatalf("endpoint %#x unexpectedly advertises sample-frequency control: %+v", + endpointAddress, endpoint.ClassDescriptors) + } + } + } + if !endpointFound { + t.Fatalf("descriptor omitted audio endpoint %#x", endpointAddress) + } + } + }) + } +} + +func TestDualSenseAudioFeatureControlsRejectMalformedRequests(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + requests := []struct { + name string + bm, request uint8 + value, index, length uint16 + data []byte + }{ + {"per-channel control", audioClassInterfaceIn, audioClassRequestGetCurrent, + uint16(audioControlVolume)<<8 | 1, uint16(audioEntitySpeakerFeatureUnit) << 8, 2, nil}, + {"wrong AC interface", audioClassInterfaceIn, audioClassRequestGetCurrent, + uint16(audioControlVolume) << 8, uint16(audioEntitySpeakerFeatureUnit)<<8 | 1, 2, nil}, + {"unknown entity", audioClassInterfaceIn, audioClassRequestGetCurrent, + uint16(audioControlVolume) << 8, 7 << 8, 2, nil}, + {"short volume get", audioClassInterfaceIn, audioClassRequestGetCurrent, + uint16(audioControlVolume) << 8, uint16(audioEntitySpeakerFeatureUnit) << 8, 1, nil}, + {"short volume set data", audioClassInterfaceOut, audioClassRequestSetCurrent, + uint16(audioControlVolume) << 8, uint16(audioEntitySpeakerFeatureUnit) << 8, 2, []byte{0}}, + {"mute range query", audioClassInterfaceIn, audioClassRequestGetMinimum, + uint16(audioControlMute) << 8, uint16(audioEntitySpeakerFeatureUnit) << 8, 1, nil}, + } + + for _, request := range requests { + t.Run(request.name, func(t *testing.T) { + if response, handled := dev.handleAudioControlRequest( + request.bm, request.request, request.value, request.index, + request.length, request.data, + ); handled || response != nil { + t.Fatalf("malformed request was handled: handled=%t response=% x", handled, response) + } + }) + } +} + +func TestDualSenseAudioGainDefaultsAreNeutralAndChangesRamp(t *testing.T) { + frame := make([]byte, audioGainRampFrames*USBHapticsAudioFrameSize) + for offset := 0; offset < len(frame); offset += 2 { + binary.LittleEndian.PutUint16(frame[offset:offset+2], uint16(int16(10000))) + } + + speaker := newSpeakerAudioFeatureState() + processed, release := speaker.applyPCM(frame, USBHapticsAudioChannels) + if release != nil || !bytes.Equal(processed, frame) { + t.Fatal("default speaker feature changed the established PCM level") + } + + speaker.setMute(true) + processed, release = speaker.applyPCM(frame, USBHapticsAudioChannels) + if release == nil { + t.Fatal("muted speaker PCM was not processed") + } + defer release() + first := int16(binary.LittleEndian.Uint16(processed[:2])) + lastOffset := (audioGainRampFrames - 1) * USBHapticsAudioFrameSize + last := int16(binary.LittleEndian.Uint16(processed[lastOffset : lastOffset+2])) + if first <= 0 || first >= 10000 || last != 0 { + t.Fatalf("speaker mute ramp was discontinuous: first=%d last=%d", first, last) + } + + microphone := newMicrophoneAudioFeatureState() + microphone.setVolume(audioMicrophoneVolumeMinimum) + micFrame := make([]byte, audioGainRampFrames*USBMicrophoneChannels*2) + for offset := 0; offset < len(micFrame); offset += 2 { + binary.LittleEndian.PutUint16(micFrame[offset:offset+2], uint16(int16(10000))) + } + micProcessed, micRelease := microphone.applyPCM(micFrame, USBMicrophoneChannels) + if micRelease != nil || !bytes.Equal(micProcessed, micFrame) { + t.Fatal("physical-style microphone gain control attenuated client capture PCM") + } + + microphone.setMute(true) + micProcessed, micRelease = microphone.applyPCM(micFrame, USBMicrophoneChannels) + if micRelease == nil { + t.Fatal("microphone mute did not process PCM") + } + defer micRelease() + micFirst := int16(binary.LittleEndian.Uint16(micProcessed[:2])) + micLastOffset := (audioGainRampFrames - 1) * USBMicrophoneChannels * 2 + micLast := int16(binary.LittleEndian.Uint16(micProcessed[micLastOffset : micLastOffset+2])) + if micFirst <= 0 || micFirst >= 10000 || micLast != 0 { + t.Fatalf("microphone mute ramp was unexpected: first=%d last=%d", micFirst, micLast) + } +} + +func TestDualSenseAudioGainSaturatesS16(t *testing.T) { + state := newSpeakerAudioFeatureState() + state.gain.current = 2 + state.gain.target = 2 + pcm := make([]byte, USBHapticsAudioFrameSize) + binary.LittleEndian.PutUint16(pcm[0:2], uint16(int16(20000))) + negative := int16(-20000) + binary.LittleEndian.PutUint16(pcm[2:4], uint16(negative)) + + processed, release := state.applyPCM(pcm, USBHapticsAudioChannels) + if release == nil { + t.Fatal("amplified PCM was not processed") + } + defer release() + if got := int16(binary.LittleEndian.Uint16(processed[0:2])); got != 32767 { + t.Fatalf("positive sample did not saturate: %d", got) + } + if got := int16(binary.LittleEndian.Uint16(processed[2:4])); got != -32768 { + t.Fatalf("negative sample did not saturate: %d", got) + } +} + +func TestDualSenseMicrophoneControlAppliesAtUSBPresentation(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + frame := make([]byte, USBMicrophoneClientFrameSize) + for offset := 0; offset < len(frame); offset += 2 { + binary.LittleEndian.PutUint16(frame[offset:offset+2], uint16(int16(10000))) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + // Change the feature control only after the raw PCM is already buffered. + // The queued samples must still observe the new value at USB presentation. + setAudioMute(t, dev, audioEntityMicrophoneFeature, true) + framesPresented := 0 + var first, last int16 + for framesPresented < audioGainRampFrames { + packet := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + if len(packet) < USBMicrophoneChannels*2 { + t.Fatalf("microphone returned a short packet: %d", len(packet)) + } + if framesPresented == 0 { + first = int16(binary.LittleEndian.Uint16(packet[:2])) + } + lastOffset := len(packet) - USBMicrophoneChannels*2 + last = int16(binary.LittleEndian.Uint16(packet[lastOffset : lastOffset+2])) + framesPresented += len(packet) / (USBMicrophoneChannels * 2) + } + if first <= 0 || first >= 10000 || last != 0 { + t.Fatalf("buffered microphone PCM missed capture-time mute ramp: first=%d last=%d", first, last) + } + if got := int16(binary.LittleEndian.Uint16(frame[:2])); got != 10000 { + t.Fatalf("microphone enqueue source was modified: %d", got) + } +} + +func TestDualSenseAudioLifecycleDropsInactiveAndStalePCM(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + const bytesPerHapticsReport = (BluetoothHapticsSampleSize / 2) * + USBHapticsAudioDownsample * USBHapticsAudioFrameSize + half := make([]byte, bytesPerHapticsReport/2) + speakerCallbacks := 0 + speakerResets := 0 + feedbackCallbacks := 0 + dev.SetSpeakerCallback(func([]byte) { speakerCallbacks++ }) + dev.SetSpeakerResetCallback(func() { speakerResets++ }) + dev.SetOutputCallback(func(OutputState) { feedbackCallbacks++ }) + + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half) + if speakerCallbacks != 0 || len(dev.hapticsPCM) != 0 { + t.Fatal("inactive render endpoint accepted PCM") + } + + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half) + if speakerCallbacks != 1 || len(dev.hapticsPCM) != len(half) { + t.Fatal("active render endpoint did not retain its partial current generation") + } + + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 0) + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, half) + if feedbackCallbacks != 0 || len(dev.hapticsPCM) != len(half) { + t.Fatal("stale haptics PCM crossed an interface close/reopen boundary") + } + + dev.ResetEndpoint(EndpointHapticsAudioOut) + if len(dev.hapticsPCM) != 0 || speakerResets != 4 { + t.Fatalf("speaker endpoint reset did not clear transport state: buffered=%d resets=%d", + len(dev.hapticsPCM), speakerResets) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + micFrame := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(micFrame) + } + if state := dev.microphoneBuffer.State(); state.QueuedBytes == 0 { + t.Fatal("microphone precondition did not queue PCM") + } + dev.ResetEndpoint(EndpointMicrophoneIn) + if !dev.microphoneInterfaceActive || dev.microphoneBuffer.State().QueuedBytes != 0 { + t.Fatal("microphone pipe reset changed alt state or retained PCM") + } +} + +func TestDualSenseFixedSampleFrequencyCompatibilityIsStrict(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + for _, endpoint := range []uint16{EndpointHapticsAudioOut, EndpointMicrophoneIn} { + got, handled := dev.HandleControl(audioClassEndpointIn, + audioClassRequestGetCurrent, uint16(audioControlSamplingFrequency)<<8, + endpoint, 3, nil) + if !handled || !bytes.Equal(got, []byte{0x80, 0xBB, 0x00}) { + t.Fatalf("48 kHz query failed for endpoint %#x: handled=%t got=% x", endpoint, handled, got) + } + if _, handled = dev.handleAudioControlRequest(audioClassEndpointOut, + audioClassRequestSetCurrent, uint16(audioControlSamplingFrequency)<<8, + endpoint, 3, []byte{0x44, 0xAC, 0x00}); handled { + t.Fatalf("endpoint %#x accepted unsupported 44.1 kHz", endpoint) + } + } +} + +func assertAudioFeatureState(t *testing.T, dev *DualSense, entity uint8, + current, minimum, maximum, resolution int16, +) { + t.Helper() + got, handled := dev.HandleControl(audioClassInterfaceIn, + audioClassRequestGetCurrent, uint16(audioControlMute)<<8, + uint16(entity)<<8, 1, nil) + if !handled || !bytes.Equal(got, []byte{0}) { + t.Fatalf("entity %d default mute: handled=%t got=% x", entity, handled, got) + } + assertAudioControlValue(t, dev, entity, audioClassRequestGetCurrent, current) + assertAudioControlValue(t, dev, entity, audioClassRequestGetMinimum, minimum) + assertAudioControlValue(t, dev, entity, audioClassRequestGetMaximum, maximum) + assertAudioControlValue(t, dev, entity, audioClassRequestGetResolution, resolution) +} + +func assertAudioControlValue(t *testing.T, dev *DualSense, entity, request uint8, want int16) { + t.Helper() + got, handled := dev.HandleControl(audioClassInterfaceIn, request, + uint16(audioControlVolume)<<8, uint16(entity)<<8, 2, nil) + if !handled || len(got) != 2 || int16(binary.LittleEndian.Uint16(got)) != want { + t.Fatalf("entity %d request %#x: handled=%t got=% x want=%d", entity, request, handled, got, want) + } +} + +func setAudioMute(t *testing.T, dev *DualSense, entity uint8, mute bool) { + t.Helper() + value := byte(0) + if mute { + value = 1 + } + if _, handled := dev.HandleControl(audioClassInterfaceOut, + audioClassRequestSetCurrent, uint16(audioControlMute)<<8, + uint16(entity)<<8, 1, []byte{value}); !handled { + t.Fatalf("entity %d mute SET_CUR was not handled", entity) + } +} + +func setAudioVolume(t *testing.T, dev *DualSense, entity uint8, volume int16) { + t.Helper() + data := make([]byte, 2) + binary.LittleEndian.PutUint16(data, uint16(volume)) + if _, handled := dev.HandleControl(audioClassInterfaceOut, + audioClassRequestSetCurrent, uint16(audioControlVolume)<<8, + uint16(entity)<<8, 2, data); !handled { + t.Fatalf("entity %d volume SET_CUR was not handled", entity) + } +} diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go new file mode 100644 index 00000000..8c655e05 --- /dev/null +++ b/device/dualsense/bthaptics.go @@ -0,0 +1,181 @@ +package dualsense + +import ( + "encoding/binary" + "errors" +) + +const ( + BluetoothHapticsReportID = 0x32 + BluetoothHapticsReportSize = 141 + BluetoothHapticsSampleSize = 64 + BluetoothHapticsSampleRate = 3000 + + // BluetoothCombinedHapticsReportID is the transport used by vDS for + // controller audio/haptics. Unlike the legacy 0x32 packet, it carries the + // current output state and haptics sample in one HID transaction. The fixed + // 398-byte size is part of the DualSense Bluetooth framing. + BluetoothCombinedHapticsReportID = 0x36 + BluetoothCombinedHapticsReportSize = 398 + BluetoothCombinedStateSize = 63 + BluetoothCombinedHapticsOffset = 78 + BluetoothCombinedSpeakerOffset = 142 + // DS5Dongle exposes the five packet-0x11 buffer fields as a 16-127 + // setting. Its default is 64, which retains a noticeably delayed haptics + // queue when the virtual USB stream is already paced in realtime. Keep the + // stream clock unchanged, but request the smallest documented queue from + // the physical controller. + BluetoothCombinedLowLatencyBufferLength = 16 + + USBHapticsAudioSampleRate = 48000 + USBHapticsAudioChannels = 4 + USBHapticsAudioBytesPerSample = 2 + USBHapticsAudioFrameSize = USBHapticsAudioChannels * USBHapticsAudioBytesPerSample + USBHapticsAudioPacketFrames = USBHapticsAudioSampleRate / 1000 + USBHapticsAudioPacketSize = USBHapticsAudioPacketFrames * USBHapticsAudioFrameSize + // The captured hardware descriptor advertises 392 bytes even though a + // nominal 1 ms 48 kHz, four-channel S16 packet carries 384 bytes. + USBHapticsAudioMaxPacketSize = 392 + USBHapticsAudioDownsample = USBHapticsAudioSampleRate / BluetoothHapticsSampleRate + + BluetoothOutputReportID = 0x31 + BluetoothOutputReportSize = 78 + + bluetoothHapticsCRCSeed = 0xEADA2D49 +) + +var ErrInvalidBluetoothHapticsSample = errors.New("dualsense bluetooth haptics sample must be exactly 64 bytes") +var ErrInvalidUSBOutputReport = errors.New("dualsense USB output report must be report 0x02 with at least 48 bytes") + +// defaultBluetoothCombinedState is the vDS default DualSense output state. +// The remaining 16 bytes of the 63-byte state are intentionally zero. A game +// output report replaces the first 47 bytes when one is available. +var defaultBluetoothCombinedState = [BluetoothCombinedStateSize]byte{ + 0xfd, 0xf7, 0x00, 0x00, 0x7f, 0x64, 0xff, 0x09, 0x00, 0x0f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x0a, 0x07, 0x00, 0x00, 0x02, 0x01, 0x00, 0xff, 0xd7, 0x00, +} + +// BuildBluetoothHapticsReport builds the DualSense Bluetooth HID report used by +// SAxense to stream 3 kHz stereo 8-bit haptics PCM to a paired controller. +func BuildBluetoothHapticsReport(sequence uint8, intervalIndex uint8, sample []byte) ([]byte, error) { + if len(sample) != BluetoothHapticsSampleSize { + return nil, ErrInvalidBluetoothHapticsSample + } + + report := make([]byte, BluetoothHapticsReportSize) + report[0] = BluetoothHapticsReportID + report[1] = (sequence & 0x0F) << 4 + + // Packet 0x11: haptics stream control. The last byte is incremented for + // each 64-byte PCM interval by SAxense. + report[2] = 0x91 + report[3] = 0x07 + report[4] = 0xFE + report[9] = 0xFF + report[10] = intervalIndex + + // Packet 0x12: 64 bytes of signed 8-bit stereo PCM. + report[11] = 0x92 + report[12] = BluetoothHapticsSampleSize + copy(report[13:13+BluetoothHapticsSampleSize], sample) + + binary.LittleEndian.PutUint32(report[BluetoothHapticsReportSize-4:], dualSenseBluetoothCRC32(report[:BluetoothHapticsReportSize-4])) + return report, nil +} + +// BuildBluetoothCombinedHapticsReport builds the vDS-compatible 0x36 report. +// +// Real DualSense Bluetooth traffic combines state, haptics, and speaker data +// into this report. VIIPER owns the virtual USB haptics stream, while +// DS4Windows injects its already-encoded 200-byte Opus frame before forwarding +// the report to a physical controller. Leaving the speaker block empty here is +// intentional: fabricated Opus padding can make the controller reject the +// entire haptics packet. +func BuildBluetoothCombinedHapticsReport(sequence uint8, packetSequence uint8, sample []byte, rawOutputReport []byte) ([]byte, error) { + if len(sample) != BluetoothHapticsSampleSize { + return nil, ErrInvalidBluetoothHapticsSample + } + + report := make([]byte, BluetoothCombinedHapticsReportSize) + report[0] = BluetoothCombinedHapticsReportID + report[1] = (sequence & 0x0F) << 4 + + // Packet 0x11 starts the Bluetooth audio/haptics stream. This is the same + // header and 64-byte interval contract used by vDS. + report[2] = 0x91 + report[3] = 0x07 + report[4] = 0xFE + report[5] = BluetoothCombinedLowLatencyBufferLength + report[6] = BluetoothCombinedLowLatencyBufferLength + report[7] = BluetoothCombinedLowLatencyBufferLength + report[8] = BluetoothCombinedLowLatencyBufferLength + report[9] = BluetoothCombinedLowLatencyBufferLength + report[10] = packetSequence + + // Packet 0x10 is the 63-byte DualSense output state. Start from vDS's + // known-good state, then retain the game's native USB effect payload. + state := defaultBluetoothCombinedState + if len(rawOutputReport) >= OutputReportSize && rawOutputReport[0] == ReportIDOutput { + copy(state[:OutputReportSize-1], rawOutputReport[1:OutputReportSize]) + } + report[11] = 0x90 + report[12] = BluetoothCombinedStateSize + copy(report[13:13+BluetoothCombinedStateSize], state[:]) + + // Packet 0x12 is the 64-byte signed 8-bit stereo haptics payload. + report[76] = 0x92 + report[77] = BluetoothHapticsSampleSize + copy(report[BluetoothCombinedHapticsOffset:BluetoothCombinedHapticsOffset+BluetoothHapticsSampleSize], sample) + + // Packet 0x13 is the optional 200-byte Opus speaker lane. It is explicitly + // empty here; zero-filled bytes masquerading as Opus cause the controller to + // reject the whole packet on some firmware revisions. + report[BluetoothCombinedSpeakerOffset] = 0x93 + report[BluetoothCombinedSpeakerOffset+1] = 0 + + binary.LittleEndian.PutUint32(report[BluetoothCombinedHapticsReportSize-4:], dualSenseBluetoothCRC32(report[:BluetoothCombinedHapticsReportSize-4])) + return report, nil +} + +// BuildBluetoothOutputReportFromUSBOutput maps a native USB DualSense output +// report 0x02 into the Bluetooth report 0x31 shape used by Sony HID-over-BT. +// +// HIDMaestro's DualSense profiles describe this as USB bytes 1-47 +// ("effectPayload") shifted to Bluetooth bytes 3-49, with byte 1 carrying the +// rolling BT tag, byte 2 carrying the BT flag 0x10, and bytes 74-77 carrying a +// Sony CRC32 over prefix [0xA2, 0x31] plus bytes 1-73. +func BuildBluetoothOutputReportFromUSBOutput(sequence uint8, usbReport []byte) ([]byte, error) { + if len(usbReport) < OutputReportSize || usbReport[0] != ReportIDOutput { + return nil, ErrInvalidUSBOutputReport + } + + report := make([]byte, BluetoothOutputReportSize) + report[0] = BluetoothOutputReportID + report[1] = (sequence & 0x0F) << 4 + report[2] = 0x10 + copy(report[3:50], usbReport[1:OutputReportSize]) + + crcInput := make([]byte, 0, 2+73) + crcInput = append(crcInput, 0xA2, BluetoothOutputReportID) + crcInput = append(crcInput, report[1:74]...) + binary.LittleEndian.PutUint32(report[74:78], dualSenseBluetoothCRC32(crcInput)) + return report, nil +} + +func dualSenseBluetoothCRC32(data []byte) uint32 { + crc := ^uint32(bluetoothHapticsCRCSeed) + for _, b := range data { + crc ^= uint32(b) + for i := 0; i < 8; i++ { + mask := uint32(0) + if crc&1 != 0 { + mask = 0xEDB88320 + } + crc = (crc >> 1) ^ mask + } + } + + return ^crc +} diff --git a/device/dualsense/bthaptics_test.go b/device/dualsense/bthaptics_test.go new file mode 100644 index 00000000..d3217915 --- /dev/null +++ b/device/dualsense/bthaptics_test.go @@ -0,0 +1,152 @@ +package dualsense + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestBuildBluetoothHapticsReportMatchesSAxenseLayout(t *testing.T) { + sample := make([]byte, BluetoothHapticsSampleSize) + for i := range sample { + sample[i] = byte(i) + } + + report, err := BuildBluetoothHapticsReport(0x0A, 0x37, sample) + if err != nil { + t.Fatalf("BuildBluetoothHapticsReport failed: %v", err) + } + + if len(report) != BluetoothHapticsReportSize { + t.Fatalf("unexpected report size: got %d want %d", len(report), BluetoothHapticsReportSize) + } + + if report[0] != BluetoothHapticsReportID { + t.Fatalf("unexpected report ID: got %#x want %#x", report[0], BluetoothHapticsReportID) + } + if report[1] != 0xA0 { + t.Fatalf("unexpected tag/sequence byte: got %#x want 0xA0", report[1]) + } + if report[2] != 0x91 || report[3] != 0x07 { + t.Fatalf("unexpected packet 0x11 header: % x", report[2:4]) + } + if !bytes.Equal(report[4:11], []byte{0xFE, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x37}) { + t.Fatalf("unexpected packet 0x11 body: % x", report[4:11]) + } + if report[11] != 0x92 || report[12] != BluetoothHapticsSampleSize { + t.Fatalf("unexpected packet 0x12 header: % x", report[11:13]) + } + if !bytes.Equal(report[13:13+BluetoothHapticsSampleSize], sample) { + t.Fatalf("sample payload was not copied into packet 0x12") + } + + gotCRC := binary.LittleEndian.Uint32(report[BluetoothHapticsReportSize-4:]) + wantCRC := dualSenseBluetoothCRC32(report[:BluetoothHapticsReportSize-4]) + if gotCRC != wantCRC { + t.Fatalf("unexpected crc: got %#x want %#x", gotCRC, wantCRC) + } +} + +func TestBuildBluetoothHapticsReportRejectsWrongSampleSize(t *testing.T) { + if _, err := BuildBluetoothHapticsReport(0, 0, make([]byte, BluetoothHapticsSampleSize-1)); err == nil { + t.Fatal("expected short sample to fail") + } +} + +func TestBuildBluetoothCombinedHapticsReportMatchesVDSLayout(t *testing.T) { + sample := make([]byte, BluetoothHapticsSampleSize) + for i := range sample { + sample[i] = byte(i) + } + raw := make([]byte, OutputReportSize) + raw[0] = ReportIDOutput + raw[1] = 0xA1 + raw[37] = 0x4D + + report, err := BuildBluetoothCombinedHapticsReport(0x0A, 0x37, sample, raw) + if err != nil { + t.Fatalf("BuildBluetoothCombinedHapticsReport failed: %v", err) + } + if len(report) != BluetoothCombinedHapticsReportSize { + t.Fatalf("unexpected report size: got %d want %d", len(report), BluetoothCombinedHapticsReportSize) + } + if report[0] != BluetoothCombinedHapticsReportID || report[1] != 0xA0 { + t.Fatalf("unexpected 0x36 report header: % x", report[:2]) + } + if report[2] != 0x91 || report[3] != 0x07 || report[4] != 0xFE || report[10] != 0x37 { + t.Fatalf("unexpected packet 0x11 body: % x", report[2:11]) + } + for _, bufferLength := range report[5:10] { + if bufferLength != BluetoothCombinedLowLatencyBufferLength { + t.Fatalf("unexpected combined haptics buffer length: got %#x want %#x", + bufferLength, BluetoothCombinedLowLatencyBufferLength) + } + } + if report[11] != 0x90 || report[12] != BluetoothCombinedStateSize { + t.Fatalf("unexpected state block header: % x", report[11:13]) + } + if report[13] != raw[1] || report[13+36] != raw[37] { + t.Fatalf("native USB output payload was not preserved in state block: % x", report[13:76]) + } + if report[76] != 0x92 || report[77] != BluetoothHapticsSampleSize || !bytes.Equal(report[78:142], sample) { + t.Fatalf("unexpected haptics block: % x", report[76:142]) + } + if report[142] != 0x93 || report[143] != 0 { + t.Fatalf("unexpected empty speaker block: % x", report[142:144]) + } + + gotCRC := binary.LittleEndian.Uint32(report[BluetoothCombinedHapticsReportSize-4:]) + wantCRC := dualSenseBluetoothCRC32(report[:BluetoothCombinedHapticsReportSize-4]) + if gotCRC != wantCRC { + t.Fatalf("unexpected crc: got %#x want %#x", gotCRC, wantCRC) + } +} + +func TestBuildBluetoothOutputReportFromUSBOutputMatchesHIDMaestroMapping(t *testing.T) { + usbReport := make([]byte, OutputReportSize) + usbReport[0] = ReportIDOutput + for i := 1; i < len(usbReport); i++ { + usbReport[i] = byte(i) + } + + report, err := BuildBluetoothOutputReportFromUSBOutput(0x07, usbReport) + if err != nil { + t.Fatalf("BuildBluetoothOutputReportFromUSBOutput failed: %v", err) + } + + if len(report) != BluetoothOutputReportSize { + t.Fatalf("unexpected report size: got %d want %d", len(report), BluetoothOutputReportSize) + } + if report[0] != BluetoothOutputReportID { + t.Fatalf("unexpected report ID: got %#x want %#x", report[0], BluetoothOutputReportID) + } + if report[1] != 0x70 { + t.Fatalf("unexpected BT tag: got %#x want 0x70", report[1]) + } + if report[2] != 0x10 { + t.Fatalf("unexpected BT flag: got %#x want 0x10", report[2]) + } + if !bytes.Equal(report[3:50], usbReport[1:OutputReportSize]) { + t.Fatalf("USB effect payload was not shifted to BT bytes 3-49:\n got: % x\nwant: % x", + report[3:50], usbReport[1:OutputReportSize]) + } + + crcInput := append([]byte{0xA2, BluetoothOutputReportID}, report[1:74]...) + gotCRC := binary.LittleEndian.Uint32(report[74:78]) + wantCRC := dualSenseBluetoothCRC32(crcInput) + if gotCRC != wantCRC { + t.Fatalf("unexpected crc: got %#x want %#x", gotCRC, wantCRC) + } +} + +func TestBuildBluetoothOutputReportFromUSBOutputRejectsInvalidReport(t *testing.T) { + if _, err := BuildBluetoothOutputReportFromUSBOutput(0, make([]byte, OutputReportSize-1)); err == nil { + t.Fatal("expected short USB output report to fail") + } + + report := make([]byte, OutputReportSize) + report[0] = 0x31 + if _, err := BuildBluetoothOutputReportFromUSBOutput(0, report); err == nil { + t.Fatal("expected wrong report ID to fail") + } +} diff --git a/device/dualsense/const.go b/device/dualsense/const.go new file mode 100644 index 00000000..c20c14a0 --- /dev/null +++ b/device/dualsense/const.go @@ -0,0 +1,264 @@ +package dualsense + +import "time" + +const ( + DefaultVID = 0x054C + DefaultPIDDSEdge = 0x0DF2 + DefaultPIDDS = 0x0CE6 +) + +const ( + DefaultMACAddressDSEdge = "A5:FE:9C:CF:92:00" // Steam reads this as serial? // TODO: not detected by all apps + DefaultSerialNumberDSEdge = "E55E00GTD1190A500" // Byte 6 (00) is "color code" will be replaced by MetaState + DefaultBoardStringEdge = "HMB-010" +) + +const ( + DefaultMACAddressDS = "A5:FA:9C:CF:92:00" // Steam reads this as serial? // TODO: not detected by all apps + DefaultSerialNumberDS = "E55700GTD1190A500" // Byte 6 (00) is "color code" will be replaced by MetaState + DefaultBoardStringDS = "BDM-050" +) +const ( + DefaultBatteryStatus = BatteryFullyCharged + DefaultTemperature = 28.0 + DefaultVoltage = 3.8 + DefaultShellColor = ShellColorBlack +) + +var DefaultBuildTime = time.Date(2025, time.July, 4, 10, 10, 32, 0, time.UTC) + +const ( + EndpointIn = 0x84 + EndpointOut = 0x03 + EndpointHapticsAudioOut = 0x01 + EndpointMicrophoneIn = 0x82 +) + +const ( + InterfaceAudioControl = 0x00 + InterfaceHapticsAudio = 0x01 + InterfaceMicrophone = 0x02 +) + +const ( + ReportIDInput = 0x01 + ReportIDOutput = 0x02 +) + +const ( + InputReportSize = 64 + OutputReportSize = 48 + InputStateSize = 33 + OutputStateSize = 6 + StreamFrameHeaderSize = 8 + StreamFrameV2HeaderSize = 16 + StreamFrameMagic0 = 0x56 + StreamFrameMagic1 = 0x50 + StreamFrameMagic2 = 0x43 + StreamFrameMagic3 = 0x4D + StreamFrameVersion = 0x01 + StreamFrameVersionV2 = 0x02 + StreamFrameVersionV3 = 0x03 + StreamFrameVersionV4 = 0x04 + StreamFrameInputState = 0x01 + StreamFrameMicrophonePCM = 0x02 + StreamFrameOutputState = 0x81 + StreamFrameSpeakerPCM = 0x82 + // V4 keeps the native feedback generated from one 512-frame USB audio + // generation beside the matching front-channel speaker PCM. The bridge can + // therefore publish one physical Bluetooth report without independently + // scheduled speaker and haptics lanes drifting at a load boundary. + StreamFrameAtomicAudioHaptics = 0x83 + USBMicrophoneSampleRate = 48000 + USBMicrophoneChannels = 2 + USBMicrophoneBytesPerSample = 2 + USBMicrophonePacketFrames = USBMicrophoneSampleRate / 1000 + USBMicrophonePacketSize = USBMicrophonePacketFrames * + USBMicrophoneChannels * USBMicrophoneBytesPerSample + USBMicrophoneMaxPacketSize = USBMicrophonePacketSize + + USBMicrophoneChannels*USBMicrophoneBytesPerSample + USBMicrophoneClientFrameFrames = 480 + USBMicrophoneClientFrameSize = USBMicrophoneClientFrameFrames * + USBMicrophoneChannels * USBMicrophoneBytesPerSample + + // OutputStateCompatExtSize is VIIPER's legacy compact server-to-client + // feedback packet: 6 base bytes plus two 11-byte DualSense trigger effect + // blocks. OutputStateExtSize appends the native USB output report and one + // optional Bluetooth haptics report so clients can forward DualSense + // haptics/control flags without reducing them to generic rumble. + OutputStateCompatExtSize = 28 + OutputStateRawReportOffset = OutputStateCompatExtSize + OutputStateBluetoothHapticsOffset = OutputStateRawReportOffset + OutputReportSize + OutputStateExtSize = OutputStateBluetoothHapticsOffset + BluetoothHapticsReportSize + // OutputStateCombinedExtSize is versioned independently from the legacy + // 0x32 haptics extension. New clients opt into it through the + // dualsensecombinedext device type, so older clients cannot lose stream + // framing when they connect to a newer VIIPER server. + OutputStateCombinedBluetoothOffset = OutputStateRawReportOffset + OutputReportSize + OutputStateCombinedExtSize = OutputStateCombinedBluetoothOffset + BluetoothCombinedHapticsReportSize +) + +const ( + ButtonSquare uint32 = 0x0010 + ButtonCross uint32 = 0x0020 + ButtonCircle uint32 = 0x0040 + ButtonTriangle uint32 = 0x0080 + + ButtonL1 uint32 = 0x0100 + ButtonR1 uint32 = 0x0200 + ButtonL2 uint32 = 0x0400 + ButtonR2 uint32 = 0x0800 + ButtonCreate uint32 = 0x1000 + ButtonOptions uint32 = 0x2000 + ButtonL3 uint32 = 0x4000 + ButtonR3 uint32 = 0x8000 + + ButtonPS uint32 = 0x00010000 + ButtonTouchpad uint32 = 0x00020000 + ButtonMicMute uint32 = 0x00040000 + + ButtonEdgeLFn uint32 = 0x00100000 + ButtonEdgeRFn uint32 = 0x00200000 + ButtonEdgeL4 uint32 = 0x00400000 + ButtonEdgeR4 uint32 = 0x00800000 +) + +const validDualSenseInputButtons uint32 = ButtonSquare | + ButtonCross | + ButtonCircle | + ButtonTriangle | + ButtonL1 | + ButtonR1 | + ButtonL2 | + ButtonR2 | + ButtonCreate | + ButtonOptions | + ButtonL3 | + ButtonR3 | + ButtonPS | + ButtonTouchpad | + ButtonMicMute | + ButtonEdgeLFn | + ButtonEdgeRFn | + ButtonEdgeL4 | + ButtonEdgeR4 + +const ( + DPadUp = 0x01 + DPadDown = 0x02 + DPadLeft = 0x04 + DPadRight = 0x08 +) + +const validDualSenseInputDPad uint8 = DPadUp | DPadDown | DPadLeft | DPadRight + +const ( + DPadUSBUp = 0x00 + DPadUSBUpRight = 0x01 + DPadUSBRight = 0x02 + DPadUSBDownRight = 0x03 + DPadUSBDown = 0x04 + DPadUSBDownLeft = 0x05 + DPadUSBLeft = 0x06 + DPadUSBUpLeft = 0x07 + DPadUSBNeutral = 0x08 +) + +const DPadMask uint8 = 0x0F + +// Gyro/Accel scale factors matching the USB report domain. +// +// Gyro: BMI323 ±2000 dps passthrough = 16.384 LSB/dps +// Accel: BMI323 4096 LSB/g × ScaleAccel(×2) = 8192 LSB/g = 835.07 LSB/(m/s²) +const ( + GyroCountsPerDps = 16.384 + AccelCountsPerMS2 = 835.07 // 8192 / 9.81 +) + +const ( + DefaultAccelXRaw int16 = 0 + DefaultAccelYRaw int16 = 0 + DefaultAccelZRaw int16 = -8192 // -1g (8192 counts/g) +) + +// Touchpad dimensions. +const ( + TouchpadMinX uint16 = 0 + TouchpadMinY uint16 = 0 + TouchpadMaxX uint16 = 1920 + TouchpadMaxY uint16 = 1080 + + TouchInactiveMask uint8 = 0x80 +) + +const DeltaTimeNS = 333 + +const ( + BatteryFullyCharged = 0x2A // Status=0x2 (Full), Level=0xA (100%) +) + +const ( + hidClassIN uint8 = 0xA1 + hidClassOUT uint8 = 0x21 +) + +const ( + hidGetReport uint8 = 0x01 + hidGetIdle uint8 = 0x02 + hidGetProtocol uint8 = 0x03 + hidSetReport uint8 = 0x09 +) + +const ( + reportTypeInput uint8 = 0x01 + reportTypeOutput uint8 = 0x02 + reportTypeFeature uint8 = 0x03 +) + +const ( + featureIDCalibration uint8 = 0x05 + featureIDPairing uint8 = 0x09 + featureIDFirmware uint8 = 0x20 + featureIDCommand uint8 = 0x80 + featureIDCommandResponse uint8 = 0x81 +) + +const ( + subcmdSerial uint8 = 0x01 + subcmdStatus uint8 = 0x03 + subcmdSensors uint8 = 0x04 +) + +const ( + HardwareType uint8 = 0x03 + HwInfo uint32 = 0x01000208 + FirmwareVersion uint16 = 0x0630 +) + +const ( + ShellColorWhite = "00" + ShellColorBlack = "01" + ShellColorCosmicRed = "02" + ShellColorNovaPink = "03" + ShellColorGalacticPurple = "04" + ShellColorStarlightBlue = "05" + ShellColorGreyCamouflage = "06" + ShellColorVolcanicRed = "07" + ShellColorSterlingSilver = "08" + ShellColorCobaltBlue = "09" + ShellColorChromaTeal = "10" + ShellColorChromaIndigo = "11" + ShellColorChromaPearl = "12" + ShellColorAnniversary30th = "30" + ShellColorGodOfWarRagnarok = "Z1" + ShellColorSpiderMan2 = "Z2" + ShellColorAstroBot = "Z3" + ShellColorFortnite = "Z4" + ShellColorMonsterHunterWilds = "Z5" + ShellColorTheLastOfUs = "Z6" + ShellColorGhostOfYotei = "Z7" + ShellColorIconBlueLimitedEdition = "ZB" + ShellColorAstroBotJoyfulEdition = "ZC" + ShellColorGenshinImpact = "ZE" +) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go new file mode 100644 index 00000000..3c276bce --- /dev/null +++ b/device/dualsense/descriptor.go @@ -0,0 +1,405 @@ +package dualsense + +import ( + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" +) + +var defaultDescriptor = usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0x00, + BDeviceSubClass: 0x00, + BDeviceProtocol: 0x00, + BMaxPacketSize0: 0x40, + IDVendor: DefaultVID, + IDProduct: DefaultPIDDS, + BcdDevice: 0x0100, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x00, + BNumConfigurations: 0x01, + Speed: 3, // High speed; required for the 48 kHz UAC stream. + }, + // Match the physical wired DualSense configuration header. The values below + // are part of the device identity exposed to host software, including titles + // that choose a controller-specific UAC route from the USB descriptor. + Configuration: usb.ConfigurationDescriptor{ + BConfigurationValue: 0x01, + BMAttributes: 0xC0, + BMaxPower: 0xFA, + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x01, // AudioControl + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x00, 0x01, 0x49, 0x00, 0x02, 0x01, 0x02}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x06, 0x04, 0x33, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x06, 0x02, 0x01, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x03, 0x03, 0x01, 0x03, 0x04, 0x02, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x04, 0x02, 0x04, 0x03, 0x02, 0x03, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x06, 0x05, 0x04, 0x01, 0x03, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x03, 0x06, 0x01, 0x01, 0x01, 0x05, 0x00}}, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0x01, BAlternateSetting: 0x00, BNumEndpoints: 0x00, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02}, + }, + { + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0x01, BAlternateSetting: 0x01, BNumEndpoints: 0x01, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02}, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x01, 0x01, 0x01, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, USBHapticsAudioChannels, USBHapticsAudioBytesPerSample, 0x10, 0x01, 0x80, 0xBB, 0x00}}, + }, + Endpoints: []usb.EndpointDescriptor{{BEndpointAddress: EndpointHapticsAudioOut, BMAttributes: 0x09, WMaxPacketSize: USBHapticsAudioMaxPacketSize, BInterval: 4, Trailing: usb.Data{0x00, 0x00}, ClassDescriptors: []usb.ClassSpecificDescriptor{{DescriptorType: 0x25, Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}}}}}, + }, + { + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0x02, BAlternateSetting: 0x00, BNumEndpoints: 0x00, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02}, + }, + { + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0x02, BAlternateSetting: 0x01, BNumEndpoints: 0x01, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02}, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x06, 0x01, 0x01, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, 0x02, 0x02, 0x10, 0x01, 0x80, 0xBB, 0x00}}, + }, + Endpoints: []usb.EndpointDescriptor{{BEndpointAddress: EndpointMicrophoneIn, BMAttributes: 0x05, WMaxPacketSize: USBMicrophoneMaxPacketSize, BInterval: 4, Trailing: usb.Data{0x00, 0x00}, ClassDescriptors: []usb.ClassSpecificDescriptor{{DescriptorType: 0x25, Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}}}}}, + }, + { + Descriptor: usb.InterfaceDescriptor{BInterfaceNumber: 0x03, BAlternateSetting: 0x00, BNumEndpoints: 0x02, BInterfaceClass: 0x03, BInterfaceSubClass: 0x00, BInterfaceProtocol: 0x00}, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, + }, + }, + ReportDescriptor: hid.ReportDescriptor{Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageGamePad}, + hid.Collection{ + Kind: hid.CollectionApplication, + Items: []hid.Item{ + hid.ReportID{ID: ReportIDInput}, + hid.Usage{Usage: hid.UsageX}, + hid.Usage{Usage: hid.UsageY}, + hid.Usage{Usage: hid.UsageZ}, + hid.Usage{Usage: hid.UsageRz}, + hid.Usage{Usage: hid.UsageRx}, + hid.Usage{Usage: hid.UsageRy}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 6}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x20}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: 0x39}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 7}, + hid.PhysicalMinimum{Min: 0}, + hid.PhysicalMaximum{Max: 315}, + hid.Unit{Value: 0x14}, + hid.ReportSize{Bits: 4}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainNullState}, + hid.Unit{Value: 0}, + + hid.UsagePage{Page: hid.UsagePageButton}, + hid.UsageMinimum{Min: 0x01}, + hid.UsageMaximum{Max: 0x0F}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 15}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x21}, + hid.ReportCount{Count: 13}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x22}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 52}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: ReportIDOutput}, + hid.Usage{Usage: 0x23}, + hid.ReportCount{Count: OutputReportSize - 1}, + hid.Output{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDCalibration}, hid.Usage{Usage: 0x33}, hid.ReportCount{Count: 40}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x08}, hid.Usage{Usage: 0x34}, hid.ReportCount{Count: 47}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: featureIDPairing}, hid.Usage{Usage: 0x24}, hid.ReportCount{Count: 19}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x0A}, hid.Usage{Usage: 0x25}, hid.ReportCount{Count: 26}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: featureIDFirmware}, hid.Usage{Usage: 0x26}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x21}, hid.Usage{Usage: 0x27}, hid.ReportCount{Count: 4}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x22}, hid.Usage{Usage: 0x40}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x80}, hid.Usage{Usage: 0x28}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x81}, hid.Usage{Usage: 0x29}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x82}, hid.Usage{Usage: 0x2A}, hid.ReportCount{Count: 9}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x83}, hid.Usage{Usage: 0x2B}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x84}, hid.Usage{Usage: 0x2C}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x85}, hid.Usage{Usage: 0x2D}, hid.ReportCount{Count: 2}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xA0}, hid.Usage{Usage: 0x2E}, hid.ReportCount{Count: 1}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xE0}, hid.Usage{Usage: 0x2F}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xF0}, hid.Usage{Usage: 0x30}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xF1}, hid.Usage{Usage: 0x31}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xF2}, hid.Usage{Usage: 0x32}, hid.ReportCount{Count: 15}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xF4}, hid.Usage{Usage: 0x35}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0xF5}, hid.Usage{Usage: 0x36}, hid.ReportCount{Count: 3}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x60}, hid.Usage{Usage: 0x41}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x61}, hid.Usage{Usage: 0x42}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x62}, hid.Usage{Usage: 0x43}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x63}, hid.Usage{Usage: 0x44}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x64}, hid.Usage{Usage: 0x45}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x65}, hid.Usage{Usage: 0x46}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x68}, hid.Usage{Usage: 0x47}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x70}, hid.Usage{Usage: 0x48}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x71}, hid.Usage{Usage: 0x49}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x72}, hid.Usage{Usage: 0x4A}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x73}, hid.Usage{Usage: 0x4B}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x74}, hid.Usage{Usage: 0x4C}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x75}, hid.Usage{Usage: 0x4D}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x76}, hid.Usage{Usage: 0x4E}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x77}, hid.Usage{Usage: 0x4F}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x78}, hid.Usage{Usage: 0x50}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x79}, hid.Usage{Usage: 0x51}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x7A}, hid.Usage{Usage: 0x52}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportID{ID: 0x7B}, hid.Usage{Usage: 0x53}, hid.ReportCount{Count: 63}, hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + }}, + }}, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: EndpointIn, + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 64, + BInterval: 6, + }, + { + BEndpointAddress: EndpointOut, + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 64, + BInterval: 6, + }, + }, + }, + /* Legacy partial UAC topology retained for comparison only. */ + /* + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x01, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x01, // AudioControl + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x24, // CS_INTERFACE + // UAC1 Header: subtype HEADER, ADC 1.00, total class + // descriptor length, one streaming interface (#2). + Payload: usb.Data{0x01, 0x00, 0x01, 0x2A, 0x00, 0x01, 0x02}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Input Terminal: USB streaming source, 4 channels + // advertised as quad (front L/R plus rear L/R). + Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x00, USBHapticsAudioChannels, 0x33, 0x00, 0x00, 0x00}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Feature Unit: topology bridge from terminal 1 to output + // terminal 3. No mute/volume controls are exposed; DS games + // only need the render stream, and omitting this unit makes + // Windows usbaudio reject some otherwise valid topologies. + Payload: usb.Data{0x06, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Output Terminal: speaker/haptics sink, source unit 2. + Payload: usb.Data{0x03, 0x03, 0x01, 0x03, 0x00, 0x02, 0x00}, + }, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x02, // AudioStreaming + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x01, + BNumEndpoints: 0x01, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x02, // AudioStreaming + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x24, // CS_INTERFACE + // AS General: terminal link 1, PCM. + Payload: usb.Data{0x01, 0x01, 0x01, 0x00, 0x01}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Format Type I: 4-channel, 16-bit PCM, one discrete + // sample rate = 48000 Hz. Games expect the wired + // DualSense haptics path to look like a standard + // 4-channel USB audio render endpoint; VIIPER downsamples + // channels 3/4 to the SAxense 3 kHz Bluetooth HID stream. + Payload: usb.Data{0x02, 0x01, USBHapticsAudioChannels, USBHapticsAudioBytesPerSample, 0x10, 0x01, 0x80, 0xBB, 0x00}, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: EndpointHapticsAudioOut, + BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. + WMaxPacketSize: USBHapticsAudioMaxPacketSize, + BInterval: 4, + // Captured wired DualSense descriptors include these two zero + // trailing bytes. Preserve their nine-byte endpoint layout. + Trailing: usb.Data{0x00, 0x00}, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x25, // CS_ENDPOINT + // EP General: no sampling-frequency or pitch controls. + Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}, + }, + }, + }, + }, + }, + */ + }, + Strings: map[uint8]string{ + 0: "\u0409", // LangID: en-US (0x0409) + 1: "Sony Interactive Entertainment", + // Preserve the product string exposed by a physical USB DualSense. + // Windows propagates this into the usbaudio device and its speaker + // endpoint, and some PlayStation titles use that identity when they + // choose a controller-specific audio route. + 2: "DualSense Wireless Controller", + }, +} + +func makeDescriptor(edge bool) usb.Descriptor { + desc := defaultDescriptor + desc.Interfaces = append([]usb.InterfaceConfig(nil), defaultDescriptor.Interfaces...) + desc.Strings = make(map[uint8]string, len(defaultDescriptor.Strings)) + for k, v := range defaultDescriptor.Strings { + desc.Strings[k] = v + } + + for i := range desc.Interfaces { + if desc.Interfaces[i].HID == nil { + continue + } + + hidFunction := *desc.Interfaces[i].HID + reportDescriptor := hidFunction.ReportDescriptor + reportDescriptor.Items = append([]hid.Item(nil), reportDescriptor.Items...) + for i, item := range reportDescriptor.Items { + collection, ok := item.(hid.Collection) + if !ok { + continue + } + + collection.Items = append([]hid.Item(nil), collection.Items...) + if !edge { + collection.Items = withoutEdgeFeatureReports(collection.Items) + } + + reportDescriptor.Items[i] = collection + } + + hidFunction.ReportDescriptor = reportDescriptor + desc.Interfaces[i].HID = &hidFunction + break + } + + desc.Device.IDProduct = DefaultPIDDS + if edge { + desc.Device.IDProduct = DefaultPIDDSEdge + desc.Strings[2] = "DualSense Edge Wireless Controller" + } + + return desc +} + +// makeAudioOnlyDescriptor retains the native PlayStation UAC interfaces while +// omitting the HID gamepad interface. It is used as a sidecar for profiles +// whose game-visible controller is Xbox or Switch, so Windows can expose the +// speaker/microphone endpoints without enumerating a second game controller. +func makeAudioOnlyDescriptor(edge bool) usb.Descriptor { + desc := makeDescriptor(edge) + interfaces := make([]usb.InterfaceConfig, 0, len(desc.Interfaces)) + for _, iface := range desc.Interfaces { + if iface.HID == nil && iface.Descriptor.BInterfaceClass != 0x03 { + interfaces = append(interfaces, iface) + } + } + desc.Interfaces = interfaces + return desc +} + +func withoutEdgeFeatureReports(items []hid.Item) []hid.Item { + filtered := make([]hid.Item, 0, len(items)) + for i := 0; i < len(items); i++ { + reportID, ok := items[i].(hid.ReportID) + if ok && isEdgeFeatureReport(reportID.ID) && i+3 < len(items) { + if _, ok := items[i+1].(hid.Usage); ok { + if _, ok := items[i+2].(hid.ReportCount); ok { + if _, ok := items[i+3].(hid.Feature); ok { + i += 3 + continue + } + } + } + } + + filtered = append(filtered, items[i]) + } + + return filtered +} + +func isEdgeFeatureReport(reportID uint8) bool { + switch reportID { + case 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, + 0x68, + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B: + return true + default: + return false + } +} diff --git a/device/dualsense/device.go b/device/dualsense/device.go new file mode 100644 index 00000000..9add054c --- /dev/null +++ b/device/dualsense/device.go @@ -0,0 +1,1179 @@ +package dualsense + +import ( + "context" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "net" + "os" + "sync" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/microphonebuffer" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +const ( + microphoneTargetClientFrames = 6 // 60 ms absorbs independent radio/client and virtual USB scheduling jitter. + microphoneMaximumClientFrames = 20 // 200 ms emergency ceiling for full-duplex BT bursts; steady state remains about 55 ms. +) + +var rawOutputLogEnabled = os.Getenv("VIIPER_DUALSENSE_RAW_OUTPUT_LOG") == "1" + +type DualSense struct { + inputCh chan InputState + inputState InputState + metaState *MetaState + + speakerFunc func([]byte) + atomicAudioHapticsFunc func(OutputState, []byte) + speakerResetFunc func() + outputFunc func(OutputState) + outputState OutputState + descriptor usb.Descriptor + extendedFeedback bool + combinedBluetoothFeedback bool + microphoneInput bool + speakerOutput bool + streamFrameVersion byte + + subcommand [2]byte + + seqCounter uint8 + hapticsSeq uint8 + hapticsInterval uint8 + hapticsPCM []byte + microphoneBuffer microphonebuffer.Buffer + microphoneSignal chan struct{} + speakerAudioFeature audioFeatureState + microphoneAudioFeature audioFeatureState + speakerStreamTelemetry *dualSenseSpeakerStreamTelemetry + speakerInterfaceActive bool + microphoneInterfaceActive bool + corruptUSBInputReports int + usbInputReportCount uint64 + // hapticsPCMStartedAt identifies the oldest PCM frame waiting to make a + // complete 10.667 ms Bluetooth haptics sample. It is only used by the + // opt-in traffic capture to expose queueing delay without affecting timing. + hapticsPCMStartedAt time.Time + timestampBase time.Time + + mtx sync.Mutex +} + +func New(o *device.CreateOptions) (*DualSense, error) { + return new(o, false) +} +func NewEdge(o *device.CreateOptions) (*DualSense, error) { + return new(o, true) +} + +func new(o *device.CreateOptions, edge bool) (*DualSense, error) { + metaState := &MetaState{ + SerialNumber: DefaultSerialNumberDS, + MACAddress: DefaultMACAddressDS, + Board: DefaultBoardStringDS, + BuildTime: DefaultBuildTime, + BatteryStatus: DefaultBatteryStatus, + TemperatureCelsius: DefaultTemperature, + BatteryVoltage: DefaultVoltage, + ShellColor: DefaultShellColor, + } + if edge { + metaState.SerialNumber = DefaultSerialNumberDSEdge + metaState.MACAddress = DefaultMACAddressDSEdge + metaState.Board = DefaultBoardStringEdge + } + if o != nil && o.DeviceSpecific != "" { + var newMeta MetaState + err := json.Unmarshal([]byte(o.DeviceSpecific), &newMeta) + if err != nil { + return nil, fmt.Errorf("invalid JSON payload: %w", err) + } + if newMeta.SerialNumber != "" { + metaState.SerialNumber = newMeta.SerialNumber + } + if newMeta.MACAddress != "" { + metaState.MACAddress = newMeta.MACAddress + } + if newMeta.Board != "" { + metaState.Board = newMeta.Board + } + if !newMeta.BuildTime.IsZero() { + metaState.BuildTime = newMeta.BuildTime + } + if newMeta.BatteryStatus != 0 { + metaState.BatteryStatus = newMeta.BatteryStatus + } + if newMeta.TemperatureCelsius != 0 { + metaState.TemperatureCelsius = newMeta.TemperatureCelsius + } + if newMeta.BatteryVoltage != 0 { + metaState.BatteryVoltage = newMeta.BatteryVoltage + } + metaState.ShellColor = newMeta.ShellColor + } + + d := &DualSense{ + descriptor: makeDescriptor(edge), + metaState: metaState, + speakerAudioFeature: newSpeakerAudioFeatureState(), + microphoneAudioFeature: newMicrophoneAudioFeatureState(), + microphoneBuffer: microphonebuffer.New( + USBMicrophonePacketSize, + USBMicrophoneChannels*USBMicrophoneBytesPerSample, + USBMicrophoneClientFrameSize, + microphoneTargetClientFrames, + microphoneMaximumClientFrames, + ), + microphoneSignal: make(chan struct{}, 1), + } + + if o != nil { + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor + } + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct + } + } + + slog.Info("DualSense device instantiated", + "edge", edge, + "vid", d.descriptor.Device.IDVendor, + "pid", d.descriptor.Device.IDProduct, + "interfaces", len(d.descriptor.Interfaces)) + + d.inputState = *NewInputState() + d.inputCh = make(chan InputState, 1) + d.inputCh <- d.inputState + d.timestampBase = time.Now() + + return d, nil +} + +func (d *DualSense) SetMetaState(meta MetaState) { + d.mtx.Lock() + defer d.mtx.Unlock() + d.metaState = &meta +} + +func (d *DualSense) SetOutputCallback(f func(OutputState)) { + d.mtx.Lock() + d.outputFunc = f + d.mtx.Unlock() +} + +// SetSpeakerCallback installs a synchronous consumer for the native virtual +// USB audio payload. The callback must copy any bytes it retains after it +// returns; the framed V3 writer does so into its fixed buffer pool. +func (d *DualSense) SetSpeakerCallback(f func([]byte)) { + d.mtx.Lock() + d.speakerFunc = f + d.mtx.Unlock() +} + +// SetAtomicAudioHapticsCallback installs the V4 transport consumer. Each +// callback contains native feedback and the exact front-channel PCM generation +// from which its Bluetooth haptics block was derived. +func (d *DualSense) SetAtomicAudioHapticsCallback(f func(OutputState, []byte)) { + d.mtx.Lock() + d.atomicAudioHapticsFunc = f + d.mtx.Unlock() +} + +// SetSpeakerResetCallback installs the transport-side queue reset paired with +// SetSpeakerCallback. USB interface close/reopen and endpoint reset must discard +// queued speaker PCM from the previous presentation generation. +func (d *DualSense) SetSpeakerResetCallback(f func()) { + d.mtx.Lock() + d.speakerResetFunc = f + d.mtx.Unlock() +} + +// beginSpeakerStream gives each stream generation independent telemetry. An +// older writer can therefore finish a callback without changing the state +// exposed for a replacement connection. +func (d *DualSense) beginSpeakerStream() *dualSenseSpeakerStreamTelemetry { + telemetry := &dualSenseSpeakerStreamTelemetry{} + d.mtx.Lock() + d.speakerStreamTelemetry = telemetry + d.mtx.Unlock() + return telemetry +} + +func (d *DualSense) UpdateInputState(state *InputState) { + next := *NewInputState() + if state != nil { + next = *state + } + + d.mtx.Lock() + d.inputState = next + d.mtx.Unlock() + + select { + case <-d.inputCh: + default: + } + select { + case d.inputCh <- next: + default: + } +} + +func (d *DualSense) GetDescriptor() *usb.Descriptor { + return &d.descriptor +} + +func (d *DualSense) GetDeviceSpecificArgs() map[string]any { + var res map[string]any + d.mtx.Lock() + defer d.mtx.Unlock() + + bytes, err := json.Marshal(d.metaState) + if err != nil { + return map[string]any{} + } + err = json.Unmarshal(bytes, &res) + if err != nil { + return map[string]any{} + } + res["speakerInterfaceActive"] = d.speakerInterfaceActive + speakerState := d.speakerStreamTelemetry.snapshot() + res["speakerStreamActive"] = speakerState.Active + res["speakerPayloadsReceived"] = speakerState.ReceivedPayloads + res["speakerBytesReceived"] = speakerState.ReceivedBytes + res["speakerPayloadsEnqueued"] = speakerState.EnqueuedPayloads + res["speakerBytesEnqueued"] = speakerState.EnqueuedBytes + res["speakerPayloadsDropped"] = speakerState.DroppedPayloads + res["speakerBytesDropped"] = speakerState.DroppedBytes + res["speakerPayloadsWritten"] = speakerState.WrittenPayloads + res["speakerBytesWritten"] = speakerState.WrittenBytes + res["speakerWriteFailures"] = speakerState.WriteFailures + res["speakerQueueDepth"] = speakerState.QueueDepth + res["speakerQueueHighWater"] = speakerState.QueueHighWater + res["speakerMaxEnqueueGapUS"] = speakerState.MaxEnqueueGapUS + res["speakerMaxWriteGapUS"] = speakerState.MaxWriteGapUS + res["microphoneInterfaceActive"] = d.microphoneInterfaceActive + microphoneState := d.microphoneBuffer.State() + res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes + res["microphoneQueueTargetBytes"] = microphoneState.TargetBytes + res["microphoneQueueMaximumBytes"] = microphoneState.MaximumBytes + res["microphoneFilteredQueueBytes"] = microphoneState.FilteredBytes + res["microphoneQueuePrimed"] = microphoneState.Primed + res["microphoneUnderruns"] = microphoneState.Underruns + res["microphoneReprimes"] = microphoneState.Reprimes + res["microphoneDroppedBytes"] = microphoneState.DroppedBytes + res["microphonePacketsRead"] = microphoneState.PacketsRead + res["microphoneZeroPackets"] = microphoneState.ZeroPackets + res["microphoneOverflowEvents"] = microphoneState.OverflowEvents + res["microphoneShortPackets"] = microphoneState.ShortPackets + res["microphoneLongPackets"] = microphoneState.LongPackets + res["microphoneServoRatePPM"] = microphoneState.ServoRatePPM + res["microphoneLowWaterBytes"] = microphoneState.LowWaterBytes + res["microphoneHighWaterBytes"] = microphoneState.HighWaterBytes + res["microphoneQueueFrames"] = microphoneState.QueueFrames + res["microphoneQueueFastGaps"] = microphoneState.QueueFastGaps + res["microphoneQueueLateGaps"] = microphoneState.QueueLateGaps + res["microphoneQueueMinGapUS"] = microphoneState.QueueMinGapUS + res["microphoneQueueMaxGapUS"] = microphoneState.QueueMaxGapUS + res["microphoneReadFastGaps"] = microphoneState.ReadFastGaps + res["microphoneReadLateGaps"] = microphoneState.ReadLateGaps + res["microphoneReadMinGapUS"] = microphoneState.ReadMinGapUS + res["microphoneReadMaxGapUS"] = microphoneState.ReadMaxGapUS + return res +} + +func (d *DualSense) SetInterfaceAltSetting(iface, alt uint8) { + d.mtx.Lock() + var resetSpeaker func() + switch iface { + case InterfaceHapticsAudio: + d.speakerInterfaceActive = alt != 0 + d.resetSpeakerAudioLocked() + resetSpeaker = d.speakerResetFunc + case InterfaceMicrophone: + d.microphoneInterfaceActive = alt != 0 + d.resetMicrophoneAudioLocked() + } + d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } +} + +// ResetEndpoint implements usb.EndpointResetDevice. A standard endpoint pipe +// reset preserves the selected alternate setting and feature controls while +// discarding all transport data from the previous endpoint generation. +func (d *DualSense) ResetEndpoint(endpoint uint8) { + d.mtx.Lock() + var resetSpeaker func() + switch endpoint { + case EndpointHapticsAudioOut: + d.resetSpeakerAudioLocked() + resetSpeaker = d.speakerResetFunc + case EndpointMicrophoneIn: + d.resetMicrophoneAudioLocked() + } + d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } +} + +func (d *DualSense) resetSpeakerAudioLocked() { + d.hapticsPCM = nil + d.hapticsPCMStartedAt = time.Time{} + d.speakerAudioFeature.resetStreamGain() +} + +func (d *DualSense) resetMicrophoneAudioLocked() { + d.microphoneBuffer.Reset() + d.drainMicrophoneSignal() + d.microphoneAudioFeature.resetStreamGain() +} + +func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + // USB/IP carries the endpoint number separately from transfer direction, + // so an IN descriptor address such as 0x82 arrives here as endpoint 2. + epNumber := ep & 0x0F + if dir == usbip.DirIn { + switch epNumber { + case EndpointIn & 0x0F: + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + d.mtx.Lock() + is := d.inputState + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) + } + return nil + case is := <-d.inputCh: + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) + } + case EndpointMicrophoneIn & 0x0F: + return d.handleMicrophoneIn(ctx) + default: + return nil + } + } + + if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + recordTrafficBytes("host->device", "interrupt-out", + out, + "summary", fmt.Sprintf("ep=%d", ep)) + if d.handleOutputReport(out) { + return nil + } + } + if dir == usbip.DirOut && epNumber == EndpointHapticsAudioOut&0x0F { + d.handleHapticsAudioOut(out) + return nil + } + + return nil +} + +func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { + if len(frame) != USBMicrophoneClientFrameSize { + return + } + + d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.mtx.Unlock() + return + } + + d.microphoneBuffer.QueueFrame(frame) + d.mtx.Unlock() + + recordTrafficSummary("client->device", "microphone-pcm-queued", len(frame), + "summary", describeMicrophonePCMFrame(frame)) + + select { + case d.microphoneSignal <- struct{}{}: + default: + } +} + +// ResetMicrophonePCM clears capture transport state after the current API +// stream ends. The API generation coordinator suppresses this reset when that +// stream was displaced by a same-device replacement. +func (d *DualSense) ResetMicrophonePCM() { + d.mtx.Lock() + d.resetMicrophoneAudioLocked() + d.mtx.Unlock() +} + +func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { + packet := make([]byte, USBMicrophoneMaxPacketSize) + for { + d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + d.microphoneAudioFeature.applyPCMInPlace( + packet[:actualLength], USBMicrophoneChannels, + ) + d.mtx.Unlock() + return packet[:actualLength] + } + d.mtx.Unlock() + + select { + case <-ctx.Done(): + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + case <-d.microphoneSignal: + case <-time.After(time.Millisecond): + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + } +} + +func (d *DualSense) drainMicrophoneSignal() { + for { + select { + case <-d.microphoneSignal: + default: + return + } + } +} + +func (d *DualSense) handleHapticsAudioOut(out []byte) { + if len(out) == 0 { + return + } + receivedAt := time.Now() + + recordTrafficBytes("host->device", "audio-haptics-out", + out, + "summary", fmt.Sprintf("ep=%d bytes=%d", EndpointHapticsAudioOut, len(out))) + + d.mtx.Lock() + if !d.speakerInterfaceActive { + d.mtx.Unlock() + return + } + + processed, release := d.speakerAudioFeature.applyPCM(out, USBHapticsAudioChannels) + speakerFunc := d.speakerFunc + atomicAudioHapticsFunc := d.atomicAudioHapticsFunc + if len(d.hapticsPCM) == 0 { + d.hapticsPCMStartedAt = receivedAt + } + d.hapticsPCM = append(d.hapticsPCM, processed...) + reports := d.drainBluetoothHapticsReportsLocked(receivedAt) + // The callback is deliberately completed under the device lock. This makes + // an alternate-setting or endpoint reset a hard generation barrier: once the + // reset acquires the lock, no pre-reset callback can enqueue stale PCM after + // the transport queue has been flushed. + if speakerFunc != nil && atomicAudioHapticsFunc == nil { + speakerFunc(processed) + } + d.mtx.Unlock() + if release != nil { + release() + } + + for _, pending := range reports { + report := pending.data + if len(report) == 0 { + continue + } + + trafficSource := "saxense-hid-0x32" + if d.combinedBluetoothFeedback { + trafficSource = "vds-hid-0x36" + } + + recordTrafficBytes("device->physical", trafficSource, + report, + "reportType", "output", + "reportID", fmt.Sprintf("0x%02X", report[0]), + "summary", fmt.Sprintf("from audio ep=%d bytes=%d assemblyMs=%.3f", + EndpointHapticsAudioOut, BluetoothHapticsSampleSize, + float64(pending.assemblyDelay.Microseconds())/1000.0)) + + d.mtx.Lock() + outputFunc := d.outputFunc + atomicAudioHapticsFunc = d.atomicAudioHapticsFunc + if outputFunc != nil || atomicAudioHapticsFunc != nil { + feedback := d.outputState + if d.combinedBluetoothFeedback { + copy(feedback.BluetoothCombinedOutputReport[:], report) + } else { + copy(feedback.BluetoothHapticsOutputReport[:], report) + } + d.mtx.Unlock() + dispatchStarted := time.Now() + if atomicAudioHapticsFunc != nil { + atomicAudioHapticsFunc(feedback, pending.speakerPCM) + } else { + outputFunc(feedback) + } + recordTrafficEvent(TrafficEvent{ + Direction: "device->bridge", + Source: "feedback-dispatch", + Length: len(report), + Summary: fmt.Sprintf("report=0x%02X callbackMs=%.3f assemblyMs=%.3f", + report[0], + float64(time.Since(dispatchStarted).Microseconds())/1000.0, + float64(pending.assemblyDelay.Microseconds())/1000.0), + }) + } else { + d.mtx.Unlock() + } + } +} + +type pendingBluetoothHapticsReport struct { + data []byte + speakerPCM []byte + assemblyDelay time.Duration +} + +func (d *DualSense) drainBluetoothHapticsReportsLocked(now time.Time) []pendingBluetoothHapticsReport { + const inputBytesPerReport = (BluetoothHapticsSampleSize / 2) * + USBHapticsAudioDownsample * + USBHapticsAudioFrameSize + + if len(d.hapticsPCM) < inputBytesPerReport { + return nil + } + + reports := make([]pendingBluetoothHapticsReport, 0, len(d.hapticsPCM)/inputBytesPerReport) + for len(d.hapticsPCM) >= inputBytesPerReport { + sample := make([]byte, BluetoothHapticsSampleSize) + generationPCM := d.hapticsPCM[:inputBytesPerReport] + copyUSBHapticsChannelsToBluetoothSample(sample, generationPCM) + speakerPCM := make([]byte, (inputBytesPerReport/USBHapticsAudioFrameSize)* + 2*USBHapticsAudioBytesPerSample) + copyDualSenseSpeakerChannels(speakerPCM, generationPCM) + + seq := d.hapticsSeq + interval := d.hapticsInterval + d.hapticsSeq++ + d.hapticsInterval++ + + var report []byte + var err error + if d.combinedBluetoothFeedback { + report, err = BuildBluetoothCombinedHapticsReport(seq, interval, sample, d.outputState.RawOutputReport[:]) + } else { + report, err = BuildBluetoothHapticsReport(seq, interval, sample) + } + if err != nil { + slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err) + } else { + assemblyDelay := now.Sub(d.hapticsPCMStartedAt) + if d.hapticsPCMStartedAt.IsZero() || assemblyDelay < 0 { + assemblyDelay = 0 + } + reports = append(reports, pendingBluetoothHapticsReport{ + data: report, + speakerPCM: speakerPCM, + assemblyDelay: assemblyDelay, + }) + } + + copy(d.hapticsPCM, d.hapticsPCM[inputBytesPerReport:]) + d.hapticsPCM = d.hapticsPCM[:len(d.hapticsPCM)-inputBytesPerReport] + if len(d.hapticsPCM) == 0 { + d.hapticsPCMStartedAt = time.Time{} + } + } + + return reports +} + +func copyUSBHapticsChannelsToBluetoothSample(dst []byte, src []byte) { + const framesPerOutputSample = BluetoothHapticsSampleSize / 2 + + for sampleFrame := 0; sampleFrame < framesPerOutputSample; sampleFrame++ { + blockStart := sampleFrame * USBHapticsAudioDownsample * USBHapticsAudioFrameSize + var leftSum int32 + var rightSum int32 + + for frame := 0; frame < USBHapticsAudioDownsample; frame++ { + frameStart := blockStart + frame*USBHapticsAudioFrameSize + leftSum += int32(int16(binary.LittleEndian.Uint16(src[frameStart+4 : frameStart+6]))) + rightSum += int32(int16(binary.LittleEndian.Uint16(src[frameStart+6 : frameStart+8]))) + } + + left := int16(leftSum / USBHapticsAudioDownsample) + right := int16(rightSum / USBHapticsAudioDownsample) + dst[sampleFrame*2] = byte(left >> 8) + dst[sampleFrame*2+1] = byte(right >> 8) + } +} + +func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { + if response, handled := d.handleAudioControlRequest( + bmRequestType, bRequest, wValue, wIndex, wLength, data, + ); handled { + return response, true + } + + reportType := uint8(wValue >> 8) + reportID := uint8(wValue & 0xFF) + + switch bmRequestType { + case hidClassIN: + switch bRequest { + case hidGetReport: + if reportType == reportTypeInput && reportID == ReportIDInput { + d.mtx.Lock() + is := d.inputState + ms := *d.metaState + d.mtx.Unlock() + b := d.buildUSBInputReport(&is, &ms) + if wLength > 0 && int(wLength) < len(b) { + b = b[:wLength] + } + recordTrafficBytes("device->host", "control-get-report", + b, + "request", "GET_REPORT", + "reportType", describeReportType(reportType), + "reportID", fmt.Sprintf("0x%02X", reportID), + "value", fmt.Sprintf("0x%04X", wValue), + "index", fmt.Sprintf("0x%04X", wIndex), + "summary", "input report") + return b, true + } + if reportType == reportTypeFeature { + if fn, ok := featureGetHandlers[reportID]; ok { + b := fn(d) + if wLength > 0 && int(wLength) < len(b) { + b = b[:wLength] + } + recordTrafficBytes("device->host", "control-get-report", + b, + "request", "GET_REPORT", + "reportType", describeReportType(reportType), + "reportID", fmt.Sprintf("0x%02X", reportID), + "value", fmt.Sprintf("0x%04X", wValue), + "index", fmt.Sprintf("0x%04X", wIndex), + "summary", "feature report") + return b, true + } + } + case hidGetIdle: + return []byte{0x00}, true + case hidGetProtocol: + return []byte{0x01}, true + } + case hidClassOUT: + if bRequest == hidSetReport { + recordTrafficBytes("host->device", "control-set-report", + data, + "request", "SET_REPORT", + "reportType", describeReportType(reportType), + "reportID", fmt.Sprintf("0x%02X", reportID), + "value", fmt.Sprintf("0x%04X", wValue), + "index", fmt.Sprintf("0x%04X", wIndex)) + switch { + case reportType == reportTypeFeature && reportID == featureIDCommand && len(data) >= 3: + d.subcommand[0] = data[1] + d.subcommand[1] = data[2] + return nil, true + case reportType == reportTypeFeature: + return nil, true + case reportType == reportTypeOutput && reportID == ReportIDOutput: + d.handleOutputReport(data) + return nil, true + } + } + } + + slog.Warn("DualSense control request unhandled", + "bmRequestType", bmRequestType, + "bRequest", bRequest, + "reportType", reportType, + "reportID", reportID, + "wIndex", wIndex, + "wLength", wLength, + "dataLen", len(data)) + + return nil, false +} + +func (d *DualSense) handleOutputReport(out []byte) bool { + report, ok := normalizeOutputReport(out) + if !ok { + return false + } + logRawOutputReport(report) + d.mtx.Lock() + outputFunc := d.outputFunc + if outputFunc != nil { + feedback := d.mergeOutputReport(report) + d.mtx.Unlock() + recordTrafficBytes("host->device", "parsed-output-report", + report, + "reportType", describeReportType(reportTypeOutput), + "reportID", fmt.Sprintf("0x%02X", report[0]), + "decodedOutput", describeOutputState(feedback)) + outputFunc(feedback) + } else { + d.mtx.Unlock() + } + return true +} + +func logRawOutputReport(report []byte) { + if !rawOutputLogEnabled { + return + } + + attrs := []any{ + "len", len(report), + "hex", hex.EncodeToString(report), + } + if len(report) > 0 { + attrs = append(attrs, "reportID", fmt.Sprintf("0x%02X", report[0])) + } + if len(report) > 4 { + attrs = append(attrs, + "flags0", fmt.Sprintf("0x%02X", report[1]), + "flags1", fmt.Sprintf("0x%02X", report[2]), + "rumbleSmall", report[3], + "rumbleLarge", report[4]) + } + if len(report) > 31 { + attrs = append(attrs, + "r2", hex.EncodeToString(report[11:21]), + "l2", hex.EncodeToString(report[22:32])) + } + + slog.Info("DualSense raw host output report", attrs...) +} + +func describeReportType(reportType uint8) string { + switch reportType { + case reportTypeInput: + return "input" + case reportTypeOutput: + return "output" + case reportTypeFeature: + return "feature" + default: + return fmt.Sprintf("0x%02X", reportType) + } +} + +func normalizeOutputReport(out []byte) ([]byte, bool) { + if len(out) == 0 { + return nil, false + } + if out[0] == ReportIDOutput { + if len(out) < 5 { + return nil, false + } + return out, true + } + // Some HID SET_REPORT paths deliver the payload without the report ID byte. + // Add it back so the parser can use the same USB report offsets. + if len(out) >= 4 { + report := make([]byte, len(out)+1) + report[0] = ReportIDOutput + copy(report[1:], out) + return report, true + } + return nil, false +} + +var featureGetHandlers = map[byte]func(*DualSense) []byte{ + featureIDCalibration: (*DualSense).featureReportCalibration, + featureIDPairing: (*DualSense).featureReportPairing, + featureIDFirmware: (*DualSense).featureReportFirmware, + featureIDCommandResponse: (*DualSense).featureReportCommandResponse, +} + +func (d *DualSense) mergeOutputReport(out []byte) OutputState { + feedback := d.outputState + clear(feedback.BluetoothHapticsOutputReport[:]) + clear(feedback.BluetoothCombinedOutputReport[:]) + if len(out) >= OutputReportSize { + copy(feedback.RawOutputReport[:], out[:OutputReportSize]) + } + + if len(out) > 4 { + flag0 := out[1] + compatibleVibration := flag0&0x01 != 0 + if len(out) > 39 { + compatibleVibration = compatibleVibration || out[39]&0x04 != 0 + } + if compatibleVibration { + feedback.RumbleSmall = out[3] + feedback.RumbleLarge = out[4] + } + } + if len(out) > 2 { + flag1 := out[2] + if flag1&0x04 != 0 && len(out) > 47 { + feedback.LedRed = out[45] + feedback.LedGreen = out[46] + feedback.LedBlue = out[47] + } + if flag1&0x10 != 0 && len(out) > 44 { + feedback.PlayerLeds = out[44] + } + } + if len(out) > 31 { + flag0 := out[1] + if flag0&0x04 != 0 { + feedback.TriggerR2Mode = out[11] + feedback.TriggerR2StartResistance = out[12] + feedback.TriggerR2EffectForce = out[13] + feedback.TriggerR2RangeForce = out[14] + feedback.TriggerR2NearReleaseStrength = out[15] + feedback.TriggerR2NearMiddleStrength = out[16] + feedback.TriggerR2PressedStrength = out[17] + feedback.TriggerR2Frequency = out[20] + } + if flag0&0x08 != 0 { + feedback.TriggerL2Mode = out[22] + feedback.TriggerL2StartResistance = out[23] + feedback.TriggerL2EffectForce = out[24] + feedback.TriggerL2RangeForce = out[25] + feedback.TriggerL2NearReleaseStrength = out[26] + feedback.TriggerL2NearMiddleStrength = out[27] + feedback.TriggerL2PressedStrength = out[28] + feedback.TriggerL2Frequency = out[31] + } + } + d.outputState = feedback + return feedback +} + +func (d *DualSense) featureReportCalibration() []byte { + report := make([]byte, 41) + report[0] = featureIDCalibration + + for i, v := range [17]int16{ + 0, 0, 0, + 8192, -8192, 8192, -8192, 8192, -8192, + 500, 500, + 8192, -8192, 8192, -8192, 8192, -8192, + } { + binary.LittleEndian.PutUint16(report[1+i*2:], uint16(v)) + } + + report[35] = 0x0B // TODO: + return report +} + +func (d *DualSense) featureReportPairing() []byte { + report := make([]byte, 20) + report[0] = featureIDPairing + + d.mtx.Lock() + mac := d.metaState.MACAddress + d.mtx.Unlock() + + if hw, err := net.ParseMAC(mac); err == nil && len(hw) == 6 { + for i := range 6 { + report[1+i] = hw[5-i] + } + } + + // TODO: + report[7] = 0x08 + report[8] = 0x25 + report[10] = 0x1E + report[12] = 0xEE + report[13] = 0x74 + report[14] = 0xD0 + report[15] = 0xBC + return report +} + +func (d *DualSense) featureReportFirmware() []byte { + report := make([]byte, 64) + report[0] = featureIDFirmware + + d.mtx.Lock() + bt := d.metaState.BuildTime + d.mtx.Unlock() + + copy(report[1:12], bt.Format("Jan 02 2006")) + copy(report[12:20], bt.Format("15:04:05")) + + report[20] = HardwareType + report[21] = 0x01 // TODO: unknown + report[22] = 0x44 // TODO: put in CONST!!! // build revision from real device + + binary.LittleEndian.PutUint32(report[24:28], HwInfo) + + // TODO: unknown + report[28] = 0x36 + report[31] = 0x01 + report[32] = 0xC1 + report[33] = 0xC8 + + binary.LittleEndian.PutUint16(report[44:46], FirmwareVersion) + + // TODO: unknown + report[48] = 0x14 + report[52] = 0x0B + report[54] = 0x01 + report[56] = 0x06 + return report +} + +func (d *DualSense) featureReportCommandResponse() []byte { + report := make([]byte, 64) + report[0] = featureIDCommandResponse + + d.mtx.Lock() + sub := d.subcommand + serial := d.metaState.SerialNumber + voltage := d.metaState.BatteryVoltage + temp := d.metaState.TemperatureCelsius + d.mtx.Unlock() + + switch sub[0] { + case subcmdSerial: + copy(report[3:21], serial) + case subcmdStatus: + // nvs locked + report[1] = 0x01 + report[4] = 0x01 + case subcmdSensors: + vRaw := uint16(math.Round(voltage * 1000)) + report[4] = byte(vRaw) + report[5] = byte(vRaw >> 8) + tRaw := uint16(math.Max(0, math.Min(4095, math.Round((2470.0-temp*26.0)/0.78125)))) + report[6] = byte(tRaw) + report[7] = byte(tRaw >> 8) + default: + slog.Warn("DualSense: unknown sub-command for featureIDCommandResponse", + "sub0", sub[0], "sub1", sub[1]) + report[1] = 0x01 + } + return report +} + +func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { + b := make([]byte, InputReportSize) + d.usbInputReportCount++ + reportCount := d.usbInputReportCount + + b[0] = ReportIDInput + + b[1] = uint8(int16(s.LX) + 128) + b[2] = uint8(int16(s.LY) + 128) + b[3] = uint8(int16(s.RX) + 128) + b[4] = uint8(int16(s.RY) + 128) + + b[5] = s.L2 + b[6] = s.R2 + + d.seqCounter++ + b[7] = d.seqCounter + + usbDPad := uint8(DPadUSBNeutral) + switch { + case s.DPad&DPadUp != 0 && s.DPad&DPadRight != 0: + usbDPad = DPadUSBUpRight + case s.DPad&DPadUp != 0 && s.DPad&DPadLeft != 0: + usbDPad = DPadUSBUpLeft + case s.DPad&DPadDown != 0 && s.DPad&DPadRight != 0: + usbDPad = DPadUSBDownRight + case s.DPad&DPadDown != 0 && s.DPad&DPadLeft != 0: + usbDPad = DPadUSBDownLeft + case s.DPad&DPadUp != 0: + usbDPad = DPadUSBUp + case s.DPad&DPadDown != 0: + usbDPad = DPadUSBDown + case s.DPad&DPadLeft != 0: + usbDPad = DPadUSBLeft + case s.DPad&DPadRight != 0: + usbDPad = DPadUSBRight + } + b[8] = (usbDPad & DPadMask) | (uint8(s.Buttons) & 0xF0) + b[9] = uint8(s.Buttons >> 8) + b[10] = uint8(s.Buttons >> 16) + + binary.LittleEndian.PutUint16(b[16:18], uint16(s.GyroX)) + binary.LittleEndian.PutUint16(b[18:20], uint16(s.GyroY)) + binary.LittleEndian.PutUint16(b[20:22], uint16(s.GyroZ)) + + binary.LittleEndian.PutUint16(b[22:24], uint16(s.AccelX)) + binary.LittleEndian.PutUint16(b[24:26], uint16(s.AccelY)) + binary.LittleEndian.PutUint16(b[26:28], uint16(s.AccelZ)) + + ts := uint32(time.Since(d.timestampBase).Microseconds() * 3) + binary.LittleEndian.PutUint32(b[28:32], ts) + + b[33] = normalizeTouchTracking(s.Touch1Active, s.Touch1Tracking) + encodeTouchCoords(b[34:37], s.Touch1X, s.Touch1Y) + + b[37] = normalizeTouchTracking(s.Touch2Active, s.Touch2Tracking) + encodeTouchCoords(b[38:41], s.Touch2X, s.Touch2Y) + + b[41] = d.seqCounter + binary.LittleEndian.PutUint32(b[49:53], ts) + battery := byte(0) + if m != nil { + battery = m.BatteryStatus + } + b[53] = battery + + corruptReason := "" + if inputStateControlsInvalid(s) { + corruptReason = "invalid input control bits" + } + + if corruptReason != "" { + d.corruptUSBInputReports++ + count := d.corruptUSBInputReports + if count <= 128 || isPowerOfTwo(count) { + slog.Warn("DualSense USB input report was corrupt; report reset to neutral", + "count", count, + "reason", corruptReason) + } + recordTrafficBytes("device->host", "usb-input-report-before-reset", + b, + "summary", describeUSBInputReport(b, reportCount, corruptReason)) + resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery) + } + + recordTrafficBytes("device->host", "usb-input-report", + b, + "summary", describeUSBInputReport(b, reportCount, corruptReason)) + + return b +} + +func inputStateControlsInvalid(s *InputState) bool { + if s == nil { + return false + } + return s.Buttons&^validDualSenseInputButtons != 0 || + s.DPad&^validDualSenseInputDPad != 0 +} + +func describeUSBInputReport(b []byte, count uint64, resetReason string) string { + if len(b) < 54 { + return fmt.Sprintf("count=%d len=%d resetReason=%s", count, len(b), resetReason) + } + + ts := binary.LittleEndian.Uint32(b[28:32]) + return fmt.Sprintf( + "count=%d reportId=0x%02X seq=%d lx=%d ly=%d rx=%d ry=%d l2=%d r2=%d raw8=0x%02X raw9=0x%02X raw10=0x%02X dpadUsb=0x%X touch1=0x%02X touch2=0x%02X ts=%d battery=0x%02X fullMagic=%t markerFrag=%t micLeak=%t resetReason=%s", + count, + b[0], + b[7], + b[1], + b[2], + b[3], + b[4], + b[5], + b[6], + b[8], + b[9], + b[10], + b[8]&DPadMask, + b[33], + b[37], + ts, + b[53], + containsStreamMagic(b), + containsStreamMarkerFragment(b, len(b)), + containsMicTransportLeakPattern(b[16:41]), + resetReason) +} + +func describeMicrophonePCMFrame(frame []byte) string { + const sampleWidth = 2 + if len(frame) < sampleWidth { + return fmt.Sprintf("len=%d", len(frame)) + } + + var sumAbs uint64 + var peak uint16 + sampleCount := 0 + for i := 0; i+1 < len(frame); i += sampleWidth { + raw := binary.LittleEndian.Uint16(frame[i : i+2]) + sample := int32(int16(raw)) + if sample < 0 { + sample = -sample + } + if uint16(sample) > peak { + peak = uint16(sample) + } + sumAbs += uint64(sample) + sampleCount++ + } + + avg := uint64(0) + if sampleCount > 0 { + avg = sumAbs / uint64(sampleCount) + } + + return fmt.Sprintf("pcmLen=%d samples=%d peak=%d avgAbs=%d", len(frame), sampleCount, peak, avg) +} + +func resetUSBInputReportToNeutral(b []byte, seq uint8, timestamp uint32, battery byte) { + for i := range b { + b[i] = 0 + } + + b[0] = ReportIDInput + b[1] = 128 + b[2] = 128 + b[3] = 128 + b[4] = 128 + b[7] = seq + b[8] = DPadUSBNeutral + + x, y, z := DefaultAccelRaw() + binary.LittleEndian.PutUint16(b[22:24], uint16(x)) + binary.LittleEndian.PutUint16(b[24:26], uint16(y)) + binary.LittleEndian.PutUint16(b[26:28], uint16(z)) + binary.LittleEndian.PutUint32(b[28:32], timestamp) + + b[33] = TouchInactiveMask + b[37] = TouchInactiveMask + b[41] = seq + binary.LittleEndian.PutUint32(b[49:53], timestamp) + b[53] = battery +} + +func normalizeTouchTracking(active bool, tracking uint8) uint8 { + if active { + return tracking &^ TouchInactiveMask + } + if tracking == 0 { + return TouchInactiveMask + } + return tracking | TouchInactiveMask +} diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go new file mode 100644 index 00000000..c9b7b957 --- /dev/null +++ b/device/dualsense/device_output_test.go @@ -0,0 +1,754 @@ +package dualsense + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "testing" + + "github.com/Alia5/VIIPER/usbip" +) + +func TestAudioOnlyDescriptorKeepsAudioAndRemovesHID(t *testing.T) { + desc := makeAudioOnlyDescriptor(false) + if got := desc.NumInterfaces(); got != 3 { + t.Fatalf("audio-only descriptor exposes %d interfaces; want 3", got) + } + + var speakerEndpointFound bool + var microphoneEndpointFound bool + for _, iface := range desc.Interfaces { + if iface.HID != nil || iface.Descriptor.BInterfaceClass == 0x03 { + t.Fatalf("audio-only descriptor retained HID interface %d", + iface.Descriptor.BInterfaceNumber) + } + for _, endpoint := range iface.Endpoints { + switch endpoint.BEndpointAddress { + case EndpointHapticsAudioOut: + speakerEndpointFound = true + case EndpointMicrophoneIn: + microphoneEndpointFound = true + } + } + } + + if !speakerEndpointFound || !microphoneEndpointFound { + t.Fatalf("audio-only descriptor endpoints: speaker=%t microphone=%t", + speakerEndpointFound, microphoneEndpointFound) + } +} + +func TestDualSenseUSBOutputReportDescriptorMatchesCapture(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + report, err := dev.GetDescriptor().Interfaces[5].HID.ReportBytes() + if err != nil { + t.Fatalf("ReportBytes returned error: %v", err) + } + + capturedReport, err := hex.DecodeString( + "05010905a1018501093009310932093509330934150026ff007508950681020600ff09209501810205010939150025073500463b016514750495018142650005091901290f150025017501950f81020600ff0921950d81020600ff0922150026ff0075089534810285020923952f9102850509339528b10285080934952fb102850909249513b102850a0925951ab10285200926953fb102852109279504b10285220940953fb10285800928953fb10285810929953fb1028582092a9509b1028583092b953fb1028584092c953fb1028585092d9502b10285a0092e9501b10285e0092f953fb10285f00930953fb10285f10931953fb10285f20932950fb10285f40935953fb10285f509369503b102c0") + if err != nil { + t.Fatalf("DecodeString returned error: %v", err) + } + if !bytes.Equal(report, capturedReport) { + t.Fatalf("USB report descriptor does not match captured DualSense descriptor:\n got: % x\nwant: % x", report, capturedReport) + } +} + +func TestMicrophoneInUsesUSBIPEndpointNumber(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + frame := make([]byte, USBMicrophoneClientFrameSize) + for i := range frame { + frame[i] = byte(i) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + if len(packet) != USBMicrophonePacketSize { + t.Fatalf("unexpected microphone packet length: got %d want %d", + len(packet), USBMicrophonePacketSize) + } + if !bytes.Equal(packet, frame[:USBMicrophonePacketSize]) { + t.Fatal("USB/IP endpoint 2 did not return queued microphone PCM") + } +} + +func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + desc := dev.GetDescriptor() + if desc.Device.IDProduct != DefaultPIDDS { + t.Fatalf("unexpected DualSense PID: %#x", desc.Device.IDProduct) + } + if desc.Strings[2] != "DualSense Wireless Controller" { + t.Fatalf("unexpected DualSense product string: %q", desc.Strings[2]) + } + + report, err := desc.Interfaces[5].HID.ReportBytes() + if err != nil { + t.Fatalf("ReportBytes returned error: %v", err) + } + + edgeFeatureReport, err := hex.DecodeString("85600941953fb102") + if err != nil { + t.Fatalf("DecodeString returned error: %v", err) + } + if bytes.Contains(report, edgeFeatureReport) { + t.Fatalf("normal DualSense descriptor advertises Edge feature report 0x60: % x", report) + } +} + +func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + desc := dev.GetDescriptor() + if desc.Device.Speed != 3 || desc.Device.BcdDevice != 0x0100 { + t.Fatalf("unexpected virtual USB speed/version: speed=%d bcd=%#x", desc.Device.Speed, desc.Device.BcdDevice) + } + if desc.Configuration.BConfigurationValue != 0x01 || + desc.Configuration.BMAttributes != 0xC0 || + desc.Configuration.BMaxPower != 0xFA { + t.Fatalf("unexpected virtual USB configuration: %+v", desc.Configuration) + } + if desc.NumInterfaces() != 4 { + t.Fatalf("unexpected interface count: got %d want 4", desc.NumInterfaces()) + } + + var foundAlt bool + var foundEndpoint bool + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber == 1 && + iface.Descriptor.BAlternateSetting == 1 && + iface.Descriptor.BInterfaceClass == 0x01 && + iface.Descriptor.BInterfaceSubClass == 0x02 { + foundAlt = true + for _, ep := range iface.Endpoints { + if ep.BEndpointAddress == EndpointHapticsAudioOut && + ep.BMAttributes&0x03 == 0x01 && + ep.WMaxPacketSize == USBHapticsAudioMaxPacketSize && + ep.BInterval == 4 && + bytes.Equal(ep.Trailing, []byte{0x00, 0x00}) { + foundEndpoint = true + } + } + } + } + if !foundAlt { + t.Fatal("experimental haptics audio streaming altsetting was not found") + } + if !foundEndpoint { + t.Fatal("experimental haptics audio OUT endpoint was not found") + } + + var audioControlClassLength int + var foundHeader bool + var foundInputTerminal bool + var foundFeatureUnit bool + var foundOutputTerminal bool + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != 0 || iface.Descriptor.BAlternateSetting != 0 { + continue + } + + for _, classDescriptor := range iface.ClassDescriptors { + audioControlClassLength += len(classDescriptor.Bytes()) + raw := classDescriptor.Bytes() + if len(raw) < 3 || raw[1] != 0x24 { + continue + } + + switch raw[2] { + case 0x01: + foundHeader = true + if len(raw) < 7 || !bytes.Equal(raw[5:7], []byte{0x49, 0x00}) { + t.Fatalf("unexpected AudioControl header descriptor: % x", raw) + } + case 0x02: + if len(raw) < 4 || raw[3] != 0x01 { + continue + } + foundInputTerminal = true + if len(raw) < 10 || raw[7] != USBHapticsAudioChannels || + !bytes.Equal(raw[8:10], []byte{0x33, 0x00}) { + t.Fatalf("unexpected haptics audio input terminal descriptor: % x", raw) + } + case 0x06: + if len(raw) < 4 || raw[3] != 0x02 { + continue + } + foundFeatureUnit = true + if len(raw) < 6 || raw[3] != 0x02 || raw[4] != 0x01 { + t.Fatalf("unexpected haptics audio feature unit descriptor: % x", raw) + } + case 0x03: + if len(raw) < 4 || raw[3] != 0x03 { + continue + } + foundOutputTerminal = true + if len(raw) < 8 || raw[3] != 0x03 || raw[7] != 0x02 { + t.Fatalf("unexpected haptics audio output terminal descriptor: % x", raw) + } + } + } + } + if audioControlClassLength != 0x49 { + t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x49", audioControlClassLength) + } + if !foundHeader || !foundInputTerminal || !foundFeatureUnit || !foundOutputTerminal { + t.Fatalf("incomplete AudioControl topology: header=%t input=%t feature=%t output=%t", + foundHeader, foundInputTerminal, foundFeatureUnit, foundOutputTerminal) + } + + var foundFormat bool + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != 1 || iface.Descriptor.BAlternateSetting != 1 { + continue + } + + for _, classDescriptor := range iface.ClassDescriptors { + raw := classDescriptor.Bytes() + if len(raw) == 11 && raw[2] == 0x02 { + foundFormat = true + if raw[4] != USBHapticsAudioChannels || + raw[5] != USBHapticsAudioBytesPerSample || + raw[6] != 0x10 || + !bytes.Equal(raw[8:11], []byte{0x80, 0xBB, 0x00}) { + t.Fatalf("unexpected haptics audio format descriptor: % x", raw) + } + } + } + } + if !foundFormat { + t.Fatal("experimental haptics audio format descriptor was not found") + } +} + +func TestDualSenseEdgeDescriptorAdvertisesEdgeFeatureReports(t *testing.T) { + dev, err := NewEdge(nil) + if err != nil { + t.Fatalf("NewEdge returned error: %v", err) + } + + desc := dev.GetDescriptor() + if desc.Device.IDProduct != DefaultPIDDSEdge { + t.Fatalf("unexpected Edge PID: %#x", desc.Device.IDProduct) + } + if desc.Strings[2] != "DualSense Edge Wireless Controller" { + t.Fatalf("unexpected Edge product string: %q", desc.Strings[2]) + } + + report, err := desc.Interfaces[5].HID.ReportBytes() + if err != nil { + t.Fatalf("ReportBytes returned error: %v", err) + } + + edgeFeatureReport, err := hex.DecodeString("85600941953fb102") + if err != nil { + t.Fatalf("DecodeString returned error: %v", err) + } + if !bytes.Contains(report, edgeFeatureReport) { + t.Fatalf("Edge descriptor does not advertise feature report 0x60: % x", report) + } +} + +func TestDualSenseOutputReportFromEndpoint(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + var got OutputState + called := false + dev.SetOutputCallback(func(out OutputState) { + got = out + called = true + }) + + report := make([]byte, OutputReportSize) + report[0] = ReportIDOutput + report[1] = 0x0F + report[3] = 0x22 + report[4] = 0x88 + report[11] = 0x21 + report[12] = 0xFC + report[13] = 0x03 + report[20] = 0x44 + report[22] = 0x25 + report[23] = 0x40 + report[24] = 0x05 + report[31] = 0x55 + + dev.HandleTransfer(context.Background(), EndpointOut, usbip.DirOut, report) + + if !called { + t.Fatal("expected output callback") + } + if got.RumbleSmall != 0x22 || got.RumbleLarge != 0x88 { + t.Fatalf("unexpected rumble: small=%#x large=%#x", got.RumbleSmall, got.RumbleLarge) + } + if got.TriggerR2Mode != 0x21 || got.TriggerR2StartResistance != 0xFC || + got.TriggerR2EffectForce != 0x03 || got.TriggerR2Frequency != 0x44 { + t.Fatalf("unexpected R2 trigger feedback: %#v", got) + } + if got.TriggerL2Mode != 0x25 || got.TriggerL2StartResistance != 0x40 || + got.TriggerL2EffectForce != 0x05 || got.TriggerL2Frequency != 0x55 { + t.Fatalf("unexpected L2 trigger feedback: %#v", got) + } + if !bytes.Equal(got.RawOutputReport[:], report) { + t.Fatalf("raw output report was not preserved:\n got: % x\nwant: % x", got.RawOutputReport, report) + } +} + +func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + var got OutputState + dev.SetOutputCallback(func(out OutputState) { + got = out + }) + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + + SetTrafficDiagnosticsEnabled(true, true) + defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true) + + pcm := make([]byte, (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample*USBHapticsAudioFrameSize) + for outputFrame := 0; outputFrame < BluetoothHapticsSampleSize/2; outputFrame++ { + for frame := 0; frame < USBHapticsAudioDownsample; frame++ { + frameStart := (outputFrame*USBHapticsAudioDownsample + frame) * USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(pcm[frameStart+4:frameStart+6], uint16(int16(outputFrame*256))) + binary.LittleEndian.PutUint16(pcm[frameStart+6:frameStart+8], uint16(int16((outputFrame+1)*-256))) + } + } + + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, pcm) + events := TrafficDiagnosticsSnapshot() + + var sawAudioOut bool + var sawSAxense bool + for _, event := range events { + switch event.Source { + case "audio-haptics-out": + sawAudioOut = true + if event.Length != len(pcm) { + t.Fatalf("unexpected audio event length: got %d want %d", event.Length, len(pcm)) + } + case "saxense-hid-0x32": + sawSAxense = true + if event.ReportID != "0x32" { + t.Fatalf("unexpected generated report ID: %s", event.ReportID) + } + if event.Length != BluetoothHapticsReportSize { + t.Fatalf("unexpected generated report length: got %d want %d", event.Length, BluetoothHapticsReportSize) + } + } + } + if !sawAudioOut { + t.Fatal("expected audio-haptics-out diagnostic event") + } + if !sawSAxense { + t.Fatal("expected generated SAxense HID 0x32 diagnostic event") + } + if got.BluetoothHapticsOutputReport[0] != BluetoothHapticsReportID { + t.Fatalf("expected callback haptics report ID 0x%02x, got 0x%02x", + BluetoothHapticsReportID, + got.BluetoothHapticsOutputReport[0]) + } +} + +func TestDualSenseCombinedHapticsAudioOutBuildsVDSReports(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.combinedBluetoothFeedback = true + + var got OutputState + dev.SetOutputCallback(func(out OutputState) { + got = out + }) + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + + pcm := make([]byte, (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample*USBHapticsAudioFrameSize) + for outputFrame := 0; outputFrame < BluetoothHapticsSampleSize/2; outputFrame++ { + for frame := 0; frame < USBHapticsAudioDownsample; frame++ { + frameStart := (outputFrame*USBHapticsAudioDownsample + frame) * USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(pcm[frameStart+4:frameStart+6], uint16(int16(outputFrame*256))) + binary.LittleEndian.PutUint16(pcm[frameStart+6:frameStart+8], uint16(int16((outputFrame+1)*-256))) + } + } + + SetTrafficDiagnosticsEnabled(true, true) + defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true) + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, pcm) + + if got.BluetoothCombinedOutputReport[0] != BluetoothCombinedHapticsReportID { + t.Fatalf("expected combined callback report ID 0x%02x, got 0x%02x", + BluetoothCombinedHapticsReportID, got.BluetoothCombinedOutputReport[0]) + } + if got.BluetoothCombinedOutputReport[11] != 0x90 || + got.BluetoothCombinedOutputReport[76] != 0x92 || + got.BluetoothCombinedOutputReport[142] != 0x93 { + t.Fatalf("combined report did not contain vDS state, haptics, and speaker blocks: % x", + got.BluetoothCombinedOutputReport[11:144]) + } + + var sawCombined bool + for _, event := range TrafficDiagnosticsSnapshot() { + if event.Source == "vds-hid-0x36" { + sawCombined = true + if event.ReportID != "0x36" || event.Length != BluetoothCombinedHapticsReportSize { + t.Fatalf("unexpected combined traffic event: %#v", event) + } + } + } + if !sawCombined { + t.Fatal("expected vds-hid-0x36 diagnostic event") + } +} + +func TestDualSenseAudioEndpointSamplingFrequencyControls(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + for _, request := range []uint8{ + audioClassRequestGetCurrent, + audioClassRequestGetMinimum, + audioClassRequestGetMaximum, + } { + got, handled := dev.HandleControl(audioClassEndpointIn, request, 0x0100, EndpointHapticsAudioOut, 3, nil) + if !handled || !bytes.Equal(got, []byte{0x80, 0xBB, 0x00}) { + t.Fatalf("unexpected sampling frequency response for request %#x: handled=%t response=% x", request, handled, got) + } + } + + got, handled := dev.HandleControl(audioClassEndpointIn, audioClassRequestGetResolution, 0x0100, EndpointHapticsAudioOut, 3, nil) + if !handled || !bytes.Equal(got, []byte{0x00, 0x00, 0x00}) { + t.Fatalf("unexpected sampling frequency resolution response: handled=%t response=% x", handled, got) + } + + if _, handled = dev.HandleControl(audioClassEndpointOut, audioClassRequestSetCurrent, 0x0100, EndpointHapticsAudioOut, 3, []byte{0x80, 0xBB, 0x00}); !handled { + t.Fatal("expected SET_CUR sampling frequency request to be accepted") + } +} + +func TestDualSenseHapticsSelectDoesNotUpdateCompatibleRumble(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + var got OutputState + dev.SetOutputCallback(func(out OutputState) { + got = out + }) + + rumbleReport := make([]byte, OutputReportSize) + rumbleReport[0] = ReportIDOutput + rumbleReport[1] = 0x01 + rumbleReport[3] = 0x22 + rumbleReport[4] = 0x88 + dev.HandleTransfer(context.Background(), EndpointOut, usbip.DirOut, rumbleReport) + + hapticsSelectReport := make([]byte, OutputReportSize) + hapticsSelectReport[0] = ReportIDOutput + hapticsSelectReport[1] = 0x02 + hapticsSelectReport[3] = 0xFF + hapticsSelectReport[4] = 0xFF + dev.HandleTransfer(context.Background(), EndpointOut, usbip.DirOut, hapticsSelectReport) + + if got.RumbleSmall != 0x22 || got.RumbleLarge != 0x88 { + t.Fatalf("haptics-select-only report changed compatible rumble: small=%#x large=%#x", got.RumbleSmall, got.RumbleLarge) + } + if !bytes.Equal(got.RawOutputReport[:], hapticsSelectReport) { + t.Fatalf("haptics-select raw report was not preserved:\n got: % x\nwant: % x", got.RawOutputReport, hapticsSelectReport) + } +} + +func TestDualSenseOutputSetReportWithoutReportId(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + var got OutputState + called := false + dev.SetOutputCallback(func(out OutputState) { + got = out + called = true + }) + + payload := make([]byte, OutputReportSize-1) + payload[0] = 0x03 + payload[2] = 0x33 + payload[3] = 0x99 + + _, handled := dev.HandleControl(hidClassOUT, hidSetReport, + uint16(reportTypeOutput)<<8|uint16(ReportIDOutput), + 0, uint16(len(payload)), payload) + + if !handled { + t.Fatal("expected SET_REPORT output to be handled") + } + if !called { + t.Fatal("expected output callback") + } + if got.RumbleSmall != 0x33 || got.RumbleLarge != 0x99 { + t.Fatalf("unexpected rumble: small=%#x large=%#x", got.RumbleSmall, got.RumbleLarge) + } +} + +func TestDualSenseOutputFlagsPreserveUnchangedTriggers(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + var got OutputState + dev.SetOutputCallback(func(out OutputState) { + got = out + }) + + triggerReport := make([]byte, OutputReportSize) + triggerReport[0] = ReportIDOutput + triggerReport[1] = 0x04 + triggerReport[11] = 0x21 + triggerReport[12] = 0xFC + triggerReport[13] = 0x03 + triggerReport[20] = 0x44 + dev.HandleTransfer(context.Background(), EndpointOut, usbip.DirOut, triggerReport) + + rumbleReport := make([]byte, OutputReportSize) + rumbleReport[0] = ReportIDOutput + rumbleReport[1] = 0x03 + rumbleReport[3] = 0x22 + rumbleReport[4] = 0x88 + dev.HandleTransfer(context.Background(), EndpointOut, usbip.DirOut, rumbleReport) + + if got.RumbleSmall != 0x22 || got.RumbleLarge != 0x88 { + t.Fatalf("unexpected rumble: small=%#x large=%#x", got.RumbleSmall, got.RumbleLarge) + } + if got.TriggerR2Mode != 0x21 || got.TriggerR2StartResistance != 0xFC || + got.TriggerR2EffectForce != 0x03 || got.TriggerR2Frequency != 0x44 { + t.Fatalf("rumble-only report cleared R2 trigger feedback: %#v", got) + } +} + +func TestDualSenseTouchTrackingBytes(t *testing.T) { + state := &InputState{} + data, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + data[15] = 0x05 + data[20] = 0x86 + + var decoded InputState + if err := decoded.UnmarshalBinary(data); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if !decoded.Touch1Active || decoded.Touch1Tracking != 0x05 { + t.Fatalf("unexpected touch 1 status: active=%v tracking=%#x", decoded.Touch1Active, decoded.Touch1Tracking) + } + if decoded.Touch2Active || decoded.Touch2Tracking != 0x86 { + t.Fatalf("unexpected touch 2 status: active=%v tracking=%#x", decoded.Touch2Active, decoded.Touch2Tracking) + } + + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + decoded.Touch2Active = false + report := dev.buildUSBInputReport(&decoded, &MetaState{}) + if report[33] != 0x05 { + t.Fatalf("unexpected touch 1 report tracking byte: %#x", report[33]) + } + if report[37] != 0x86 { + t.Fatalf("unexpected touch 2 report tracking byte: %#x", report[37]) + } + if report[41] == 0 { + t.Fatal("expected touch packet counter to be populated") + } + if report[49] == 0x10 && report[50] == 0 && report[51] == 0 && report[52] == 0 { + t.Fatal("unexpected legacy hard-coded status byte in report timestamp area") + } +} + +func TestDualSenseTouchTrackingZeroUsesActiveFallback(t *testing.T) { + state := &InputState{ + Touch1Active: true, + Touch1Tracking: 0, + } + data, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + var decoded InputState + if err := decoded.UnmarshalBinary(data); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if !decoded.Touch1Active || decoded.Touch1Tracking != 1 { + t.Fatalf("active touch without tracking id should use fallback: active=%v tracking=%#x", decoded.Touch1Active, decoded.Touch1Tracking) + } + + state.Touch1Active = false + data, err = state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if data[15] != TouchInactiveMask { + t.Fatalf("inactive touch should use inactive mask, got %#x", data[15]) + } +} + +func TestDualSenseUSBInputReportPreservesArbitraryMotionBytes(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + state := NewInputState() + state.GyroZ = int16(uint16(StreamFrameMagic0) | uint16(StreamFrameMagic1)<<8) + state.AccelX = int16(uint16(StreamFrameMagic2) | uint16(StreamFrameMagic3)<<8) + state.LX = 17 + state.RY = -42 + state.R2 = 200 + state.Buttons = ButtonCross + + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + if !containsStreamMagic(report) { + t.Fatalf("expected arbitrary motion bytes to survive: % x", report) + } + if dev.corruptUSBInputReports != 0 { + t.Fatalf("valid motion was incorrectly rejected, resets=%d", dev.corruptUSBInputReports) + } + if report[1] != 145 || report[4] != 86 || report[5] != 0 || report[6] != 200 || + report[8]&byte(ButtonCross) == 0 { + t.Fatalf("valid controls were not preserved: % x", report[:11]) + } +} + +func TestDualSenseUSBInputReportNeutralizesInvalidControlBits(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + state := NewInputState() + state.LX = 17 + state.R2 = 200 + state.Buttons = 1 << 31 + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one invalid-control reset, got %d", dev.corruptUSBInputReports) + } + if report[1] != 128 || report[2] != 128 || report[3] != 128 || report[4] != 128 || + report[5] != 0 || report[6] != 0 || report[8] != DPadUSBNeutral || + report[9] != 0 || report[10] != 0 { + t.Fatalf("invalid controls were not reset to neutral: % x", report[:11]) + } + if report[53] != BatteryFullyCharged { + t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) + } +} + +func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { + out := OutputState{ + RumbleSmall: 0x11, + RumbleLarge: 0x22, + TriggerR2Mode: 0x21, + TriggerR2StartResistance: 0x33, + TriggerR2PressedStrength: 0x44, + TriggerR2Frequency: 0x55, + TriggerL2Mode: 0x25, + TriggerL2StartResistance: 0x66, + TriggerL2PressedStrength: 0x77, + TriggerL2Frequency: 0x88, + } + out.RawOutputReport[0] = ReportIDOutput + out.RawOutputReport[1] = 0x02 + out.RawOutputReport[3] = 0x99 + + data, err := out.MarshalExtendedBinary() + if err != nil { + t.Fatalf("MarshalExtendedBinary returned error: %v", err) + } + if len(data) != OutputStateExtSize { + t.Fatalf("unexpected extended feedback length: %d", len(data)) + } + if data[6] != 0x21 || data[7] != 0x33 || data[12] != 0x44 || data[15] != 0x55 { + t.Fatalf("unexpected R2 trigger block: % x", data[6:17]) + } + if data[17] != 0x25 || data[18] != 0x66 || data[23] != 0x77 || data[26] != 0x88 { + t.Fatalf("unexpected L2 trigger block: % x", data[17:28]) + } + + var decoded OutputState + if err := decoded.UnmarshalBinary(data); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + if decoded.TriggerR2Frequency != out.TriggerR2Frequency || + decoded.TriggerL2Frequency != out.TriggerL2Frequency { + t.Fatalf("unexpected decoded frequencies: R2=%#x L2=%#x", decoded.TriggerR2Frequency, decoded.TriggerL2Frequency) + } + if !bytes.Equal(decoded.RawOutputReport[:], out.RawOutputReport[:]) { + t.Fatalf("raw output report did not round-trip:\n got: % x\nwant: % x", decoded.RawOutputReport, out.RawOutputReport) + } +} + +func TestDualSenseCombinedExtendedFeedbackRoundTrips(t *testing.T) { + out := OutputState{} + out.RawOutputReport[0] = ReportIDOutput + out.RawOutputReport[1] = 0x24 + out.BluetoothCombinedOutputReport[0] = BluetoothCombinedHapticsReportID + out.BluetoothCombinedOutputReport[76] = 0x92 + out.BluetoothCombinedOutputReport[142] = 0x93 + + data, err := out.MarshalCombinedExtendedBinary() + if err != nil { + t.Fatalf("MarshalCombinedExtendedBinary returned error: %v", err) + } + if len(data) != OutputStateCombinedExtSize { + t.Fatalf("unexpected combined feedback length: got %d want %d", len(data), OutputStateCombinedExtSize) + } + + var decoded OutputState + if err := decoded.UnmarshalBinary(data); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + if !bytes.Equal(decoded.RawOutputReport[:], out.RawOutputReport[:]) { + t.Fatalf("raw output report did not round-trip:\n got: % x\nwant: % x", decoded.RawOutputReport, out.RawOutputReport) + } + if !bytes.Equal(decoded.BluetoothCombinedOutputReport[:], out.BluetoothCombinedOutputReport[:]) { + t.Fatalf("combined Bluetooth output report did not round-trip:\n got: % x\nwant: % x", decoded.BluetoothCombinedOutputReport, out.BluetoothCombinedOutputReport) + } +} diff --git a/device/dualsense/diagnostics.go b/device/dualsense/diagnostics.go new file mode 100644 index 00000000..0b66bcd0 --- /dev/null +++ b/device/dualsense/diagnostics.go @@ -0,0 +1,179 @@ +package dualsense + +import ( + "encoding/hex" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" +) + +const trafficEventLimit = 65536 + +type TrafficEvent struct { + TimeUTC string `json:"timeUtc"` + Direction string `json:"direction"` + Source string `json:"source"` + ReportType string `json:"reportType,omitempty"` + ReportID string `json:"reportId,omitempty"` + Request string `json:"request,omitempty"` + Value string `json:"value,omitempty"` + Index string `json:"index,omitempty"` + Length int `json:"length"` + Hex string `json:"hex,omitempty"` + Summary string `json:"summary,omitempty"` + DecodedOutput string `json:"decodedOutput,omitempty"` +} + +var trafficDiagnosticsEnabled atomic.Bool + +var trafficDiagnostics = struct { + sync.Mutex + events []TrafficEvent +}{} + +func init() { + trafficDiagnosticsEnabled.Store(rawOutputLogEnabled) +} + +func SetTrafficDiagnosticsEnabled(enabled bool, clear bool) { + trafficDiagnosticsEnabled.Store(enabled) + if clear { + ClearTrafficDiagnostics() + } + slog.Info("DualSense traffic diagnostics toggled", "enabled", enabled, "clear", clear) +} + +func TrafficDiagnosticsEnabled() bool { + return trafficDiagnosticsEnabled.Load() +} + +func ClearTrafficDiagnostics() { + trafficDiagnostics.Lock() + trafficDiagnostics.events = nil + trafficDiagnostics.Unlock() +} + +func TrafficDiagnosticsSnapshot() []TrafficEvent { + trafficDiagnostics.Lock() + defer trafficDiagnostics.Unlock() + + events := make([]TrafficEvent, len(trafficDiagnostics.events)) + copy(events, trafficDiagnostics.events) + return events +} + +func recordTrafficEvent(event TrafficEvent) { + if !TrafficDiagnosticsEnabled() { + return + } + + if event.TimeUTC == "" { + event.TimeUTC = time.Now().UTC().Format(time.RFC3339Nano) + } + + trafficDiagnostics.Lock() + if len(trafficDiagnostics.events) >= trafficEventLimit { + copy(trafficDiagnostics.events, trafficDiagnostics.events[1:]) + trafficDiagnostics.events[len(trafficDiagnostics.events)-1] = event + } else { + trafficDiagnostics.events = append(trafficDiagnostics.events, event) + } + trafficDiagnostics.Unlock() + + attrs := []any{ + "direction", event.Direction, + "source", event.Source, + "len", event.Length, + } + if event.ReportType != "" { + attrs = append(attrs, "reportType", event.ReportType) + } + if event.ReportID != "" { + attrs = append(attrs, "reportID", event.ReportID) + } + if event.Request != "" { + attrs = append(attrs, "request", event.Request) + } + if event.Summary != "" { + attrs = append(attrs, "summary", event.Summary) + } + if event.DecodedOutput != "" { + attrs = append(attrs, "decodedOutput", event.DecodedOutput) + } + if event.Hex != "" { + attrs = append(attrs, "hex", event.Hex) + } + slog.Info("DualSense host traffic", attrs...) +} + +func recordTrafficBytes(direction, source string, data []byte, fields ...any) { + event := TrafficEvent{ + Direction: direction, + Source: source, + Length: len(data), + Hex: hex.EncodeToString(data), + } + recordTrafficEventWithFields(event, fields...) +} + +func recordTrafficSummary(direction, source string, length int, fields ...any) { + event := TrafficEvent{ + Direction: direction, + Source: source, + Length: length, + } + recordTrafficEventWithFields(event, fields...) +} + +func recordTrafficEventWithFields(event TrafficEvent, fields ...any) { + for i := 0; i+1 < len(fields); i += 2 { + key, _ := fields[i].(string) + value := fmt.Sprint(fields[i+1]) + switch key { + case "reportType": + event.ReportType = value + case "reportID": + event.ReportID = value + case "request": + event.Request = value + case "value": + event.Value = value + case "index": + event.Index = value + case "summary": + event.Summary = value + case "decodedOutput": + event.DecodedOutput = value + } + } + recordTrafficEvent(event) +} + +func describeOutputState(out OutputState) string { + return fmt.Sprintf( + "rumbleSmall=%d rumbleLarge=%d led=%d,%d,%d playerLeds=0x%02X r2=%02X/%02X/%02X/%02X/%02X/%02X/%02X/%02X l2=%02X/%02X/%02X/%02X/%02X/%02X/%02X/%02X", + out.RumbleSmall, + out.RumbleLarge, + out.LedRed, + out.LedGreen, + out.LedBlue, + out.PlayerLeds, + out.TriggerR2Mode, + out.TriggerR2StartResistance, + out.TriggerR2EffectForce, + out.TriggerR2RangeForce, + out.TriggerR2NearReleaseStrength, + out.TriggerR2NearMiddleStrength, + out.TriggerR2PressedStrength, + out.TriggerR2Frequency, + out.TriggerL2Mode, + out.TriggerL2StartResistance, + out.TriggerL2EffectForce, + out.TriggerL2RangeForce, + out.TriggerL2NearReleaseStrength, + out.TriggerL2NearMiddleStrength, + out.TriggerL2PressedStrength, + out.TriggerL2Frequency) +} diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go new file mode 100644 index 00000000..1145e950 --- /dev/null +++ b/device/dualsense/ds_handler.go @@ -0,0 +1,505 @@ +package dualsense + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "hash/crc32" + "io" + "log/slog" + "net" + "strings" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("dualsense", &dshandler{}) + api.RegisterDevice("dualsenseext", &dshandler{extendedFeedback: true}) + api.RegisterDevice("dualsensecombinedext", &dshandler{combinedBluetoothFeedback: true}) + api.RegisterDevice("dualsensecombinedmicext", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion}) + api.RegisterDevice("dualsensecombinedmicv2", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2}) + api.RegisterDevice("dualsensecombinedaudioduplexv3", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV3}) + api.RegisterDevice("dualsensecombinedaudioduplexv4", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV4}) + api.RegisterDevice("dualsenseaudioonlyduplexv3", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, audioOnly: true, streamFrameVersion: StreamFrameVersionV3}) + api.RegisterDevice("dualsenseaudioonlyduplexv4", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, audioOnly: true, streamFrameVersion: StreamFrameVersionV4}) +} + +type dshandler struct { + extendedFeedback bool + combinedBluetoothFeedback bool + microphoneInput bool + speakerOutput bool + audioOnly bool + streamFrameVersion byte +} + +func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + if o == nil { + o = &device.CreateOptions{} + } + + metaState := MetaState{ + ShellColor: DefaultShellColor, + } + if o.DeviceSpecific != "" { + if err := json.Unmarshal([]byte(o.DeviceSpecific), &metaState); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + } + + serial := metaState.SerialNumber + if serial == "" { + serial = DefaultSerialNumberDS + } + if metaState.ShellColor != "" && len(serial) >= 6 { + code := strings.ToUpper(metaState.ShellColor) + if len(code) >= 2 { + serial = serial[:4] + code[:2] + serial[6:] + } + } + if _, ok := serials[serial]; ok { + for i := 1; i < 16; i++ { + newSerial := fmt.Sprintf("%s%02X", serial[:len(serial)-2], i) + if _, exists := serials[newSerial]; !exists { + serial = newSerial + break + } + } + } + metaState.SerialNumber = serial + serials[serial] = struct{}{} + + mac := metaState.MACAddress + if mac == "" { + mac = DefaultMACAddressDS + } + if _, ok := macs[mac]; ok { + prefix := mac[:len(mac)-2] + for i := 1; i <= 16; i++ { + candidate := fmt.Sprintf("%s%02X", prefix, i) + if _, exists := macs[candidate]; !exists { + mac = candidate + break + } + } + } + metaState.MACAddress = mac + macs[mac] = struct{}{} + + b, err := json.Marshal(metaState) + if err != nil { + return nil, fmt.Errorf("marshal meta state: %w", err) + } + o.DeviceSpecific = string(b) + + dse, err := new(o, false) + if err != nil { + return nil, err + } + dse.extendedFeedback = h.extendedFeedback + dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback + dse.microphoneInput = h.microphoneInput + dse.speakerOutput = h.speakerOutput + if h.audioOnly { + dse.descriptor = makeAudioOnlyDescriptor(false) + } + dse.streamFrameVersion = h.streamFrameVersion + return dse, nil +} + +func (h *dshandler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + defer func() { + if devPtr == nil || *devPtr == nil { + return + } + dse, ok := (*devPtr).(*DualSense) + if !ok { + slog.Warn("device is not DualSenseEdge on disconnect") + return + } + dse.mtx.Lock() + serial := dse.metaState.SerialNumber + mac := dse.metaState.MACAddress + dse.mtx.Unlock() + delete(serials, serial) + delete(macs, mac) + slog.Debug("DualSenseEdge disconnected, serial/mac released", "serial", serial, "mac", mac) + }() + + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + dse, ok := (*devPtr).(*DualSense) + if !ok { + return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) + } + + microphoneInput := h.microphoneInput || dse.microphoneInput + speakerOutput := h.speakerOutput || dse.speakerOutput + streamFrameVersion := h.streamFrameVersion + if dse.streamFrameVersion != 0 { + streamFrameVersion = dse.streamFrameVersion + } + logger.Info("DualSense input stream configured", + "microphoneInput", microphoneInput, + "speakerOutput", speakerOutput, + "frameVersion", streamFrameVersion) + + marshalFeedback := func(feedback OutputState) ([]byte, error) { + var data []byte + var err error + if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback { + data, err = feedback.MarshalCombinedExtendedBinary() + } else if h.extendedFeedback || dse.extendedFeedback { + data, err = feedback.MarshalExtendedBinary() + } else { + data, err = feedback.MarshalBinary() + } + if err != nil { + return nil, err + } + return data, nil + } + + if speakerOutput { + if streamFrameVersion != StreamFrameVersionV3 && + streamFrameVersion != StreamFrameVersionV4 { + return fmt.Errorf("DualSense speaker output requires framed stream version 0x%02X or 0x%02X", + StreamFrameVersionV3, StreamFrameVersionV4) + } + + writer := newDualSenseOutputWriter(conn, streamFrameVersion, + dse.beginSpeakerStream(), logger) + go writer.Run() + dse.SetOutputCallback(func(feedback OutputState) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + writer.EnqueueControl(StreamFrameOutputState, data) + }) + if streamFrameVersion == StreamFrameVersionV4 { + dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal atomic audio/haptics feedback", "error", err) + return + } + writer.EnqueueAtomicAudioHaptics(data, speakerPCM) + }) + } else { + dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + } + dse.SetSpeakerResetCallback(writer.ResetSpeaker) + defer func() { + dse.SetOutputCallback(nil) + dse.SetSpeakerCallback(nil) + dse.SetAtomicAudioHapticsCallback(nil) + dse.SetSpeakerResetCallback(nil) + writer.Stop() + }() + } else { + dse.SetOutputCallback(func(feedback OutputState) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send feedback", "error", err) + } + }) + defer dse.SetOutputCallback(nil) + } + + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) + } +} + +func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger, microphoneInput bool) error { + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, StreamFrameVersion) +} + +func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog.Logger, microphoneInput bool, frameVersion byte) error { + if !microphoneInput { + buf := make([]byte, InputStateSize) + for { + if _, err := io.ReadFull(conn, buf); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read input state: %w", err) + } + + var state InputState + if err := state.UnmarshalBinary(buf); err != nil { + return fmt.Errorf("unmarshal input state: %w", err) + } + dse.UpdateInputState(&state) + } + } + + if frameVersion != StreamFrameVersion && + frameVersion != StreamFrameVersionV2 && + frameVersion != StreamFrameVersionV3 && + frameVersion != StreamFrameVersionV4 { + return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", + frameVersion) + } + + headerSize := StreamFrameHeaderSize + if frameVersion == StreamFrameVersionV2 || + frameVersion == StreamFrameVersionV3 || + frameVersion == StreamFrameVersionV4 { + headerSize = StreamFrameV2HeaderSize + } + header := make([]byte, headerSize) + input := make([]byte, InputStateSize) + microphonePCM := make([]byte, USBMicrophoneClientFrameSize) + var expectedSequence uint32 + sequenceInitialized := false + for { + if _, err := io.ReadFull(conn, header); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read stream frame header: %w", err) + } + + if header[0] != StreamFrameMagic0 || + header[1] != StreamFrameMagic1 || + header[2] != StreamFrameMagic2 || + header[3] != StreamFrameMagic3 { + return fmt.Errorf("invalid DualSense framed stream magic %02X %02X %02X %02X", + header[0], header[1], header[2], header[3]) + } + if header[4] != frameVersion { + return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", header[4]) + } + + frameType := header[5] + payloadLen := int(binary.LittleEndian.Uint16(header[6:8])) + + var payload []byte + switch frameType { + case StreamFrameInputState: + if payloadLen != InputStateSize { + return fmt.Errorf("invalid framed input state length %d", payloadLen) + } + payload = input + case StreamFrameMicrophonePCM: + if payloadLen != USBMicrophoneClientFrameSize { + return fmt.Errorf("invalid microphone pcm frame length %d", payloadLen) + } + payload = microphonePCM + default: + return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X length %d", frameType, payloadLen) + } + + if _, err := io.ReadFull(conn, payload); err != nil { + return fmt.Errorf("read framed packet type 0x%02X: %w", frameType, err) + } + + if frameVersion == StreamFrameVersionV2 || + frameVersion == StreamFrameVersionV3 || + frameVersion == StreamFrameVersionV4 { + sequence := binary.LittleEndian.Uint32(header[8:12]) + if sequenceInitialized && sequence != expectedSequence { + return fmt.Errorf("DualSense framed stream sequence mismatch: got %d expected %d", sequence, expectedSequence) + } + expectedSequence = sequence + 1 + sequenceInitialized = true + + receivedCRC := binary.LittleEndian.Uint32(header[12:16]) + calculatedCRC := framedStreamCRC(header[4:12], payload) + if receivedCRC != calculatedCRC { + return fmt.Errorf("DualSense framed stream CRC mismatch for sequence %d: got %08X expected %08X", sequence, receivedCRC, calculatedCRC) + } + } + + switch frameType { + case StreamFrameInputState: + corruptReason := inputStatePayloadCorruptionReason(input) + recordTrafficBytes("client->device", "framed-input-state", + input, + "summary", describeInputStatePayload(input, corruptReason)) + if corruptReason != "" { + return fmt.Errorf("invalid framed input state: %s", corruptReason) + } + var state InputState + if err := state.UnmarshalBinary(input); err != nil { + return fmt.Errorf("unmarshal framed input state: %w", err) + } + dse.UpdateInputState(&state) + case StreamFrameMicrophonePCM: + recordTrafficSummary("client->device", "framed-microphone-pcm", len(microphonePCM), + "summary", describeMicrophonePCMFrame(microphonePCM)) + dse.QueueMicrophonePCMFrame(microphonePCM) + } + } +} + +func framedStreamCRC(headerFields, payload []byte) uint32 { + hash := crc32.NewIEEE() + _, _ = hash.Write(headerFields) + _, _ = hash.Write(payload) + return hash.Sum32() +} + +func inputStatePayloadCorruptionReason(input []byte) string { + if len(input) < InputStateSize { + return fmt.Sprintf("invalid length %d", len(input)) + } + + buttons := binary.LittleEndian.Uint32(input[4:8]) + dpad := input[8] + if buttons&^validDualSenseInputButtons != 0 || + dpad&^validDualSenseInputDPad != 0 { + return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) + } + + return "" +} + +func describeInputStatePayload(input []byte, corruptReason string) string { + if len(input) < InputStateSize { + return fmt.Sprintf("len=%d corruptReason=%s", len(input), corruptReason) + } + + buttons := binary.LittleEndian.Uint32(input[4:8]) + dpad := input[8] + gyroX := int16(binary.LittleEndian.Uint16(input[21:23])) + gyroY := int16(binary.LittleEndian.Uint16(input[23:25])) + gyroZ := int16(binary.LittleEndian.Uint16(input[25:27])) + accelX := int16(binary.LittleEndian.Uint16(input[27:29])) + accelY := int16(binary.LittleEndian.Uint16(input[29:31])) + accelZ := int16(binary.LittleEndian.Uint16(input[31:33])) + + return fmt.Sprintf( + "lx=%d ly=%d rx=%d ry=%d buttons=0x%08X dpad=0x%02X l2=%d r2=%d touch1Status=0x%02X touch2Status=0x%02X gyro=%d,%d,%d accel=%d,%d,%d fullMagic=%t markerFragControls=%t micLeak=%t corruptReason=%s", + int8(input[0]), + int8(input[1]), + int8(input[2]), + int8(input[3]), + buttons, + dpad, + input[9], + input[10], + input[15], + input[20], + gyroX, + gyroY, + gyroZ, + accelX, + accelY, + accelZ, + containsStreamMagic(input), + containsStreamMarkerFragment(input, len(input)), + containsMicTransportLeakPattern(input[11:]), + corruptReason) +} + +func containsStreamMagic(data []byte) bool { + const magicLength = 4 + if len(data) < magicLength { + return false + } + + for i := 0; i+magicLength <= len(data); i++ { + if data[i] == StreamFrameMagic0 && + data[i+1] == StreamFrameMagic1 && + data[i+2] == StreamFrameMagic2 && + data[i+3] == StreamFrameMagic3 { + return true + } + } + + return false +} + +func containsStreamMarkerFragment(data []byte, length int) bool { + const markerLength = 3 + if len(data) < markerLength || length < markerLength { + return false + } + + end := min(length, len(data)) + for i := 0; i+markerLength <= end; i++ { + if data[i] == StreamFrameMagic0 && + data[i+1] == StreamFrameMagic1 && + data[i+2] == StreamFrameMagic2 { + return true + } + if data[i] == StreamFrameMagic1 && + data[i+1] == StreamFrameMagic2 && + data[i+2] == StreamFrameMagic3 { + return true + } + } + + return false +} + +func containsMicTransportLeakPattern(data []byte) bool { + return containsStrongMicTransportLeakPattern(data) || + containsWeakMicTransportLeakPattern(data) +} + +func containsStrongMicTransportLeakPattern(data []byte) bool { + return containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) || + containsByteSequence(data, []byte{StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) || + containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}) || + containsByteSequence(data, []byte{StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}) +} + +func containsWeakMicTransportLeakPattern(data []byte) bool { + return containsByteSequence(data, []byte{0x01, 0x01, hidClassOUT}) || + containsByteSequence(data, []byte{0x80, 0x87, StreamFrameMagic2}) +} + +func containsByteSequence(data []byte, sequence []byte) bool { + if len(sequence) == 0 || len(data) < len(sequence) { + return false + } + + for i := 0; i+len(sequence) <= len(data); i++ { + found := true + for j := range sequence { + if data[i+j] != sequence[j] { + found = false + break + } + } + if found { + return true + } + } + + return false +} + +func isPowerOfTwo(value int) bool { + return value > 0 && value&(value-1) == 0 +} + +func (h *dshandler) UpdateMetaState(meta string, dev *usb.Device) error { + dse, ok := (*dev).(*DualSense) + if !ok { + return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) + } + dse.mtx.Lock() + current := *dse.metaState + dse.mtx.Unlock() + if err := json.Unmarshal([]byte(meta), ¤t); err != nil { + return fmt.Errorf("unmarshal meta state: %w", err) + } + dse.SetMetaState(current) + return nil +} diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go new file mode 100644 index 00000000..094eb958 --- /dev/null +++ b/device/dualsense/ds_handler_test.go @@ -0,0 +1,580 @@ +package dualsense + +import ( + "context" + "encoding/binary" + "hash/crc32" + "io" + "log/slog" + "net" + "slices" + "strings" + "testing" + + "github.com/Alia5/VIIPER/usbip" +) + +func makeStreamFrame(t *testing.T, frameType byte, payload []byte) []byte { + t.Helper() + if len(payload) > 0xFFFF { + t.Fatalf("payload too large: %d", len(payload)) + } + + frame := make([]byte, StreamFrameHeaderSize+len(payload)) + copy(frame, makeStreamFrameHeader(frameType, len(payload))) + copy(frame[StreamFrameHeaderSize:], payload) + return frame +} + +func makeStreamFrameHeader(frameType byte, payloadLength int) []byte { + frame := make([]byte, StreamFrameHeaderSize) + frame[0] = StreamFrameMagic0 + frame[1] = StreamFrameMagic1 + frame[2] = StreamFrameMagic2 + frame[3] = StreamFrameMagic3 + frame[4] = StreamFrameVersion + frame[5] = frameType + binary.LittleEndian.PutUint16(frame[6:8], uint16(payloadLength)) + return frame +} + +func makeStreamFrameV2(frameType byte, sequence uint32, payload []byte) []byte { + return makeStreamFrameWithCRC(StreamFrameVersionV2, frameType, sequence, payload) +} + +func makeStreamFrameV3(frameType byte, sequence uint32, payload []byte) []byte { + return makeStreamFrameWithCRC(StreamFrameVersionV3, frameType, sequence, payload) +} + +func makeStreamFrameWithCRC(version, frameType byte, sequence uint32, payload []byte) []byte { + frame := make([]byte, StreamFrameV2HeaderSize+len(payload)) + frame[0] = StreamFrameMagic0 + frame[1] = StreamFrameMagic1 + frame[2] = StreamFrameMagic2 + frame[3] = StreamFrameMagic3 + frame[4] = version + frame[5] = frameType + binary.LittleEndian.PutUint16(frame[6:8], uint16(len(payload))) + binary.LittleEndian.PutUint32(frame[8:12], sequence) + copy(frame[StreamFrameV2HeaderSize:], payload) + hash := crc32.NewIEEE() + _, _ = hash.Write(frame[4:12]) + _, _ = hash.Write(payload) + binary.LittleEndian.PutUint32(frame[12:16], hash.Sum32()) + return frame +} + +func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStream(server, dev, logger, true) + }() + + state := NewInputState() + state.LX = 42 + state.R2 = 99 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + microphonePayload := make([]byte, USBMicrophoneClientFrameSize) + for i := range microphonePayload { + microphonePayload[i] = byte(i) + } + + if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + for range microphoneTargetClientFrames { + if _, err := client.Write(makeStreamFrame(t, StreamFrameMicrophonePCM, microphonePayload)); err != nil { + t.Fatalf("write microphone frame: %v", err) + } + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + + if err := <-errCh; err != nil { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + gotMicrophoneState := dev.microphoneBuffer.State() + dev.mtx.Unlock() + + if gotInput.LX != 42 || gotInput.R2 != 99 { + t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2) + } + if gotMicrophoneState.QueuedBytes != USBMicrophoneClientFrameSize*microphoneTargetClientFrames || + !gotMicrophoneState.Primed { + t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState) + } +} + +func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) + }() + + state := NewInputState() + state.LX = 42 + state.R2 = 99 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + microphonePayload := make([]byte, USBMicrophoneClientFrameSize) + for i := range microphonePayload { + microphonePayload[i] = byte(i) + } + + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, inputPayload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + if _, err := client.Write(makeStreamFrameV2(StreamFrameMicrophonePCM, 1, microphonePayload)); err != nil { + t.Fatalf("write microphone frame: %v", err) + } + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 2, inputPayload)); err != nil { + t.Fatalf("write interleaved input frame: %v", err) + } + for frameIndex := 1; frameIndex < microphoneTargetClientFrames; frameIndex++ { + sequence := uint32(frameIndex + 2) + if _, err := client.Write(makeStreamFrameV2(StreamFrameMicrophonePCM, sequence, microphonePayload)); err != nil { + t.Fatalf("write microphone frame %d: %v", frameIndex+1, err) + } + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + + if err := <-errCh; err != nil { + t.Fatalf("readDualSenseInputStreamVersion returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + gotMicrophoneState := dev.microphoneBuffer.State() + dev.mtx.Unlock() + + if gotInput.LX != state.LX || gotInput.R2 != state.R2 { + t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2) + } + if gotMicrophoneState.QueuedBytes != len(microphonePayload)*microphoneTargetClientFrames || + !gotMicrophoneState.Primed { + t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState) + } + gotMicrophone := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + if !slices.Equal(gotMicrophone, microphonePayload[:USBMicrophonePacketSize]) { + t.Fatal("microphone payload changed in transport") + } +} + +func TestReadDualSenseInputStreamV3RetainsInputAndMicrophoneFraming(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + server, client := net.Pipe() + defer server.Close() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, + StreamFrameVersionV3) + }() + + state := NewInputState() + state.LX = 21 + state.R2 = 87 + state.Buttons = ButtonTriangle + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + microphonePayload := make([]byte, USBMicrophoneClientFrameSize) + for index := range microphonePayload { + microphonePayload[index] = byte(index) + } + + if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState, 0, + inputPayload)); err != nil { + t.Fatalf("write V3 input frame: %v", err) + } + for frame := 0; frame < microphoneTargetClientFrames; frame++ { + if _, err := client.Write(makeStreamFrameV3(StreamFrameMicrophonePCM, + uint32(frame+1), microphonePayload)); err != nil { + t.Fatalf("write V3 microphone frame %d: %v", frame, err) + } + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("V3 input reader returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + gotMicrophoneState := dev.microphoneBuffer.State() + dev.mtx.Unlock() + if gotInput.LX != state.LX || gotInput.R2 != state.R2 || + gotInput.Buttons != state.Buttons { + t.Fatalf("V3 input state changed: got %+v want %+v", gotInput, state) + } + if gotMicrophoneState.QueuedBytes != USBMicrophoneClientFrameSize* + microphoneTargetClientFrames || !gotMicrophoneState.Primed { + t.Fatalf("unexpected V3 microphone state: %+v", gotMicrophoneState) + } +} + +func TestBaseDispatcherHonorsCreatedV2StreamProtocol(t *testing.T) { + variant := &dshandler{ + combinedBluetoothFeedback: true, + microphoneInput: true, + streamFrameVersion: StreamFrameVersionV2, + } + dev, err := variant.CreateDevice(nil) + if err != nil { + t.Fatalf("CreateDevice returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + // The runtime dispatcher infers the concrete Go type as plain + // "dualsense", so exercise the base registration here as well. + errCh <- (&dshandler{}).StreamHandler()(server, &dev, logger) + }() + + state := NewInputState() + state.LX = 42 + state.R2 = 99 + state.Buttons = ButtonCross + payload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, payload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("base stream handler rejected V2 variant framing: %v", err) + } + + dualSense := dev.(*DualSense) + dualSense.mtx.Lock() + got := dualSense.inputState + dualSense.mtx.Unlock() + if got.LX != state.LX || got.R2 != state.R2 || got.Buttons != state.Buttons { + t.Fatalf("V2 frame was not preserved: got LX=%d R2=%d buttons=%#x", + got.LX, got.R2, got.Buttons) + } +} + +func TestReadDualSenseInputStreamRejectsUnversionedMicFrames(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStream(server, dev, logger, true) + }() + + oldStylePrefix := []byte{StreamFrameMicrophonePCM, 0, 0, 0, 0, 0, 0, 0} + if _, err := client.Write(oldStylePrefix); err != nil { + t.Fatalf("write old style prefix: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + + err = <-errCh + if err == nil || !strings.Contains(err.Error(), "invalid DualSense framed stream magic") { + t.Fatalf("expected invalid magic error, got %v", err) + } + + gotMicrophoneLen := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int) + if gotMicrophoneLen != 0 { + t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen) + } +} + +func TestQueueMicrophonePCMFrameRequiresActiveInterface(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + frame := make([]byte, USBMicrophoneClientFrameSize) + dev.QueueMicrophonePCMFrame(frame) + if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != 0 { + t.Fatalf("inactive mic interface should drop PCM, got %d bytes", queued) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + dev.QueueMicrophonePCMFrame(frame) + if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != USBMicrophoneClientFrameSize { + t.Fatalf("active mic interface should queue PCM, got %d bytes", queued) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 0) + if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != 0 { + t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", queued) + } +} + +func TestQueueMicrophonePCMFrameKeepsNewestMaximumFrames(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + for value := 0; value <= microphoneMaximumClientFrames; value++ { + frame := make([]byte, USBMicrophoneClientFrameSize) + for i := range frame { + frame[i] = byte(value) + } + dev.QueueMicrophonePCMFrame(frame) + } + + state := dev.GetDeviceSpecificArgs() + if queued := state["queuedMicrophoneBytes"].(int); queued != USBMicrophoneClientFrameSize*microphoneMaximumClientFrames { + t.Fatalf("unexpected bounded queue length: %d", queued) + } + if dropped := state["microphoneDroppedBytes"].(uint64); dropped != USBMicrophoneClientFrameSize { + t.Fatalf("unexpected dropped byte count: %d", dropped) + } + packet := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + if packet[0] != 1 || packet[len(packet)-1] != 1 { + t.Fatalf("queue did not retain the newest maximum frames: % x", packet[:8]) + } +} + +func TestReadDualSenseInputStreamV2PreservesArbitraryMotionBytes(t *testing.T) { + patterns := map[string][]byte{ + "full stream magic": {StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}, + "marker fragment": {StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}, + "strong CM pattern": {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, + "strong CP pattern": {StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}, + "weak CM pattern": {0x01, 0x01, hidClassOUT}, + "weak CP pattern": {0x80, 0x87, StreamFrameMagic2}, + } + + for name, pattern := range patterns { + t.Run(name, func(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) + }() + + state := NewInputState() + state.LX = 12 + state.R2 = 34 + state.Buttons = ButtonCross + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[21:], pattern) + + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 0, inputPayload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("readDualSenseInputStreamVersion returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + gotPayload, err := gotInput.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if !slices.Equal(gotPayload[21:21+len(pattern)], pattern) { + t.Fatalf("valid motion bytes changed: got % x want % x", gotPayload[21:21+len(pattern)], pattern) + } + if gotInput.LX != state.LX || gotInput.R2 != state.R2 || gotInput.Buttons != state.Buttons { + t.Fatalf("valid controls changed: got %+v want %+v", gotInput, *state) + } + }) + } +} + +func TestReadDualSenseInputStreamV2RejectsBadCRC(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) + }() + + state := NewInputState() + state.LX = 12 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + frame := makeStreamFrameV2(StreamFrameInputState, 0, inputPayload) + frame[len(frame)-1] ^= 0x80 + if _, err := client.Write(frame); err != nil { + t.Fatalf("write input frame: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "CRC mismatch") { + t.Fatalf("expected CRC mismatch, got %v", err) + } +} + +func TestReadDualSenseInputStreamV2RejectsSequenceGap(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) + }() + + state := NewInputState() + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 10, inputPayload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 12, inputPayload)); err != nil { + t.Fatalf("write second input frame: %v", err) + } + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "sequence mismatch") { + t.Fatalf("expected sequence mismatch, got %v", err) + } +} + +func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + server, client := net.Pipe() + defer server.Close() + + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- readDualSenseInputStream(server, dev, logger, true) + }() + + state := NewInputState() + state.LX = -32 + state.Buttons = validDualSenseInputButtons | 0x80000000 + state.DPad = validDualSenseInputDPad | 0x80 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { + t.Fatalf("write input frame: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "invalid controls") { + t.Fatalf("expected invalid controls error, got %v", err) + } +} + +func TestDualSenseUpdateInputStateCopiesState(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + state := NewInputState() + state.LX = 44 + state.Buttons = ButtonCross + dev.UpdateInputState(state) + + state.LX = -91 + state.Buttons = 0xFFFFFFFF + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + if gotInput.LX != 44 || gotInput.Buttons != ButtonCross { + t.Fatalf("input state should be copied before publish: got LX=%d buttons=%#x", + gotInput.LX, gotInput.Buttons) + } +} diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go new file mode 100644 index 00000000..5779039f --- /dev/null +++ b/device/dualsense/dsedge_handler.go @@ -0,0 +1,228 @@ +package dualsense + +import ( + "encoding/json" + "fmt" + "log/slog" + "net" + "strings" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("dualsenseedge", &dsedgehandler{}) + api.RegisterDevice("dualsenseedgeext", &dsedgehandler{extendedFeedback: true}) + api.RegisterDevice("dualsenseedgecombinedext", &dsedgehandler{combinedBluetoothFeedback: true}) + api.RegisterDevice("dualsenseedgecombinedmicext", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion}) + api.RegisterDevice("dualsenseedgecombinedmicv2", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2}) + api.RegisterDevice("dualsenseedgecombinedaudioduplexv3", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV3}) + api.RegisterDevice("dualsenseedgecombinedaudioduplexv4", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, speakerOutput: true, streamFrameVersion: StreamFrameVersionV4}) +} + +type dsedgehandler struct { + extendedFeedback bool + combinedBluetoothFeedback bool + microphoneInput bool + speakerOutput bool + streamFrameVersion byte +} + +func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + if o == nil { + o = &device.CreateOptions{} + } + + metaState := MetaState{ + ShellColor: DefaultShellColor, + } + if o.DeviceSpecific != "" { + if err := json.Unmarshal([]byte(o.DeviceSpecific), &metaState); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + } + + serial := metaState.SerialNumber + if serial == "" { + serial = DefaultSerialNumberDSEdge + } + if metaState.ShellColor != "" && len(serial) >= 6 { + code := strings.ToUpper(metaState.ShellColor) + if len(code) >= 2 { + serial = serial[:4] + code[:2] + serial[6:] + } + } + if _, ok := serials[serial]; ok { + for i := 1; i < 16; i++ { + newSerial := fmt.Sprintf("%s%02X", serial[:len(serial)-2], i) + if _, exists := serials[newSerial]; !exists { + serial = newSerial + break + } + } + } + metaState.SerialNumber = serial + serials[serial] = struct{}{} + + mac := metaState.MACAddress + if mac == "" { + mac = DefaultMACAddressDSEdge + } + if _, ok := macs[mac]; ok { + prefix := mac[:len(mac)-2] + for i := 1; i <= 16; i++ { + candidate := fmt.Sprintf("%s%02X", prefix, i) + if _, exists := macs[candidate]; !exists { + mac = candidate + break + } + } + } + metaState.MACAddress = mac + macs[mac] = struct{}{} + + b, err := json.Marshal(metaState) + if err != nil { + return nil, fmt.Errorf("marshal meta state: %w", err) + } + o.DeviceSpecific = string(b) + + dse, err := new(o, true) + if err != nil { + return nil, err + } + dse.extendedFeedback = h.extendedFeedback + dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback + dse.microphoneInput = h.microphoneInput + dse.speakerOutput = h.speakerOutput + dse.streamFrameVersion = h.streamFrameVersion + return dse, nil +} + +func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + defer func() { + if devPtr == nil || *devPtr == nil { + return + } + dse, ok := (*devPtr).(*DualSense) + if !ok { + slog.Warn("device is not DualSenseEdge on disconnect") + return + } + dse.mtx.Lock() + serial := dse.metaState.SerialNumber + mac := dse.metaState.MACAddress + dse.mtx.Unlock() + delete(serials, serial) + delete(macs, mac) + slog.Debug("DualSenseEdge disconnected, serial/mac released", "serial", serial, "mac", mac) + }() + + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + dse, ok := (*devPtr).(*DualSense) + if !ok { + return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) + } + + microphoneInput := h.microphoneInput || dse.microphoneInput + speakerOutput := h.speakerOutput || dse.speakerOutput + streamFrameVersion := h.streamFrameVersion + if dse.streamFrameVersion != 0 { + streamFrameVersion = dse.streamFrameVersion + } + logger.Info("DualSense Edge input stream configured", + "microphoneInput", microphoneInput, + "speakerOutput", speakerOutput, + "frameVersion", streamFrameVersion) + + marshalFeedback := func(feedback OutputState) ([]byte, error) { + var data []byte + var err error + if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback { + data, err = feedback.MarshalCombinedExtendedBinary() + } else if h.extendedFeedback || dse.extendedFeedback { + data, err = feedback.MarshalExtendedBinary() + } else { + data, err = feedback.MarshalBinary() + } + if err != nil { + return nil, err + } + return data, nil + } + + if speakerOutput { + if streamFrameVersion != StreamFrameVersionV3 && + streamFrameVersion != StreamFrameVersionV4 { + return fmt.Errorf("DualSense Edge speaker output requires framed stream version 0x%02X or 0x%02X", + StreamFrameVersionV3, StreamFrameVersionV4) + } + + writer := newDualSenseOutputWriter(conn, streamFrameVersion, + dse.beginSpeakerStream(), logger) + go writer.Run() + dse.SetOutputCallback(func(feedback OutputState) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + writer.EnqueueControl(StreamFrameOutputState, data) + }) + if streamFrameVersion == StreamFrameVersionV4 { + dse.SetAtomicAudioHapticsCallback(func(feedback OutputState, speakerPCM []byte) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal atomic audio/haptics feedback", "error", err) + return + } + writer.EnqueueAtomicAudioHaptics(data, speakerPCM) + }) + } else { + dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + } + dse.SetSpeakerResetCallback(writer.ResetSpeaker) + defer func() { + dse.SetOutputCallback(nil) + dse.SetSpeakerCallback(nil) + dse.SetAtomicAudioHapticsCallback(nil) + dse.SetSpeakerResetCallback(nil) + writer.Stop() + }() + } else { + dse.SetOutputCallback(func(feedback OutputState) { + data, err := marshalFeedback(feedback) + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send feedback", "error", err) + } + }) + defer dse.SetOutputCallback(nil) + } + + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) + } +} + +func (h *dsedgehandler) UpdateMetaState(meta string, dev *usb.Device) error { + dse, ok := (*dev).(*DualSense) + if !ok { + return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) + } + dse.mtx.Lock() + current := *dse.metaState + dse.mtx.Unlock() + if err := json.Unmarshal([]byte(meta), ¤t); err != nil { + return fmt.Errorf("unmarshal meta state: %w", err) + } + dse.SetMetaState(current) + return nil +} diff --git a/device/dualsense/helpers.go b/device/dualsense/helpers.go new file mode 100644 index 00000000..f7426b68 --- /dev/null +++ b/device/dualsense/helpers.go @@ -0,0 +1,31 @@ +package dualsense + +import "math" + +func GyroDpsToRaw(dps float64) int16 { + return int16(min(max(math.Round(dps*GyroCountsPerDps), math.MinInt16), math.MaxInt16)) +} + +func GyroRawToDps(raw int16) float64 { + return float64(raw) / GyroCountsPerDps +} + +func AccelMS2ToRaw(ms2 float64) int16 { + return int16(min(max(math.Round(ms2*AccelCountsPerMS2), math.MinInt16), math.MaxInt16)) +} + +func AccelRawToMS2(raw int16) float64 { + return float64(raw) / AccelCountsPerMS2 +} + +func DefaultAccelRaw() (x, y, z int16) { + return DefaultAccelXRaw, DefaultAccelYRaw, DefaultAccelZRaw +} + +func encodeTouchCoords(b []byte, x, y uint16) { + x = min(x, TouchpadMaxX) + y = min(y, TouchpadMaxY) + b[0] = uint8(x & 0xFF) + b[1] = uint8((x>>8)&0x0F) | uint8((y&0x0F)<<4) + b[2] = uint8(y >> 4) +} diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go new file mode 100644 index 00000000..511e67e6 --- /dev/null +++ b/device/dualsense/output_writer.go @@ -0,0 +1,564 @@ +package dualsense + +import ( + "encoding/binary" + "log/slog" + "net" + "sync" + "sync/atomic" + "time" +) + +const ( + dualSenseOutputControlQueueCapacity = 32 + dualSenseOutputAudioQueueCapacity = 64 + // Windows submits the virtual DualSense audio endpoint in ten-packet + // USB/IP URBs. Reserve the captured maximum packet size for every packet; + // the normal 48 kHz four-channel payload is 3,840 bytes and becomes a + // 1,920-byte native stereo speaker block. + // A V4 frame carries one complete 512-source-frame generation: the + // marshalled native feedback plus its matching front-channel stereo PCM. + // Keep the fixed pool large enough for that indivisible payload; V3 uses + // the same pool and simply consumes the smaller PCM-only prefix. + dualSenseSpeakerPayloadCapacity = dualSenseAtomicFeedbackPrefix + + OutputStateCombinedExtSize + + (BluetoothHapticsSampleSize/2)*USBHapticsAudioDownsample* + 2*USBHapticsAudioBytesPerSample + dualSenseSpeakerTraceInterval = 10 * time.Second + dualSenseSpeakerResetTimeout = 250 * time.Millisecond + dualSenseAtomicFeedbackPrefix = 2 +) + +type dualSenseSpeakerStreamTelemetry struct { + receivedPayloads atomic.Uint64 + receivedBytes atomic.Uint64 + enqueuedPayloads atomic.Uint64 + enqueuedBytes atomic.Uint64 + droppedPayloads atomic.Uint64 + droppedBytes atomic.Uint64 + writtenPayloads atomic.Uint64 + writtenBytes atomic.Uint64 + writeFailures atomic.Uint64 + queueDepth atomic.Uint64 + queueHighWater atomic.Uint64 + lastEnqueueNS atomic.Int64 + maxEnqueueGapNS atomic.Int64 + lastWriteNS atomic.Int64 + maxWriteGapNS atomic.Int64 + active atomic.Bool +} + +type dualSenseSpeakerStreamSnapshot struct { + ReceivedPayloads uint64 + ReceivedBytes uint64 + EnqueuedPayloads uint64 + EnqueuedBytes uint64 + DroppedPayloads uint64 + DroppedBytes uint64 + WrittenPayloads uint64 + WrittenBytes uint64 + WriteFailures uint64 + QueueDepth uint64 + QueueHighWater uint64 + MaxEnqueueGapUS int64 + MaxWriteGapUS int64 + Active bool +} + +func (s *dualSenseSpeakerStreamTelemetry) snapshot() dualSenseSpeakerStreamSnapshot { + if s == nil { + return dualSenseSpeakerStreamSnapshot{} + } + return dualSenseSpeakerStreamSnapshot{ + ReceivedPayloads: s.receivedPayloads.Load(), + ReceivedBytes: s.receivedBytes.Load(), + EnqueuedPayloads: s.enqueuedPayloads.Load(), + EnqueuedBytes: s.enqueuedBytes.Load(), + DroppedPayloads: s.droppedPayloads.Load(), + DroppedBytes: s.droppedBytes.Load(), + WrittenPayloads: s.writtenPayloads.Load(), + WrittenBytes: s.writtenBytes.Load(), + WriteFailures: s.writeFailures.Load(), + QueueDepth: s.queueDepth.Load(), + QueueHighWater: s.queueHighWater.Load(), + MaxEnqueueGapUS: s.maxEnqueueGapNS.Load() / int64(time.Microsecond), + MaxWriteGapUS: s.maxWriteGapNS.Load() / int64(time.Microsecond), + Active: s.active.Load(), + } +} + +func recordMaximumInt64(target *atomic.Int64, value int64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +func recordMaximumUint64(target *atomic.Uint64, value uint64) { + for value > 0 { + current := target.Load() + if value <= current || target.CompareAndSwap(current, value) { + return + } + } +} + +type dualSenseOutputFrame struct { + frameType byte + payload []byte + audio bool + generation uint64 +} + +// dualSenseOutputWriter serializes controller feedback and virtual speaker +// PCM on one framed stream. USB isochronous completion must never wait for TCP +// backpressure, so speaker extraction uses a fixed pool and a bounded queue. +type dualSenseOutputWriter struct { + conn net.Conn + version byte + logger *slog.Logger + telemetry *dualSenseSpeakerStreamTelemetry + control chan dualSenseOutputFrame + audio chan dualSenseOutputFrame + audioFree chan []byte + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + audioWrite sync.Mutex + stopped bool + streamViable atomic.Bool + audioGeneration atomic.Uint64 + sequence uint32 + packet []byte + lastTrace time.Time +} + +func newDualSenseOutputWriter(conn net.Conn, version byte, + telemetry *dualSenseSpeakerStreamTelemetry, logger *slog.Logger) *dualSenseOutputWriter { + if telemetry == nil { + telemetry = &dualSenseSpeakerStreamTelemetry{} + } + telemetry.queueDepth.Store(0) + telemetry.lastEnqueueNS.Store(0) + telemetry.lastWriteNS.Store(0) + telemetry.active.Store(true) + w := &dualSenseOutputWriter{ + conn: conn, + version: version, + logger: logger, + telemetry: telemetry, + control: make(chan dualSenseOutputFrame, dualSenseOutputControlQueueCapacity), + audio: make(chan dualSenseOutputFrame, dualSenseOutputAudioQueueCapacity), + audioFree: make(chan []byte, dualSenseOutputAudioQueueCapacity), + stop: make(chan struct{}), + done: make(chan struct{}), + packet: make([]byte, 0, StreamFrameV2HeaderSize+dualSenseSpeakerPayloadCapacity), + lastTrace: time.Now(), + } + w.streamViable.Store(conn != nil) + for range dualSenseOutputAudioQueueCapacity { + w.audioFree <- make([]byte, dualSenseSpeakerPayloadCapacity) + } + return w +} + +func (w *dualSenseOutputWriter) EnqueueControl(frameType byte, payload []byte) { + if len(payload) == 0 { + return + } + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + w.enqueueFrameLocked(w.control, dualSenseOutputFrame{ + frameType: frameType, + payload: append([]byte(nil), payload...), + }) +} + +// EnqueueSpeakerFromUSB extracts front-left/front-right from the native +// DualSense 48 kHz, four-channel S16LE endpoint. Rear-left/rear-right remain +// exclusively on the advanced-haptics lane. The extraction itself performs no +// allocation after writer construction. +func (w *dualSenseOutputWriter) EnqueueSpeakerFromUSB(usbPCM []byte) { + const usbFrameSize = USBHapticsAudioFrameSize + const speakerFrameSize = 2 * USBHapticsAudioBytesPerSample + + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + + framesRemaining := len(usbPCM) / usbFrameSize + if framesRemaining == 0 { + return + } + w.telemetry.receivedPayloads.Add(1) + w.telemetry.receivedBytes.Add(uint64(framesRemaining * speakerFrameSize)) + sourceOffset := 0 + for framesRemaining > 0 { + var buffer []byte + select { + case buffer = <-w.audioFree: + default: + w.recordSpeakerDrop(framesRemaining * speakerFrameSize) + return + } + + frames := min(framesRemaining, cap(buffer)/speakerFrameSize) + length := copyDualSenseSpeakerChannels(buffer, + usbPCM[sourceOffset:sourceOffset+frames*usbFrameSize]) + frame := dualSenseOutputFrame{ + frameType: StreamFrameSpeakerPCM, + payload: buffer[:length], + audio: true, + generation: w.audioGeneration.Load(), + } + if !w.enqueueFrameLocked(w.audio, frame) { + w.audioFree <- buffer[:cap(buffer)] + w.recordSpeakerDrop(framesRemaining * speakerFrameSize) + return + } + w.recordSpeakerEnqueue(length) + + framesRemaining -= frames + sourceOffset += frames * usbFrameSize + } +} + +// EnqueueAtomicAudioHaptics publishes one V4 generation. A little-endian +// feedback length prefixes the native extended feedback; the remaining bytes +// are the matching 512-frame stereo PCM block. +func (w *dualSenseOutputWriter) EnqueueAtomicAudioHaptics(feedback, speakerPCM []byte) { + if len(feedback) == 0 || len(feedback) > int(^uint16(0)) || + len(speakerPCM) == 0 { + return + } + + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + + w.telemetry.receivedPayloads.Add(1) + w.telemetry.receivedBytes.Add(uint64(len(speakerPCM))) + var buffer []byte + select { + case buffer = <-w.audioFree: + default: + w.recordSpeakerDrop(len(speakerPCM)) + return + } + + length := dualSenseAtomicFeedbackPrefix + len(feedback) + len(speakerPCM) + if length > cap(buffer) { + w.audioFree <- buffer[:cap(buffer)] + w.recordSpeakerDrop(len(speakerPCM)) + return + } + buffer = buffer[:length] + binary.LittleEndian.PutUint16(buffer[:dualSenseAtomicFeedbackPrefix], + uint16(len(feedback))) + copy(buffer[dualSenseAtomicFeedbackPrefix:], feedback) + copy(buffer[dualSenseAtomicFeedbackPrefix+len(feedback):], speakerPCM) + frame := dualSenseOutputFrame{ + frameType: StreamFrameAtomicAudioHaptics, + payload: buffer, + audio: true, + generation: w.audioGeneration.Load(), + } + if !w.enqueueFrameLocked(w.audio, frame) { + w.audioFree <- buffer[:cap(buffer)] + w.recordSpeakerDrop(len(speakerPCM)) + return + } + w.recordSpeakerEnqueue(len(speakerPCM)) +} + +func (w *dualSenseOutputWriter) recordSpeakerEnqueue(length int) { + w.telemetry.enqueuedPayloads.Add(1) + w.telemetry.enqueuedBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastEnqueueNS.Swap(now) + if previous > 0 && now > previous { + recordMaximumInt64(&w.telemetry.maxEnqueueGapNS, now-previous) + } + depth := uint64(len(w.audio)) + w.telemetry.queueDepth.Store(depth) + recordMaximumUint64(&w.telemetry.queueHighWater, depth) +} + +func (w *dualSenseOutputWriter) recordSpeakerDrop(length int) { + if length <= 0 { + return + } + w.telemetry.droppedPayloads.Add(1) + w.telemetry.droppedBytes.Add(uint64(length)) +} + +func (w *dualSenseOutputWriter) recordSpeakerWrite(length int) { + w.telemetry.writtenPayloads.Add(1) + w.telemetry.writtenBytes.Add(uint64(length)) + now := time.Now().UnixNano() + previous := w.telemetry.lastWriteNS.Swap(now) + if previous > 0 && now > previous { + recordMaximumInt64(&w.telemetry.maxWriteGapNS, now-previous) + } +} + +// copyDualSenseSpeakerChannels copies the first stereo pair from interleaved +// four-channel S16LE PCM into dst and returns the number of bytes written. +func copyDualSenseSpeakerChannels(dst, src []byte) int { + const usbFrameSize = USBHapticsAudioFrameSize + const speakerFrameSize = 2 * USBHapticsAudioBytesPerSample + + frames := min(len(src)/usbFrameSize, len(dst)/speakerFrameSize) + for frame := 0; frame < frames; frame++ { + source := frame * usbFrameSize + destination := frame * speakerFrameSize + copy(dst[destination:destination+speakerFrameSize], + src[source:source+speakerFrameSize]) + } + return frames * speakerFrameSize +} + +// enqueueFrameLocked requires enqueueLock to be held for reading. Shutdown +// takes the write side before draining, so no producer can publish a frame +// after the final drain has observed an empty queue. +func (w *dualSenseOutputWriter) enqueueFrameLocked(queue chan dualSenseOutputFrame, + frame dualSenseOutputFrame) bool { + select { + case queue <- frame: + return true + default: + // Do not let TCP backpressure delay a USB/IP isochronous completion. + return false + } +} + +func (w *dualSenseOutputWriter) Run() { + defer func() { + w.requestStop() + w.drainAudioQueue() + w.telemetry.queueDepth.Store(0) + w.telemetry.active.Store(false) + w.traceSpeakerState(true) + close(w.done) + }() + preferAudio := false + for { + // Alternate when both lanes are continuously ready. If the preferred + // lane is empty, immediately service whichever frame arrives next. + if preferAudio { + select { + case frame := <-w.audio: + if !w.writeAndRelease(frame) { + return + } + preferAudio = false + continue + default: + } + } else { + select { + case frame := <-w.control: + if !w.writeAndRelease(frame) { + return + } + preferAudio = true + continue + default: + } + } + + select { + case <-w.stop: + return + case frame := <-w.control: + if !w.writeAndRelease(frame) { + return + } + preferAudio = true + case frame := <-w.audio: + if !w.writeAndRelease(frame) { + return + } + preferAudio = false + } + } +} + +func (w *dualSenseOutputWriter) writeAndRelease(frame dualSenseOutputFrame) bool { + if frame.audio { + w.audioWrite.Lock() + defer w.audioWrite.Unlock() + if frame.generation != w.audioGeneration.Load() { + w.recordSpeakerDrop(len(frame.payload)) + w.release(frame) + w.telemetry.queueDepth.Store(uint64(len(w.audio))) + return true + } + } + + ok := w.write(frame) + if frame.audio { + w.telemetry.queueDepth.Store(uint64(len(w.audio))) + if ok { + w.recordSpeakerWrite(len(frame.payload)) + } else { + w.telemetry.writeFailures.Add(1) + } + } + w.release(frame) + if frame.audio { + w.traceSpeakerState(false) + } + return ok +} + +// ResetSpeaker advances the audio generation and flushes every queued frame. +// It waits for an already-started write before returning, making interface and +// endpoint resets a hard barrier between USB presentation generations. +func (w *dualSenseOutputWriter) ResetSpeaker() { + w.enqueueLock.Lock() + w.audioGeneration.Add(1) + w.drainAudioQueue() + w.enqueueLock.Unlock() + + // A peer that has stopped reading can otherwise hold audioWrite forever. + // Bound the old generation's in-flight write; write() closes a timed-out + // stream so the owning handler can return and accept a replacement. + if w.conn != nil { + if err := w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)); err != nil { + w.invalidateStream() + } + } + w.audioWrite.Lock() + if w.conn != nil && w.streamViable.Load() { + if err := w.conn.SetWriteDeadline(time.Time{}); err != nil { + w.invalidateStream() + } + } + w.audioWrite.Unlock() + w.telemetry.queueDepth.Store(0) +} + +func (w *dualSenseOutputWriter) drainAudioQueue() { + for { + select { + case frame := <-w.audio: + w.release(frame) + default: + return + } + } +} + +func (w *dualSenseOutputWriter) traceSpeakerState(final bool) { + if w.logger == nil { + return + } + now := time.Now() + if !final && now.Sub(w.lastTrace) < dualSenseSpeakerTraceInterval { + return + } + w.lastTrace = now + state := w.telemetry.snapshot() + log := w.logger.Debug + message := "DualSense framed speaker stream" + if final { + log = w.logger.Info + message = "DualSense framed speaker stream stopped" + } + log(message, + "receivedPayloads", state.ReceivedPayloads, + "receivedBytes", state.ReceivedBytes, + "enqueuedPayloads", state.EnqueuedPayloads, + "enqueuedBytes", state.EnqueuedBytes, + "droppedPayloads", state.DroppedPayloads, + "droppedBytes", state.DroppedBytes, + "writtenPayloads", state.WrittenPayloads, + "writtenBytes", state.WrittenBytes, + "writeFailures", state.WriteFailures, + "queueDepth", state.QueueDepth, + "queueHighWater", state.QueueHighWater, + "maxEnqueueGapUS", state.MaxEnqueueGapUS, + "maxWriteGapUS", state.MaxWriteGapUS) +} + +func (w *dualSenseOutputWriter) write(frame dualSenseOutputFrame) bool { + if len(frame.payload) > int(^uint16(0)) { + return true + } + packetLength := StreamFrameV2HeaderSize + len(frame.payload) + if cap(w.packet) < packetLength { + w.packet = make([]byte, packetLength) + } else { + w.packet = w.packet[:packetLength] + } + header := w.packet[:StreamFrameV2HeaderSize] + header[0] = StreamFrameMagic0 + header[1] = StreamFrameMagic1 + header[2] = StreamFrameMagic2 + header[3] = StreamFrameMagic3 + header[4] = w.version + header[5] = frame.frameType + binary.LittleEndian.PutUint16(header[6:8], uint16(len(frame.payload))) + binary.LittleEndian.PutUint32(header[8:12], w.sequence) + w.sequence++ + binary.LittleEndian.PutUint32(header[12:16], + framedStreamCRC(header[4:12], frame.payload)) + copy(w.packet[StreamFrameV2HeaderSize:], frame.payload) + + remaining := w.packet + for len(remaining) > 0 { + n, err := w.conn.Write(remaining) + if err != nil || n <= 0 { + w.invalidateStream() + return false + } + remaining = remaining[n:] + } + return true +} + +func (w *dualSenseOutputWriter) release(frame dualSenseOutputFrame) { + if frame.audio { + w.audioFree <- frame.payload[:cap(frame.payload)] + } +} + +func (w *dualSenseOutputWriter) Stop() { + w.requestStop() + if w.conn != nil { + _ = w.conn.SetWriteDeadline(time.Now().Add(dualSenseSpeakerResetTimeout)) + _ = w.conn.Close() + } + select { + case <-w.done: + case <-time.After(300 * time.Millisecond): + } +} + +func (w *dualSenseOutputWriter) requestStop() { + w.stopOnce.Do(func() { + w.streamViable.Store(false) + w.enqueueLock.Lock() + w.stopped = true + close(w.stop) + w.enqueueLock.Unlock() + }) +} + +func (w *dualSenseOutputWriter) invalidateStream() { + w.streamViable.Store(false) + if w.conn != nil { + _ = w.conn.Close() + } +} diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go new file mode 100644 index 00000000..1e3d836f --- /dev/null +++ b/device/dualsense/output_writer_test.go @@ -0,0 +1,722 @@ +package dualsense + +import ( + "context" + "encoding/binary" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func TestCopyDualSenseSpeakerChannelsSelectsFrontPairWithoutAllocation(t *testing.T) { + const frames = 480 + source := make([]byte, frames*USBHapticsAudioFrameSize) + for frame := 0; frame < frames; frame++ { + offset := frame * USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(source[offset:offset+2], uint16(int16(frame))) + binary.LittleEndian.PutUint16(source[offset+2:offset+4], uint16(int16(-frame))) + binary.LittleEndian.PutUint16(source[offset+4:offset+6], uint16(int16(1000+frame))) + binary.LittleEndian.PutUint16(source[offset+6:offset+8], uint16(int16(-1000-frame))) + } + destination := make([]byte, frames*2*USBHapticsAudioBytesPerSample) + + if allocations := testing.AllocsPerRun(1000, func() { + copyDualSenseSpeakerChannels(destination, source) + }); allocations != 0 { + t.Fatalf("speaker channel extraction allocated %.2f objects per run", allocations) + } + writer := newDualSenseOutputWriter(nil, StreamFrameVersionV3, nil, nil) + if allocations := testing.AllocsPerRun(1000, func() { + writer.EnqueueSpeakerFromUSB(source) + frame := <-writer.audio + writer.release(frame) + }); allocations != 0 { + t.Fatalf("speaker enqueue path allocated %.2f objects per run", allocations) + } + + written := copyDualSenseSpeakerChannels(destination, source) + if written != len(destination) { + t.Fatalf("unexpected speaker byte count: got %d want %d", written, len(destination)) + } + for frame := 0; frame < frames; frame++ { + offset := frame * 4 + left := int16(binary.LittleEndian.Uint16(destination[offset : offset+2])) + right := int16(binary.LittleEndian.Uint16(destination[offset+2 : offset+4])) + if left != int16(frame) || right != int16(-frame) { + t.Fatalf("speaker frame %d changed: left=%d right=%d", frame, left, right) + } + } +} + +func TestDualSenseV3WriterFramesNativeSpeakerPairAndFeedback(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil) + go writer.Run() + + usbPCM := make([]byte, 2*USBHapticsAudioFrameSize) + copy(usbPCM[0:8], []byte{1, 2, 3, 4, 0xA1, 0xA2, 0xA3, 0xA4}) + copy(usbPCM[8:16], []byte{5, 6, 7, 8, 0xB1, 0xB2, 0xB3, 0xB4}) + writer.EnqueueSpeakerFromUSB(usbPCM) + + header, payload := readDualSenseOutputFrame(t, client) + if header[4] != StreamFrameVersionV3 || header[5] != StreamFrameSpeakerPCM { + t.Fatalf("unexpected speaker frame header: % x", header) + } + if sequence := binary.LittleEndian.Uint32(header[8:12]); sequence != 0 { + t.Fatalf("unexpected speaker frame sequence: %d", sequence) + } + wantSpeaker := []byte{1, 2, 3, 4, 5, 6, 7, 8} + if string(payload) != string(wantSpeaker) { + t.Fatalf("speaker payload retained haptics channels: got % x want % x", payload, wantSpeaker) + } + if got, want := binary.LittleEndian.Uint32(header[12:16]), + framedStreamCRC(header[4:12], payload); got != want { + t.Fatalf("speaker frame CRC mismatch: got %08X want %08X", got, want) + } + + feedback := []byte{0x11, 0x22, 0x33} + writer.EnqueueControl(StreamFrameOutputState, feedback) + header, payload = readDualSenseOutputFrame(t, client) + if header[5] != StreamFrameOutputState || string(payload) != string(feedback) { + t.Fatalf("unexpected feedback frame: header=% x payload=% x", header, payload) + } + if sequence := binary.LittleEndian.Uint32(header[8:12]); sequence != 1 { + t.Fatalf("feedback did not share the speaker sequence: %d", sequence) + } + + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + writer.Stop() + state := writer.telemetry.snapshot() + if state.ReceivedPayloads != 1 || state.ReceivedBytes != 8 || + state.EnqueuedPayloads != 1 || state.EnqueuedBytes != 8 || + state.WrittenPayloads != 1 || state.WrittenBytes != 8 || + state.DroppedPayloads != 0 || state.WriteFailures != 0 { + t.Fatalf("unexpected V3 speaker telemetry: %+v", state) + } + if state.Active || state.QueueDepth != 0 || + len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + t.Fatalf("writer did not release its speaker pool: state=%+v free=%d", + state, len(writer.audioFree)) + } +} + +func TestDualSenseV4WriterKeepsFeedbackAndSpeakerGenerationAtomic(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, StreamFrameVersionV4, nil, nil) + go writer.Run() + + feedback := make([]byte, 474) + feedback[76] = 0x36 + feedback[154] = 0x41 + speaker := make([]byte, 512*2*USBHapticsAudioBytesPerSample) + for index := range speaker { + speaker[index] = byte(index) + } + writer.EnqueueAtomicAudioHaptics(feedback, speaker) + + header, payload := readDualSenseOutputFrame(t, client) + if header[4] != StreamFrameVersionV4 || + header[5] != StreamFrameAtomicAudioHaptics { + t.Fatalf("unexpected atomic frame header: % x", header) + } + feedbackLength := int(binary.LittleEndian.Uint16(payload[:2])) + if feedbackLength != len(feedback) { + t.Fatalf("unexpected atomic feedback length: got %d want %d", + feedbackLength, len(feedback)) + } + if got := payload[2 : 2+feedbackLength]; string(got) != string(feedback) { + t.Fatal("atomic frame changed native feedback") + } + if got := payload[2+feedbackLength:]; string(got) != string(speaker) { + t.Fatal("atomic frame changed matching speaker PCM") + } + + _ = client.Close() + writer.Stop() + state := writer.telemetry.snapshot() + if state.ReceivedPayloads != 1 || state.ReceivedBytes != uint64(len(speaker)) || + state.EnqueuedPayloads != 1 || state.WrittenPayloads != 1 || + state.DroppedPayloads != 0 { + t.Fatalf("unexpected V4 atomic telemetry: %+v", state) + } +} + +func TestDualSenseV3WriterShutdownReturnsEveryQueuedSpeakerBuffer(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil) + go writer.Run() + + usbPCM := make([]byte, 480*USBHapticsAudioFrameSize) + for packet := 0; packet < 10; packet++ { + writer.EnqueueSpeakerFromUSB(usbPCM) + } + writer.Stop() + _ = client.Close() + + state := writer.telemetry.snapshot() + if state.Active || state.QueueDepth != 0 || len(writer.audio) != 0 || + len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + t.Fatalf("shutdown retained speaker buffers: state=%+v queued=%d free=%d", + state, len(writer.audio), len(writer.audioFree)) + } + if state.ReceivedPayloads != 10 || state.EnqueuedPayloads != 10 || + state.DroppedPayloads != 0 { + t.Fatalf("unexpected shutdown telemetry: %+v", state) + } +} + +func TestDualSenseV3WriterAlternatesFeedbackAndSpeakerUnderPressure(t *testing.T) { + server, client := net.Pipe() + writer := newDualSenseOutputWriter(server, StreamFrameVersionV3, nil, nil) + usbPCM := make([]byte, USBHapticsAudioFrameSize) + for frame := 0; frame < 4; frame++ { + writer.EnqueueControl(StreamFrameOutputState, []byte{byte(frame)}) + writer.EnqueueSpeakerFromUSB(usbPCM) + } + go writer.Run() + + for frame := 0; frame < 8; frame++ { + header, _ := readDualSenseOutputFrame(t, client) + wantType := byte(StreamFrameOutputState) + if frame%2 != 0 { + wantType = StreamFrameSpeakerPCM + } + if header[5] != wantType { + t.Fatalf("frame %d starved one output lane: got type 0x%02X want 0x%02X", + frame, header[5], wantType) + } + } + writer.Stop() + _ = client.Close() +} + +type writeStartedConn struct { + net.Conn + started chan struct{} + once sync.Once +} + +func (c *writeStartedConn) Write(payload []byte) (int, error) { + c.once.Do(func() { close(c.started) }) + return c.Conn.Write(payload) +} + +type deadlineTrackingConn struct { + net.Conn + started chan struct{} + closed chan struct{} + startedOnce sync.Once + closedOnce sync.Once + deadlineLock sync.Mutex + deadlines []time.Time +} + +func newDeadlineTrackingConn(conn net.Conn) *deadlineTrackingConn { + return &deadlineTrackingConn{ + Conn: conn, + started: make(chan struct{}), + closed: make(chan struct{}), + } +} + +func (c *deadlineTrackingConn) Write(payload []byte) (int, error) { + c.startedOnce.Do(func() { close(c.started) }) + return c.Conn.Write(payload) +} + +func (c *deadlineTrackingConn) SetWriteDeadline(deadline time.Time) error { + c.deadlineLock.Lock() + c.deadlines = append(c.deadlines, deadline) + c.deadlineLock.Unlock() + return c.Conn.SetWriteDeadline(deadline) +} + +func (c *deadlineTrackingConn) Close() error { + err := c.Conn.Close() + c.closedOnce.Do(func() { close(c.closed) }) + return err +} + +func (c *deadlineTrackingConn) writeDeadlines() []time.Time { + c.deadlineLock.Lock() + defer c.deadlineLock.Unlock() + return append([]time.Time(nil), c.deadlines...) +} + +func TestDualSenseV3WriterWriteFailureCannotRaceFinalDrain(t *testing.T) { + server, client := net.Pipe() + conn := &writeStartedConn{Conn: server, started: make(chan struct{})} + writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil) + writer.EnqueueControl(StreamFrameOutputState, []byte{0x01}) + go writer.Run() + <-conn.started + + // Model a producer that has already entered the enqueue critical section + // when the socket fails. Run must wait for it, stop all later producers, + // and only then perform its final drain. + writer.enqueueLock.RLock() + buffer := <-writer.audioFree + buffer[0] = 0x55 + writer.audio <- dualSenseOutputFrame{ + frameType: StreamFrameSpeakerPCM, + payload: buffer[:4], + audio: true, + } + if err := client.Close(); err != nil { + t.Fatalf("close failed client: %v", err) + } + writer.enqueueLock.RUnlock() + + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not finish after socket failure") + } + if len(writer.audio) != 0 || + len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + t.Fatalf("write-failure shutdown retained a pooled buffer: queued=%d free=%d", + len(writer.audio), len(writer.audioFree)) + } + before := writer.telemetry.snapshot() + writer.EnqueueSpeakerFromUSB(make([]byte, USBHapticsAudioFrameSize)) + after := writer.telemetry.snapshot() + if after.ReceivedPayloads != before.ReceivedPayloads || + after.DroppedPayloads != before.DroppedPayloads { + t.Fatalf("stopped writer accepted a stale callback: before=%+v after=%+v", + before, after) + } +} + +func TestDualSenseV3WriterResetIsHardWriteGenerationBarrier(t *testing.T) { + server, client := net.Pipe() + conn := newDeadlineTrackingConn(server) + writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil) + + oldPCM := make([]byte, USBHapticsAudioFrameSize) + oldPCM[0] = 0x11 + writer.EnqueueSpeakerFromUSB(oldPCM) + go writer.Run() + <-conn.started + queuedOldPCM := make([]byte, USBHapticsAudioFrameSize) + queuedOldPCM[0] = 0x12 + writer.EnqueueSpeakerFromUSB(queuedOldPCM) + + resetDone := make(chan struct{}) + go func() { + writer.ResetSpeaker() + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("speaker reset crossed an in-flight old-generation write") + case <-time.After(20 * time.Millisecond): + } + + _, oldPayload := readDualSenseOutputFrame(t, client) + if len(oldPayload) != 4 || oldPayload[0] != 0x11 { + t.Fatalf("unexpected in-flight old-generation payload: % x", oldPayload) + } + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("speaker reset did not finish after the in-flight write") + } + deadlines := conn.writeDeadlines() + if len(deadlines) != 2 || deadlines[0].IsZero() || !deadlines[1].IsZero() { + t.Fatalf("viable reset did not arm and clear its write deadline: %v", deadlines) + } + if len(writer.audio) != 0 { + t.Fatalf("speaker reset retained %d queued old-generation frames", len(writer.audio)) + } + + newPCM := make([]byte, USBHapticsAudioFrameSize) + newPCM[0] = 0x22 + writer.EnqueueSpeakerFromUSB(newPCM) + _, newPayload := readDualSenseOutputFrame(t, client) + if len(newPayload) != 4 || newPayload[0] != 0x22 { + t.Fatalf("unexpected post-reset payload: % x", newPayload) + } + + writer.Stop() + _ = client.Close() +} + +func TestDualSenseV3WriterResetBoundsBlockedWriteAndClosesStream(t *testing.T) { + server, client := net.Pipe() + conn := newDeadlineTrackingConn(server) + writer := newDualSenseOutputWriter(conn, StreamFrameVersionV3, nil, nil) + + inFlightPCM := make([]byte, USBHapticsAudioFrameSize) + inFlightPCM[0] = 0x31 + writer.EnqueueSpeakerFromUSB(inFlightPCM) + go writer.Run() + <-conn.started + + queuedPCM := make([]byte, USBHapticsAudioFrameSize) + queuedPCM[0] = 0x32 + writer.EnqueueSpeakerFromUSB(queuedPCM) + + resetDone := make(chan struct{}) + go func() { + writer.ResetSpeaker() + close(resetDone) + }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("speaker reset remained blocked after its write deadline") + } + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("timed-out speaker write did not stop the failed stream") + } + select { + case <-conn.closed: + default: + t.Fatal("timed-out speaker write did not close the failed stream") + } + + deadlines := conn.writeDeadlines() + if len(deadlines) != 1 || deadlines[0].IsZero() { + t.Fatalf("failed stream deadline was cleared or not armed: %v", deadlines) + } + if writer.streamViable.Load() { + t.Fatal("timed-out speaker stream remained marked viable") + } + if writer.audioGeneration.Load() != 1 || len(writer.audio) != 0 || + len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + t.Fatalf("timed-out reset retained stale audio: generation=%d queued=%d free=%d", + writer.audioGeneration.Load(), len(writer.audio), len(writer.audioFree)) + } + + buffer := make([]byte, StreamFrameV2HeaderSize+4) + if count, err := client.Read(buffer); count != 0 || err == nil { + t.Fatalf("failed stream replayed stale audio: bytes=%d error=%v payload=% x", + count, err, buffer[:count]) + } + + before := writer.telemetry.snapshot() + writer.EnqueueSpeakerFromUSB(make([]byte, USBHapticsAudioFrameSize)) + after := writer.telemetry.snapshot() + if after.ReceivedPayloads != before.ReceivedPayloads || len(writer.audio) != 0 { + t.Fatalf("failed writer accepted audio after reconnect was required: before=%+v after=%+v queued=%d", + before, after, len(writer.audio)) + } + + writer.Stop() + _ = client.Close() +} + +func TestDualSenseAndEdgeV3VariantsForwardSpeakerAndAcceptInput(t *testing.T) { + for _, edge := range []bool{false, true} { + name := "DualSense" + if edge { + name = "DualSense Edge" + } + t.Run(name, func(t *testing.T) { + var dev usb.Device + var err error + var streamHandler func(net.Conn, *usb.Device, *slog.Logger) error + if edge { + variant := &dsedgehandler{ + combinedBluetoothFeedback: true, + microphoneInput: true, + speakerOutput: true, + streamFrameVersion: StreamFrameVersionV3, + } + dev, err = variant.CreateDevice(nil) + streamHandler = (&dsedgehandler{}).StreamHandler() + } else { + variant := &dshandler{ + combinedBluetoothFeedback: true, + microphoneInput: true, + speakerOutput: true, + streamFrameVersion: StreamFrameVersionV3, + } + dev, err = variant.CreateDevice(nil) + streamHandler = (&dshandler{}).StreamHandler() + } + if err != nil { + t.Fatalf("CreateDevice returned error: %v", err) + } + + server, client := net.Pipe() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(server, &dev, logger) + }() + + state := NewInputState() + state.LX = 73 + state.Buttons = ButtonCross + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState, + 0, inputPayload)); err != nil { + t.Fatalf("write V3 input state: %v", err) + } + + dualSense := dev.(*DualSense) + dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + usbPCM := []byte{ + 0x01, 0x02, 0x03, 0x04, 0xA1, 0xA2, 0xA3, 0xA4, + 0x05, 0x06, 0x07, 0x08, 0xB1, 0xB2, 0xB3, 0xB4, + } + dualSense.HandleTransfer(context.Background(), + EndpointHapticsAudioOut, usbip.DirOut, usbPCM) + + header, payload := readDualSenseOutputFrame(t, client) + if header[4] != StreamFrameVersionV3 || + header[5] != StreamFrameSpeakerPCM { + t.Fatalf("unexpected V3 speaker frame: % x", header) + } + want := []byte{0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08} + if string(payload) != string(want) { + t.Fatalf("unexpected native speaker pair: got % x want % x", + payload, want) + } + + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("V3 stream handler returned error: %v", err) + } + + dualSense.mtx.Lock() + gotInput := dualSense.inputState + callbacksCleared := dualSense.outputFunc == nil && + dualSense.speakerFunc == nil && + dualSense.speakerResetFunc == nil + dualSense.mtx.Unlock() + if gotInput.LX != state.LX || gotInput.Buttons != state.Buttons { + t.Fatalf("V3 input changed: got %+v want %+v", gotInput, state) + } + if !callbacksCleared { + t.Fatal("V3 handler retained a callback after shutdown") + } + speakerState := dualSense.GetDeviceSpecificArgs() + if speakerState["speakerStreamActive"].(bool) || + speakerState["speakerPayloadsReceived"].(uint64) != 1 || + speakerState["speakerPayloadsDropped"].(uint64) != 0 || + speakerState["speakerPayloadsWritten"].(uint64) != 1 { + t.Fatalf("unexpected exposed speaker state: %+v", speakerState) + } + }) + } +} + +func TestDualSenseV4HandlerPairsTheSameUSBGeneration(t *testing.T) { + variant := &dshandler{ + combinedBluetoothFeedback: true, + microphoneInput: true, + speakerOutput: true, + streamFrameVersion: StreamFrameVersionV4, + } + dev, err := variant.CreateDevice(nil) + if err != nil { + t.Fatalf("CreateDevice returned error: %v", err) + } + + server, client := net.Pipe() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- variant.StreamHandler()(server, &dev, logger) + }() + + state := NewInputState() + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if _, err := client.Write(makeStreamFrameWithCRC(StreamFrameVersionV4, + StreamFrameInputState, 0, inputPayload)); err != nil { + t.Fatalf("write V4 input state: %v", err) + } + + dualSense := dev.(*DualSense) + dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + usbPCM := make([]byte, 512*USBHapticsAudioFrameSize) + negativeRearSample := int16(-12000) + for frame := 0; frame < 512; frame++ { + offset := frame * USBHapticsAudioFrameSize + binary.LittleEndian.PutUint16(usbPCM[offset:offset+2], + uint16(int16(frame+1))) + binary.LittleEndian.PutUint16(usbPCM[offset+2:offset+4], + uint16(int16(-frame-1))) + binary.LittleEndian.PutUint16(usbPCM[offset+4:offset+6], + uint16(int16(12000))) + binary.LittleEndian.PutUint16(usbPCM[offset+6:offset+8], + uint16(negativeRearSample)) + } + dualSense.HandleTransfer(context.Background(), + EndpointHapticsAudioOut, usbip.DirOut, usbPCM) + + header, payload := readDualSenseOutputFrame(t, client) + if header[4] != StreamFrameVersionV4 || + header[5] != StreamFrameAtomicAudioHaptics { + t.Fatalf("unexpected V4 atomic frame: % x", header) + } + feedbackLength := int(binary.LittleEndian.Uint16(payload[:2])) + feedback := payload[2 : 2+feedbackLength] + speaker := payload[2+feedbackLength:] + if feedbackLength != 474 || len(speaker) != 512*4 { + t.Fatalf("unexpected V4 generation sizes: feedback=%d speaker=%d", + feedbackLength, len(speaker)) + } + if feedback[76] != 0x36 || feedback[152] != 0x92 || + feedback[153] != BluetoothHapticsSampleSize { + t.Fatalf("atomic feedback omitted combined haptics: % x", + feedback[76:154]) + } + for frame := 0; frame < 512; frame++ { + offset := frame * 4 + left := int16(binary.LittleEndian.Uint16(speaker[offset : offset+2])) + right := int16(binary.LittleEndian.Uint16(speaker[offset+2 : offset+4])) + if left != int16(frame+1) || right != int16(-frame-1) { + t.Fatalf("speaker generation diverged at frame %d: %d/%d", + frame, left, right) + } + } + + _ = client.Close() + if err := <-errCh; err != nil { + t.Fatalf("V4 stream handler returned error: %v", err) + } +} + +func TestDualSenseV3ReplacementOwnsTelemetryAndClearsCallbacks(t *testing.T) { + variant := &dshandler{ + combinedBluetoothFeedback: true, + microphoneInput: true, + speakerOutput: true, + streamFrameVersion: StreamFrameVersionV3, + } + dev, err := variant.CreateDevice(nil) + if err != nil { + t.Fatalf("CreateDevice returned error: %v", err) + } + dualSense := dev.(*DualSense) + dualSense.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) + streamHandler := (&dshandler{}).StreamHandler() + + runStream := func(speakerPayloads int) *dualSenseSpeakerStreamTelemetry { + server, client := net.Pipe() + errCh := make(chan error, 1) + go func() { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + errCh <- streamHandler(server, &dev, logger) + }() + + inputPayload, err := NewInputState().MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + if _, err := client.Write(makeStreamFrameV3(StreamFrameInputState, + 0, inputPayload)); err != nil { + t.Fatalf("write V3 input state: %v", err) + } + + usbPCM := make([]byte, USBHapticsAudioFrameSize) + for payload := 0; payload < speakerPayloads; payload++ { + usbPCM[0] = byte(payload + 1) + dualSense.HandleTransfer(context.Background(), + EndpointHapticsAudioOut, usbip.DirOut, usbPCM) + header, got := readDualSenseOutputFrame(t, client) + if header[5] != StreamFrameSpeakerPCM || len(got) != 4 || + got[0] != usbPCM[0] { + t.Fatalf("unexpected replacement speaker frame: header=% x payload=% x", + header, got) + } + } + + if err := client.Close(); err != nil { + t.Fatalf("close client pipe: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("V3 stream handler returned error: %v", err) + } + + dualSense.mtx.Lock() + telemetry := dualSense.speakerStreamTelemetry + callbacksCleared := dualSense.outputFunc == nil && + dualSense.speakerFunc == nil && + dualSense.speakerResetFunc == nil + dualSense.mtx.Unlock() + if !callbacksCleared { + t.Fatal("finished V3 stream retained a callback") + } + return telemetry + } + + first := runStream(1) + second := runStream(2) + if first == second { + t.Fatal("replacement V3 stream reused the previous telemetry generation") + } + first.receivedPayloads.Add(100) + state := dualSense.GetDeviceSpecificArgs() + if state["speakerStreamActive"].(bool) || + state["speakerPayloadsReceived"].(uint64) != 2 || + state["speakerPayloadsEnqueued"].(uint64) != 2 || + state["speakerPayloadsWritten"].(uint64) != 2 || + state["speakerPayloadsDropped"].(uint64) != 0 { + t.Fatalf("stale V3 generation changed replacement telemetry: %+v", state) + } +} + +func TestDualSenseOutputWriterResetFlushesSpeakerGeneration(t *testing.T) { + telemetry := &dualSenseSpeakerStreamTelemetry{} + writer := newDualSenseOutputWriter(nil, StreamFrameVersionV3, telemetry, nil) + usbPCM := make([]byte, USBHapticsAudioFrameSize) + usbPCM[0] = 0x55 + + writer.EnqueueSpeakerFromUSB(usbPCM) + if len(writer.audio) != 1 { + t.Fatal("speaker reset precondition did not queue audio") + } + writer.ResetSpeaker() + if len(writer.audio) != 0 || len(writer.audioFree) != dualSenseOutputAudioQueueCapacity { + t.Fatalf("speaker reset retained pooled audio: queued=%d free=%d", + len(writer.audio), len(writer.audioFree)) + } + if writer.audioGeneration.Load() != 1 || telemetry.droppedPayloads.Load() != 0 { + t.Fatalf("unexpected reset generation/telemetry: generation=%d drops=%d", + writer.audioGeneration.Load(), telemetry.droppedPayloads.Load()) + } + + writer.EnqueueSpeakerFromUSB(usbPCM) + frame := <-writer.audio + if frame.generation != 1 || len(frame.payload) != 4 || frame.payload[0] != 0x55 { + t.Fatalf("post-reset frame used stale generation or payload: generation=%d payload=% x", + frame.generation, frame.payload) + } + writer.release(frame) +} + +func readDualSenseOutputFrame(t *testing.T, reader io.Reader) ([]byte, []byte) { + t.Helper() + header := make([]byte, StreamFrameV2HeaderSize) + if _, err := io.ReadFull(reader, header); err != nil { + t.Fatalf("read output frame header: %v", err) + } + payload := make([]byte, int(binary.LittleEndian.Uint16(header[6:8]))) + if _, err := io.ReadFull(reader, payload); err != nil { + t.Fatalf("read output frame payload: %v", err) + } + return header, payload +} diff --git a/device/dualsense/serial_dedupe.go b/device/dualsense/serial_dedupe.go new file mode 100644 index 00000000..9535c620 --- /dev/null +++ b/device/dualsense/serial_dedupe.go @@ -0,0 +1,6 @@ +package dualsense + +var ( + serials = map[string]struct{}{} + macs = map[string]struct{}{} +) diff --git a/device/dualsense/state.go b/device/dualsense/state.go new file mode 100644 index 00000000..ce2d9fb2 --- /dev/null +++ b/device/dualsense/state.go @@ -0,0 +1,328 @@ +package dualsense + +import ( + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "time" +) + +// nolint +// viiper:wire dualsense c2s stickLX:i8 stickLY:i8 stickRX:i8 stickRY:i8 buttons:u32 dpad:u8 triggerL2:u8 triggerR2:u8 touch1X:u16 touch1Y:u16 touch1Active:bool touch2X:u16 touch2Y:u16 touch2Active:bool gyroX:i16 gyroY:i16 gyroZ:i16 accelX:i16 accelY:i16 accelZ:i16 +type InputState struct { + LX, LY int8 + RX, RY int8 + Buttons uint32 + DPad uint8 + L2, R2 uint8 + + Touch1X, Touch1Y uint16 + Touch1Active bool + Touch1Tracking uint8 + Touch2X, Touch2Y uint16 + Touch2Active bool + Touch2Tracking uint8 + + GyroX, GyroY, GyroZ int16 + AccelX, AccelY, AccelZ int16 +} + +// NewInputState returns a DualSense input state in its neutral/resting state. +func NewInputState() *InputState { + x, y, z := DefaultAccelRaw() + return &InputState{ + AccelX: x, + AccelY: y, + AccelZ: z, + } +} + +func (s *InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, InputStateSize) + b[0] = uint8(s.LX) + b[1] = uint8(s.LY) + b[2] = uint8(s.RX) + b[3] = uint8(s.RY) + binary.LittleEndian.PutUint32(b[4:8], s.Buttons) + b[8] = s.DPad + b[9] = s.L2 + b[10] = s.R2 + binary.LittleEndian.PutUint16(b[11:13], s.Touch1X) + binary.LittleEndian.PutUint16(b[13:15], s.Touch1Y) + b[15] = encodeTouchStatus(s.Touch1Active, s.Touch1Tracking) + if s.Touch1Active && b[15] == 0 { + b[15] = 1 + } + binary.LittleEndian.PutUint16(b[16:18], s.Touch2X) + binary.LittleEndian.PutUint16(b[18:20], s.Touch2Y) + b[20] = encodeTouchStatus(s.Touch2Active, s.Touch2Tracking) + if s.Touch2Active && b[20] == 0 { + b[20] = 1 + } + binary.LittleEndian.PutUint16(b[21:23], uint16(s.GyroX)) + binary.LittleEndian.PutUint16(b[23:25], uint16(s.GyroY)) + binary.LittleEndian.PutUint16(b[25:27], uint16(s.GyroZ)) + binary.LittleEndian.PutUint16(b[27:29], uint16(s.AccelX)) + binary.LittleEndian.PutUint16(b[29:31], uint16(s.AccelY)) + binary.LittleEndian.PutUint16(b[31:33], uint16(s.AccelZ)) + return b, nil +} + +func (s *InputState) UnmarshalBinary(data []byte) error { + if len(data) < InputStateSize { + return io.ErrUnexpectedEOF + } + s.LX = int8(data[0]) + s.LY = int8(data[1]) + s.RX = int8(data[2]) + s.RY = int8(data[3]) + s.Buttons = binary.LittleEndian.Uint32(data[4:8]) + s.DPad = data[8] + s.L2 = data[9] + s.R2 = data[10] + s.Touch1X = binary.LittleEndian.Uint16(data[11:13]) + s.Touch1Y = binary.LittleEndian.Uint16(data[13:15]) + s.Touch1Active, s.Touch1Tracking = decodeTouchStatus(data[15]) + s.Touch2X = binary.LittleEndian.Uint16(data[16:18]) + s.Touch2Y = binary.LittleEndian.Uint16(data[18:20]) + s.Touch2Active, s.Touch2Tracking = decodeTouchStatus(data[20]) + s.GyroX = int16(binary.LittleEndian.Uint16(data[21:23])) + s.GyroY = int16(binary.LittleEndian.Uint16(data[23:25])) + s.GyroZ = int16(binary.LittleEndian.Uint16(data[25:27])) + s.AccelX = int16(binary.LittleEndian.Uint16(data[27:29])) + s.AccelY = int16(binary.LittleEndian.Uint16(data[29:31])) + s.AccelZ = int16(binary.LittleEndian.Uint16(data[31:33])) + return nil +} + +// nolint +// viiper:wire dualsense s2c rumbleSmall:u8 rumbleLarge:u8 ledRed:u8 ledGreen:u8 ledBlue:u8 playerLeds:u8 triggerR2Mode:u8 triggerR2StartResistance:u8 triggerR2EffectForce:u8 triggerR2RangeForce:u8 triggerR2NearReleaseStrength:u8 triggerR2NearMiddleStrength:u8 triggerR2PressedStrength:u8 triggerR2Reserved:u8*2 triggerR2Frequency:u8 triggerR2Padding:u8 triggerL2Mode:u8 triggerL2StartResistance:u8 triggerL2EffectForce:u8 triggerL2RangeForce:u8 triggerL2NearReleaseStrength:u8 triggerL2NearMiddleStrength:u8 triggerL2PressedStrength:u8 triggerL2Reserved:u8*2 triggerL2Frequency:u8 triggerL2Padding:u8 rawOutputReport:u8*48 +type OutputState struct { + RumbleSmall uint8 + RumbleLarge uint8 + LedRed uint8 + LedGreen uint8 + LedBlue uint8 + PlayerLeds uint8 + + TriggerR2Mode uint8 + TriggerR2StartResistance uint8 + TriggerR2EffectForce uint8 + TriggerR2RangeForce uint8 + TriggerR2NearReleaseStrength uint8 + TriggerR2NearMiddleStrength uint8 + TriggerR2PressedStrength uint8 + TriggerR2Frequency uint8 + TriggerL2Mode uint8 + TriggerL2StartResistance uint8 + TriggerL2EffectForce uint8 + TriggerL2RangeForce uint8 + TriggerL2NearReleaseStrength uint8 + TriggerL2NearMiddleStrength uint8 + TriggerL2PressedStrength uint8 + TriggerL2Frequency uint8 + + RawOutputReport [OutputReportSize]byte + BluetoothHapticsOutputReport [BluetoothHapticsReportSize]byte + BluetoothCombinedOutputReport [BluetoothCombinedHapticsReportSize]byte +} + +func (f *OutputState) MarshalBinary() ([]byte, error) { + return []byte{ + f.RumbleSmall, + f.RumbleLarge, + f.LedRed, + f.LedGreen, + f.LedBlue, + f.PlayerLeds, + }, nil +} + +func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { + b := make([]byte, OutputStateExtSize) + b[0] = f.RumbleSmall + b[1] = f.RumbleLarge + b[2] = f.LedRed + b[3] = f.LedGreen + b[4] = f.LedBlue + b[5] = f.PlayerLeds + + b[6] = f.TriggerR2Mode + b[7] = f.TriggerR2StartResistance + b[8] = f.TriggerR2EffectForce + b[9] = f.TriggerR2RangeForce + b[10] = f.TriggerR2NearReleaseStrength + b[11] = f.TriggerR2NearMiddleStrength + b[12] = f.TriggerR2PressedStrength + b[15] = f.TriggerR2Frequency + + b[17] = f.TriggerL2Mode + b[18] = f.TriggerL2StartResistance + b[19] = f.TriggerL2EffectForce + b[20] = f.TriggerL2RangeForce + b[21] = f.TriggerL2NearReleaseStrength + b[22] = f.TriggerL2NearMiddleStrength + b[23] = f.TriggerL2PressedStrength + b[26] = f.TriggerL2Frequency + copy(b[OutputStateRawReportOffset:], f.RawOutputReport[:]) + copy(b[OutputStateBluetoothHapticsOffset:], f.BluetoothHapticsOutputReport[:]) + return b, nil +} + +// MarshalCombinedExtendedBinary emits the versioned vDS-style 0x36 feedback +// extension. It intentionally does not append the legacy 0x32 report: stream +// consumers must select exactly one framing contract. +func (f *OutputState) MarshalCombinedExtendedBinary() ([]byte, error) { + b := make([]byte, OutputStateCombinedExtSize) + b[0] = f.RumbleSmall + b[1] = f.RumbleLarge + b[2] = f.LedRed + b[3] = f.LedGreen + b[4] = f.LedBlue + b[5] = f.PlayerLeds + + b[6] = f.TriggerR2Mode + b[7] = f.TriggerR2StartResistance + b[8] = f.TriggerR2EffectForce + b[9] = f.TriggerR2RangeForce + b[10] = f.TriggerR2NearReleaseStrength + b[11] = f.TriggerR2NearMiddleStrength + b[12] = f.TriggerR2PressedStrength + b[15] = f.TriggerR2Frequency + + b[17] = f.TriggerL2Mode + b[18] = f.TriggerL2StartResistance + b[19] = f.TriggerL2EffectForce + b[20] = f.TriggerL2RangeForce + b[21] = f.TriggerL2NearReleaseStrength + b[22] = f.TriggerL2NearMiddleStrength + b[23] = f.TriggerL2PressedStrength + b[26] = f.TriggerL2Frequency + copy(b[OutputStateRawReportOffset:], f.RawOutputReport[:]) + copy(b[OutputStateCombinedBluetoothOffset:], f.BluetoothCombinedOutputReport[:]) + return b, nil +} + +func (f *OutputState) UnmarshalBinary(data []byte) error { + if len(data) < OutputStateSize { + return io.ErrUnexpectedEOF + } + f.RumbleSmall = data[0] + f.RumbleLarge = data[1] + f.LedRed = data[2] + f.LedGreen = data[3] + f.LedBlue = data[4] + f.PlayerLeds = data[5] + if len(data) < OutputStateCompatExtSize { + return nil + } + + f.TriggerR2Mode = data[6] + f.TriggerR2StartResistance = data[7] + f.TriggerR2EffectForce = data[8] + f.TriggerR2RangeForce = data[9] + f.TriggerR2NearReleaseStrength = data[10] + f.TriggerR2NearMiddleStrength = data[11] + f.TriggerR2PressedStrength = data[12] + f.TriggerR2Frequency = data[15] + f.TriggerL2Mode = data[17] + f.TriggerL2StartResistance = data[18] + f.TriggerL2EffectForce = data[19] + f.TriggerL2RangeForce = data[20] + f.TriggerL2NearReleaseStrength = data[21] + f.TriggerL2NearMiddleStrength = data[22] + f.TriggerL2PressedStrength = data[23] + f.TriggerL2Frequency = data[26] + if len(data) >= OutputStateCombinedExtSize { + copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateCombinedBluetoothOffset]) + copy(f.BluetoothCombinedOutputReport[:], data[OutputStateCombinedBluetoothOffset:OutputStateCombinedExtSize]) + } else if len(data) >= OutputStateExtSize { + copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateBluetoothHapticsOffset]) + copy(f.BluetoothHapticsOutputReport[:], data[OutputStateBluetoothHapticsOffset:OutputStateExtSize]) + } else if len(data) >= OutputStateBluetoothHapticsOffset { + copy(f.RawOutputReport[:], data[OutputStateRawReportOffset:OutputStateBluetoothHapticsOffset]) + } + return nil +} + +func encodeTouchStatus(active bool, tracking uint8) uint8 { + if tracking != 0 { + if active { + return tracking &^ 0x80 + } + return tracking | 0x80 + } + if active { + return 0 + } + return TouchInactiveMask +} + +func decodeTouchStatus(status uint8) (bool, uint8) { + return status&0x80 == 0, status +} + +type MetaState struct { + SerialNumber string `json:"serial_number"` + MACAddress string `json:"mac_address"` // "XX:XX:XX:XX:XX:XX" + Board string `json:"board"` + BuildTime time.Time `json:"build_time"` + + BatteryStatus uint8 `json:"battery_status"` + TemperatureCelsius float64 `json:"temperature_celsius"` + BatteryVoltage float64 `json:"battery_voltage"` + + ShellColor string `json:"shell_color"` // hardware variant / controller color code, e.g. "00", "Z1" +} + +func (m *MetaState) ToMap() map[string]any { + bytes, err := json.Marshal(m) + if err != nil { + slog.Error("marshal meta state for map", "error", err) + return map[string]any{} + } + var res map[string]any + err = json.Unmarshal(bytes, &res) + if err != nil { + slog.Error("unmarshal meta state for map", "error", err) + return map[string]any{} + } + return res +} + +func (m *MetaState) UpdateFromMap(data map[string]any) { + bytes, err := json.Marshal(data) + if err != nil { + slog.Error("marshal meta state for update", "error", err) + return + } + var newMeta MetaState + err = json.Unmarshal(bytes, &newMeta) + if err != nil { + slog.Error("unmarshal meta state for update", "error", err) + return + } + if newMeta.SerialNumber != "" { + m.SerialNumber = newMeta.SerialNumber + } + if newMeta.MACAddress != "" { + m.MACAddress = newMeta.MACAddress + } + if newMeta.Board != "" { + m.Board = newMeta.Board + } + if !newMeta.BuildTime.IsZero() { + m.BuildTime = newMeta.BuildTime + } + if newMeta.BatteryStatus != 0 { + m.BatteryStatus = newMeta.BatteryStatus + } + if newMeta.TemperatureCelsius != 0 { + m.TemperatureCelsius = newMeta.TemperatureCelsius + } + if newMeta.BatteryVoltage != 0 { + m.BatteryVoltage = newMeta.BatteryVoltage + } + m.ShellColor = newMeta.ShellColor +} diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go new file mode 100644 index 00000000..bae4d034 --- /dev/null +++ b/device/dualshock4/audio_test.go @@ -0,0 +1,531 @@ +package dualshock4 + +import ( + "context" + "encoding/binary" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/Alia5/VIIPER/usbip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescriptorExposesNativeDS4AudioTopology(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + desc := dev.GetDescriptor() + assert.Equal(t, uint8(4), desc.NumInterfaces()) + assert.Equal(t, uint8(InterfaceHID), desc.Interfaces[len(desc.Interfaces)-1].Descriptor.BInterfaceNumber) + require.Len(t, desc.Interfaces[0].ClassDescriptors, 7) + assert.Len(t, desc.Interfaces[0].ClassDescriptors[2].Payload, 8) + + var speakerEndpointFound bool + var microphoneEndpointFound bool + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + switch endpoint.BEndpointAddress { + case EndpointAudioOut: + speakerEndpointFound = true + assert.Equal(t, uint8(0x09), endpoint.BMAttributes) + assert.Equal(t, uint16(USBSpeakerMaxPacketSize), endpoint.WMaxPacketSize) + case EndpointMicrophoneIn: + microphoneEndpointFound = true + assert.Equal(t, uint8(0x05), endpoint.BMAttributes) + assert.Equal(t, uint16(USBMicrophoneMaxPacketSize), endpoint.WMaxPacketSize) + } + } + } + + assert.True(t, speakerEndpointFound) + assert.True(t, microphoneEndpointFound) + + configurationLength := 9 + for _, iface := range desc.Interfaces { + configurationLength += 9 + if iface.HID != nil { + configurationLength += 9 + } + for _, classDescriptor := range iface.ClassDescriptors { + configurationLength += 2 + len(classDescriptor.Payload) + } + for _, endpoint := range iface.Endpoints { + configurationLength += 7 + len(endpoint.Trailing) + for _, classDescriptor := range endpoint.ClassDescriptors { + configurationLength += 2 + len(classDescriptor.Payload) + } + } + } + assert.Equal(t, 225, configurationLength) +} + +func TestAudioOnlyDescriptorKeepsAudioAndRemovesHID(t *testing.T) { + desc := makeAudioOnlyDescriptor() + assert.Equal(t, uint8(3), desc.NumInterfaces()) + + var speakerEndpointFound bool + var microphoneEndpointFound bool + for _, iface := range desc.Interfaces { + assert.Nil(t, iface.HID) + assert.NotEqual(t, uint8(0x03), + iface.Descriptor.BInterfaceClass) + for _, endpoint := range iface.Endpoints { + switch endpoint.BEndpointAddress { + case EndpointAudioOut: + speakerEndpointFound = true + case EndpointMicrophoneIn: + microphoneEndpointFound = true + } + } + } + + assert.True(t, speakerEndpointFound) + assert.True(t, microphoneEndpointFound) +} + +func TestAudioSamplingFrequencyControlsMatchDS4Hardware(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + speaker, handled := dev.HandleControl(audioClassEndpointIn, audioClassRequestGetCurrent, + uint16(audioControlSamplingFrequency)<<8, EndpointAudioOut, 3, nil) + require.True(t, handled) + assert.Equal(t, []byte{0x00, 0x7D, 0x00}, speaker) + + microphone, handled := dev.HandleControl(audioClassEndpointIn, audioClassRequestGetCurrent, + uint16(audioControlSamplingFrequency)<<8, EndpointMicrophoneIn, 3, nil) + require.True(t, handled) + assert.Equal(t, []byte{0x80, 0x3E, 0x00}, microphone) +} + +func TestAudioInterfacesTrackAlternateSettings(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + dev.SetInterfaceAltSetting(InterfaceSpeaker, 1) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + state := dev.GetDeviceSpecificArgs() + assert.Equal(t, true, state["speakerInterfaceActive"]) + assert.Equal(t, true, state["microphoneInterfaceActive"]) + + microphone := dev.HandleTransfer(context.Background(), uint32(EndpointMicrophoneIn), usbip.DirIn, nil) + assert.Len(t, microphone, USBMicrophonePacketSize) + assert.Equal(t, make([]byte, USBMicrophonePacketSize), microphone) +} + +func TestSpeakerTransferIsForwardedWithoutLoopbackCapture(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + dev.SetInterfaceAltSetting(InterfaceSpeaker, 1) + + forwarded := make(chan []byte, 1) + dev.SetSpeakerCallback(func(pcm []byte) { forwarded <- pcm }) + pcm := make([]byte, 128) + for index := range pcm { + pcm[index] = byte(index) + } + + dev.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, pcm) + got := <-forwarded + assert.Equal(t, pcm, got) + + // The callback owns a copy; reusing the USB/IP buffer must not mutate it. + pcm[0] = 0xFF + assert.Equal(t, byte(0), got[0]) + dev.SetSpeakerCallback(nil) +} + +func TestDuplexWriterFramesSpeakerPCM(t *testing.T) { + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + go writer.Run() + + pcm := []byte{0x00, 0x01, 0xFE, 0xFF} + writer.EnqueueAudio(StreamFrameSpeakerPCM, pcm) + header := make([]byte, StreamFrameV2HeaderSize) + _, err := io.ReadFull(client, header) + require.NoError(t, err) + payload := make([]byte, binary.LittleEndian.Uint16(header[6:8])) + _, err = io.ReadFull(client, payload) + require.NoError(t, err) + + assert.Equal(t, []byte{StreamFrameMagic0, StreamFrameMagic1, + StreamFrameMagic2, StreamFrameMagic3}, header[:4]) + assert.Equal(t, byte(StreamFrameVersionV3), header[4]) + assert.Equal(t, byte(StreamFrameSpeakerPCM), header[5]) + assert.Equal(t, uint32(0), binary.LittleEndian.Uint32(header[8:12])) + assert.Equal(t, dualShock4FramedStreamCRC(header[4:12], payload), + binary.LittleEndian.Uint32(header[12:16])) + assert.Equal(t, pcm, payload) + + require.NoError(t, client.Close()) + writer.Stop() +} + +func TestAudioInterfaceTransitionsDropPreviousGeneration(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + dev.SetSpeakerCallback(func(pcm []byte) { + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, pcm) + }) + dev.SetSpeakerResetCallback(writer.ResetSpeaker) + + dev.SetInterfaceAltSetting(InterfaceSpeaker, 1) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + stale := []byte{0x11, 0x22, 0x33, 0x44} + dev.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, stale) + require.Len(t, writer.audio, 1) + microphone := make([]byte, USBMicrophoneClientFrameSize) + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(microphone) + } + require.Equal(t, true, + dev.GetDeviceSpecificArgs()["microphoneQueuePrimed"]) + + dev.SetInterfaceAltSetting(InterfaceSpeaker, 0) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 0) + require.Empty(t, writer.audio, + "closing the speaker interface retained stale PCM") + state := dev.GetDeviceSpecificArgs() + assert.Equal(t, 0, state["queuedMicrophoneBytes"]) + assert.Equal(t, false, state["microphoneQueuePrimed"]) + dev.SetInterfaceAltSetting(InterfaceSpeaker, 1) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + state = dev.GetDeviceSpecificArgs() + assert.Equal(t, true, state["speakerInterfaceActive"]) + assert.Equal(t, true, state["microphoneInterfaceActive"]) + + go writer.Run() + fresh := []byte{0x55, 0x66, 0x77, 0x88} + dev.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, fresh) + assert.Equal(t, fresh, readDualShock4OutputPayload(t, client)) + + dev.SetSpeakerCallback(nil) + dev.SetSpeakerResetCallback(nil) + require.NoError(t, client.Close()) + writer.Stop() +} + +func TestEndpointResetDropsSpeakerAndMicrophoneWithoutChangingAlt(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + server, client := net.Pipe() + writer := newDualShock4OutputWriter(server, StreamFrameVersionV3) + dev.SetSpeakerCallback(func(pcm []byte) { + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, pcm) + }) + dev.SetSpeakerResetCallback(writer.ResetSpeaker) + dev.SetInterfaceAltSetting(InterfaceSpeaker, 1) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + dev.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, []byte{0x10, 0x20, 0x30, 0x40}) + require.Len(t, writer.audio, 1) + microphone := make([]byte, USBMicrophoneClientFrameSize) + for index := range microphone { + microphone[index] = byte(index + 1) + } + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(microphone) + } + require.Equal(t, true, + dev.GetDeviceSpecificArgs()["microphoneQueuePrimed"]) + + dev.ResetEndpoint(EndpointAudioOut) + dev.ResetEndpoint(EndpointMicrophoneIn) + state := dev.GetDeviceSpecificArgs() + assert.Equal(t, true, state["speakerInterfaceActive"]) + assert.Equal(t, true, state["microphoneInterfaceActive"]) + assert.Equal(t, 0, state["queuedMicrophoneBytes"]) + assert.Equal(t, false, state["microphoneQueuePrimed"]) + require.Empty(t, writer.audio, + "speaker endpoint reset retained stale PCM") + + go writer.Run() + fresh := []byte{0x50, 0x60, 0x70, 0x80} + dev.HandleTransfer(context.Background(), uint32(EndpointAudioOut), + usbip.DirOut, fresh) + assert.Equal(t, fresh, readDualShock4OutputPayload(t, client)) + + dev.SetSpeakerCallback(nil) + dev.SetSpeakerResetCallback(nil) + require.NoError(t, client.Close()) + writer.Stop() +} + +type dualShock4WriteGateConn struct { + net.Conn + started chan struct{} + release chan struct{} + once sync.Once +} + +func (c *dualShock4WriteGateConn) Write(payload []byte) (int, error) { + c.once.Do(func() { close(c.started) }) + <-c.release + return len(payload), nil +} + +func TestSpeakerResetWaitsForInFlightWrite(t *testing.T) { + server, client := net.Pipe() + gate := &dualShock4WriteGateConn{ + Conn: server, started: make(chan struct{}), release: make(chan struct{}), + } + writer := newDualShock4OutputWriter(gate, StreamFrameVersionV3) + go writer.Run() + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x01, 0x02, 0x03, 0x04}) + + select { + case <-gate.started: + case <-time.After(time.Second): + t.Fatal("speaker writer did not start") + } + resetDone := make(chan struct{}) + go func() { + writer.ResetSpeaker() + close(resetDone) + }() + select { + case <-resetDone: + t.Fatal("speaker reset crossed an in-flight write") + case <-time.After(20 * time.Millisecond): + } + + close(gate.release) + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("speaker reset did not finish after the write completed") + } + + require.NoError(t, client.Close()) + writer.Stop() +} + +type dualShock4DeadlineBlockConn struct { + net.Conn + started chan struct{} + unblock chan struct{} + startedOnce sync.Once + unblockOnce sync.Once + mu sync.Mutex + writes [][]byte + deadlines []time.Time + closeCount int +} + +func (c *dualShock4DeadlineBlockConn) Write(payload []byte) (int, error) { + c.mu.Lock() + c.writes = append(c.writes, append([]byte(nil), payload...)) + c.mu.Unlock() + c.startedOnce.Do(func() { close(c.started) }) + <-c.unblock + return 0, context.DeadlineExceeded +} + +func (c *dualShock4DeadlineBlockConn) SetWriteDeadline(deadline time.Time) error { + c.mu.Lock() + c.deadlines = append(c.deadlines, deadline) + c.mu.Unlock() + if !deadline.IsZero() { + c.unblockOnce.Do(func() { close(c.unblock) }) + } + return nil +} + +func (c *dualShock4DeadlineBlockConn) Close() error { + c.mu.Lock() + c.closeCount++ + c.mu.Unlock() + c.unblockOnce.Do(func() { close(c.unblock) }) + return c.Conn.Close() +} + +func (c *dualShock4DeadlineBlockConn) snapshot() ([][]byte, []time.Time, int) { + c.mu.Lock() + defer c.mu.Unlock() + writes := make([][]byte, len(c.writes)) + for index := range c.writes { + writes[index] = append([]byte(nil), c.writes[index]...) + } + return writes, append([]time.Time(nil), c.deadlines...), c.closeCount +} + +func TestSpeakerResetBoundsBlockedWriteAndDropsQueuedGeneration(t *testing.T) { + server, client := net.Pipe() + conn := &dualShock4DeadlineBlockConn{ + Conn: server, started: make(chan struct{}), unblock: make(chan struct{}), + } + writer := newDualShock4OutputWriter(conn, StreamFrameVersionV3) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x11}) + go writer.Run() + select { + case <-conn.started: + case <-time.After(time.Second): + t.Fatal("speaker writer did not enter the blocked write") + } + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x22}) + + resetStarted := time.Now() + resetDone := make(chan struct{}) + go func() { + writer.ResetSpeaker() + close(resetDone) + }() + select { + case <-resetDone: + case <-time.After(time.Second): + t.Fatal("speaker reset did not bound the blocked write") + } + if elapsed := time.Since(resetStarted); elapsed > time.Second { + t.Fatalf("speaker reset exceeded its bounded wait: %s", elapsed) + } + select { + case <-writer.done: + case <-time.After(time.Second): + t.Fatal("writer did not stop so the timed-out stream could reconnect") + } + + writes, deadlines, closeCount := conn.snapshot() + require.Len(t, writes, 1, + "queued old-generation speaker PCM was replayed after reset") + require.GreaterOrEqual(t, len(writes[0]), StreamFrameV2HeaderSize+1) + assert.Equal(t, byte(0x11), writes[0][StreamFrameV2HeaderSize]) + require.Len(t, deadlines, 1, + "timed-out stream must not clear its reset deadline") + assert.False(t, deadlines[0].IsZero()) + assert.GreaterOrEqual(t, deadlines[0].Sub(resetStarted), + dualShock4SpeakerResetWriteTimeout-50*time.Millisecond) + assert.GreaterOrEqual(t, closeCount, 1, + "timed-out stream was not closed for reconnect") + assert.Empty(t, writer.audio) + writer.EnqueueAudio(StreamFrameSpeakerPCM, []byte{0x33}) + assert.Empty(t, writer.audio, + "failed writer accepted audio instead of waiting for reconnect") + + require.NoError(t, client.Close()) +} + +func readDualShock4OutputPayload(t *testing.T, reader io.Reader) []byte { + t.Helper() + header := make([]byte, StreamFrameV2HeaderSize) + _, err := io.ReadFull(reader, header) + require.NoError(t, err) + require.Equal(t, byte(StreamFrameSpeakerPCM), header[5]) + payload := make([]byte, binary.LittleEndian.Uint16(header[6:8])) + _, err = io.ReadFull(reader, payload) + require.NoError(t, err) + return payload +} + +func TestFramedStreamQueuesDS4MicrophonePCM(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + server, client := net.Pipe() + done := make(chan error, 1) + go func() { + done <- readDualShock4InputStream(server, dev, + slog.New(slog.NewTextHandler(io.Discard, nil)), true, + StreamFrameVersionV2) + }() + + input, err := NewInputState().MarshalBinary() + require.NoError(t, err) + microphone := make([]byte, USBMicrophoneClientFrameSize) + for index := range microphone { + microphone[index] = byte(index) + } + + _, err = client.Write(makeDualShock4StreamFrame(StreamFrameInputState, 0, input)) + require.NoError(t, err) + for sequence := uint32(1); sequence <= microphoneTargetClientFrames; sequence++ { + _, err = client.Write(makeDualShock4StreamFrame(StreamFrameMicrophonePCM, sequence, microphone)) + require.NoError(t, err) + } + require.NoError(t, client.Close()) + require.NoError(t, <-done) + + packet := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + require.Len(t, packet, USBMicrophonePacketSize) + assert.Equal(t, microphone[:USBMicrophonePacketSize], packet) +} + +func TestDS4MicrophoneQueuePrimesAndExposesRecoveryTelemetry(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + frame := make([]byte, USBMicrophoneClientFrameSize) + for index := range frame { + frame[index] = byte(index + 1) + } + for count := 0; count < microphoneTargetClientFrames-1; count++ { + dev.QueueMicrophonePCMFrame(frame) + } + + packet := dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + assert.Equal(t, make([]byte, USBMicrophonePacketSize), packet) + state := dev.GetDeviceSpecificArgs() + assert.Equal(t, USBMicrophoneClientFrameSize*microphoneTargetClientFrames, + state["microphoneQueueTargetBytes"]) + assert.Equal(t, USBMicrophoneClientFrameSize*microphoneMaximumClientFrames, + state["microphoneQueueMaximumBytes"]) + assert.Equal(t, false, state["microphoneQueuePrimed"]) + + dev.QueueMicrophonePCMFrame(frame) + packet = dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + assert.Equal(t, frame[:USBMicrophonePacketSize], packet) + + packetsPerClientFrame := USBMicrophoneClientFrameSize / USBMicrophonePacketSize + for count := 1; count < microphoneTargetClientFrames*packetsPerClientFrame; count++ { + packet = dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + assert.NotEqual(t, make([]byte, USBMicrophonePacketSize), packet) + } + packet = dev.HandleTransfer(context.Background(), + uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) + assert.Equal(t, make([]byte, USBMicrophonePacketSize), packet) + state = dev.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["microphoneUnderruns"]) + assert.Equal(t, uint64(0), state["microphoneReprimes"]) + assert.Equal(t, false, state["microphoneQueuePrimed"]) + + for count := 0; count < microphoneTargetClientFrames; count++ { + dev.QueueMicrophonePCMFrame(frame) + } + state = dev.GetDeviceSpecificArgs() + assert.Equal(t, uint64(1), state["microphoneUnderruns"]) + assert.Equal(t, uint64(1), state["microphoneReprimes"]) + assert.Equal(t, true, state["microphoneQueuePrimed"]) +} + +func makeDualShock4StreamFrame(frameType byte, sequence uint32, + payload []byte) []byte { + header := make([]byte, StreamFrameV2HeaderSize) + header[0] = StreamFrameMagic0 + header[1] = StreamFrameMagic1 + header[2] = StreamFrameMagic2 + header[3] = StreamFrameMagic3 + header[4] = StreamFrameVersionV2 + header[5] = frameType + binary.LittleEndian.PutUint16(header[6:8], uint16(len(payload))) + binary.LittleEndian.PutUint32(header[8:12], sequence) + binary.LittleEndian.PutUint32(header[12:16], + dualShock4FramedStreamCRC(header[4:12], payload)) + return append(header, payload...) +} diff --git a/device/dualshock4/const.go b/device/dualshock4/const.go new file mode 100644 index 00000000..ee76a018 --- /dev/null +++ b/device/dualshock4/const.go @@ -0,0 +1,208 @@ +package dualshock4 + +import "time" + +const ( + DefaultVID = 0x054C + DefaultPID = 0x09CC +) + +const ( + DefaultSerialString = "1111020BF619A500" + DefaultBoardString = "JDM-055" + DefaultTemperature = 28.0 + DefaultVoltage = 4.2 + DefaultBatteryStatus = BatteryChargingFlag | BatteryFullyCharged +) + +var DefaultBuildTime = time.Date(2021, time.September, 17, 11, 34, 0, 0, time.UTC) + +const ( + EndpointAudioOut = 0x01 + EndpointMicrophoneIn = 0x82 + EndpointIn = 0x84 + EndpointOut = 0x03 +) + +const ( + InterfaceAudioControl = 0x00 + InterfaceSpeaker = 0x01 + InterfaceMicrophone = 0x02 + InterfaceHID = 0x03 +) + +const ( + USBSpeakerSampleRate = 32000 + USBSpeakerChannels = 2 + USBSpeakerBytesPerSample = 2 + USBSpeakerMaxPacketSize = 132 + + USBMicrophoneSampleRate = 16000 + USBMicrophoneChannels = 1 + USBMicrophoneBytesPerSample = 2 + USBMicrophonePacketFrames = USBMicrophoneSampleRate / 1000 + USBMicrophonePacketSize = USBMicrophonePacketFrames * + USBMicrophoneChannels * USBMicrophoneBytesPerSample + USBMicrophoneMaxPacketSize = 34 + USBMicrophoneClientFrames = USBMicrophoneSampleRate / 100 + USBMicrophoneClientFrameSize = USBMicrophoneClientFrames * + USBMicrophoneChannels * USBMicrophoneBytesPerSample +) + +const ( + InputStateSize = 31 + StreamFrameV2HeaderSize = 16 + StreamFrameMagic0 = 0x56 + StreamFrameMagic1 = 0x50 + StreamFrameMagic2 = 0x43 + StreamFrameMagic3 = 0x4D + StreamFrameVersionV2 = 0x02 + StreamFrameVersionV3 = 0x03 + StreamFrameInputState = 0x01 + StreamFrameMicrophonePCM = 0x02 + StreamFrameOutputState = 0x81 + StreamFrameSpeakerPCM = 0x82 +) + +const ( + ReportIDInput = 0x01 + ReportIDOutput = 0x05 +) + +const ( + InputReportSize = 64 +) + +const ( + ButtonSquare uint16 = 0x0010 + ButtonCross uint16 = 0x0020 + ButtonCircle uint16 = 0x0040 + ButtonTriangle uint16 = 0x0080 + + DPadMask uint8 = 0x0F +) + +const ( + ButtonL1 uint16 = 0x0100 + ButtonR1 uint16 = 0x0200 + ButtonL2 uint16 = 0x0400 + ButtonR2 uint16 = 0x0800 + ButtonShare uint16 = 0x1000 + ButtonOptions uint16 = 0x2000 + ButtonL3 uint16 = 0x4000 + ButtonR3 uint16 = 0x8000 + + ButtonPS uint16 = 0x0001 + ButtonTouchpadClick uint16 = 0x0002 +) + +const ( + ButtonPSUSB uint8 = 0x01 + ButtonTouchpadClickUSB uint8 = 0x02 + + CounterShift = 2 +) + +const ( + DPadUSBUp = 0x00 + DPadUSBUpRight = 0x01 + DPadUSBRight = 0x02 + DPadUSBDownRight = 0x03 + DPadUSBDown = 0x04 + DPadUSBDownLeft = 0x05 + DPadUSBLeft = 0x06 + DPadUSBUpLeft = 0x07 + DPadUSBNeutral = 0x08 +) + +const ( + DPadUp = 0x01 + DPadDown = 0x02 + DPadLeft = 0x04 + DPadRight = 0x08 +) + +// The DS4 USB input report carries gyro/accel as signed int16 values. +// VIIPER's wire protocol keeps them as int16, but interprets them as fixed-point +// physical units to avoid float serialization across clients. +// +// Gyro fields (GyroX/Y/Z): °/s scaled by GyroCountsPerDps. +// Accel fields (AccelX/Y/Z): m/s² scaled by AccelCountsPerMS2. +const ( + // GyroCountsPerDps is the fixed-point scale factor for °/s. + // resolution is 0.0625 °/s and range is about +-2048 °/s. + GyroCountsPerDps = 16.0 + + // AccelCountsPerMS2 is the fixed-point scale factor for m/s². + // resolution is ~0.00195 m/s² and range is about +-64 m/s² (~+-6.5 g). + AccelCountsPerMS2 = 512.0 + + StandardGravityMS2 = 9.81 +) + +// Default accelerometer raw values for a controller lying flat on a table. +const ( + DefaultAccelXRaw int16 = 0 + DefaultAccelYRaw int16 = 0 + // -StandardGravityMS2 * AccelCountsPerMS2 = (-9.81 * 512) = -5023 + DefaultAccelZRaw int16 = -5023 +) + +const ( + DefaultLedRed = 0x00 + DefaultLedGreen = 0x00 + DefaultLedBlue = 0x40 +) + +const ( + TouchpadMinX uint16 = 0 + TouchpadMaxX uint16 = 1920 + TouchpadMinY uint16 = 0 + TouchpadMaxY uint16 = 942 + + TouchInactiveMask uint8 = 0x80 +) + +const ( + BatteryLevelMask = 0x0F + BatteryChargingFlag = 0x10 + BatteryFullyCharged = 0x0A +) +const ( + HardwareVersionMajor uint16 = 0x0001 + HardwareVersionMinor uint16 = 0xB400 + SoftwareVersionMajor uint32 = 0x00000001 + SoftwareVersionMinor uint16 = 0xA00B +) + +const ( + hidClassIN uint8 = 0xA1 // bmRequestType: HID class IN (device→host) + hidClassOUT uint8 = 0x21 // bmRequestType: HID class OUT (host→device) +) + +const ( + hidGetReport uint8 = 0x01 + hidGetIdle uint8 = 0x02 + hidGetProtocol uint8 = 0x03 + hidSetReport uint8 = 0x09 +) + +const ( + reportTypeInput uint8 = 0x01 + reportTypeOutput uint8 = 0x02 + reportTypeFeature uint8 = 0x03 +) + +const ( + featureIDCalibration byte = 0x02 + featureIDCapabilities byte = 0x03 + featureIDCalibrationBT byte = 0x05 + featureIDProbe byte = 0x08 + featureIDStatus byte = 0x10 + featureIDProbeResponse byte = 0x11 + featureIDSerial byte = 0x12 + featureIDIdentity byte = 0x81 + featureIDSubcommand byte = 0xA0 + featureIDBoardInfo byte = 0xA3 + featureIDTelemetry byte = 0xA4 // serial or voltage/temperature; content selected by featureIDSubcommand +) diff --git a/device/dualshock4/descriptor.go b/device/dualshock4/descriptor.go new file mode 100644 index 00000000..ef6fd2fb --- /dev/null +++ b/device/dualshock4/descriptor.go @@ -0,0 +1,469 @@ +package dualshock4 + +import ( + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" +) + +var defaultDescriptor = usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0x00, + BDeviceSubClass: 0x00, + BDeviceProtocol: 0x00, + BMaxPacketSize0: 0x40, + IDVendor: DefaultVID, + IDProduct: DefaultPID, + BcdDevice: 0x0100, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x00, + BNumConfigurations: 0x01, + Speed: 2, + }, + Configuration: usb.ConfigurationDescriptor{ + BConfigurationValue: 0x01, + BMAttributes: 0xC0, + BMaxPower: 0xFA, + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceAudioControl, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0x01, + BInterfaceSubClass: 0x01, + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + // Faithful CUH-ZCT2 UAC1 AudioControl topology: stereo USB + // stream -> headset, plus headset microphone -> USB stream. + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x00, 0x01, 0x47, 0x00, 0x02, InterfaceSpeaker, InterfaceMicrophone}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x06, 0x02, 0x03, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x06, 0x02, 0x01, 0x01, 0x03, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x03, 0x03, 0x02, 0x04, 0x04, 0x02, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x04, 0x02, 0x04, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x06, 0x05, 0x04, 0x01, 0x03, 0x00, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x03, 0x06, 0x01, 0x01, 0x01, 0x05, 0x00}}, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceSpeaker, BAlternateSetting: 0x00, + BNumEndpoints: 0x00, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceSpeaker, BAlternateSetting: 0x01, + BNumEndpoints: 0x01, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x01, 0x01, 0x01, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, USBSpeakerChannels, USBSpeakerBytesPerSample, 0x10, 0x01, 0x00, 0x7D, 0x00}}, + }, + Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: EndpointAudioOut, + BMAttributes: 0x09, + WMaxPacketSize: USBSpeakerMaxPacketSize, + BInterval: 0x01, + Trailing: usb.Data{0x00, 0x00}, + ClassDescriptors: []usb.ClassSpecificDescriptor{{DescriptorType: 0x25, Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}}}, + }}, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceMicrophone, BAlternateSetting: 0x00, + BNumEndpoints: 0x00, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceMicrophone, BAlternateSetting: 0x01, + BNumEndpoints: 0x01, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x24, Payload: usb.Data{0x01, 0x06, 0x01, 0x01, 0x00}}, + {DescriptorType: 0x24, Payload: usb.Data{0x02, 0x01, USBMicrophoneChannels, USBMicrophoneBytesPerSample, 0x10, 0x01, 0x80, 0x3E, 0x00}}, + }, + Endpoints: []usb.EndpointDescriptor{{ + BEndpointAddress: EndpointMicrophoneIn, + BMAttributes: 0x05, + WMaxPacketSize: USBMicrophoneMaxPacketSize, + BInterval: 0x01, + Trailing: usb.Data{0x00, 0x00}, + ClassDescriptors: []usb.ClassSpecificDescriptor{{DescriptorType: 0x25, Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}}}, + }}, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: InterfaceHID, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0x03, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, + }, + }, + ReportDescriptor: hid.ReportDescriptor{Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageGamePad}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + + hid.ReportID{ID: ReportIDInput}, + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageX}, + hid.Usage{Usage: hid.UsageY}, + hid.Usage{Usage: hid.UsageZ}, + hid.Usage{Usage: hid.UsageRz}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 4}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.Usage{Usage: 0x39}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 7}, + hid.PhysicalMinimum{Min: 0}, + hid.PhysicalMaximum{Max: 315}, + hid.Unit{Value: 0x14}, + hid.ReportSize{Bits: 4}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs | hid.MainNullState}, + hid.Unit{Value: 0}, + + hid.UsagePage{Page: hid.UsagePageButton}, + hid.UsageMinimum{Min: 0x01}, + hid.UsageMaximum{Max: 0x0E}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 14}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x20}, + hid.ReportSize{Bits: 6}, + hid.ReportCount{Count: 1}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 0x7F}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageRx}, + hid.Usage{Usage: hid.UsageRy}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 2}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x21}, + hid.ReportCount{Count: 54}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: ReportIDOutput}, + hid.Usage{Usage: 0x22}, + hid.ReportCount{Count: 31}, + hid.Output{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x04}, + hid.Usage{Usage: 0x23}, + hid.ReportCount{Count: 36}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDCalibration}, + hid.Usage{Usage: 0x24}, + hid.ReportCount{Count: 36}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDProbe}, + hid.Usage{Usage: 0x25}, + hid.ReportCount{Count: 3}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDStatus}, + hid.Usage{Usage: 0x26}, + hid.ReportCount{Count: 4}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDProbeResponse}, + hid.Usage{Usage: 0x27}, + hid.ReportCount{Count: 2}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDSerial}, + hid.UsagePage{Page: 0xFF02}, + hid.Usage{Usage: 0x21}, + hid.ReportCount{Count: 15}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x13}, + hid.Usage{Usage: 0x22}, + hid.ReportCount{Count: 22}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x14}, + hid.UsagePage{Page: 0xFF05}, + hid.Usage{Usage: 0x20}, + hid.ReportCount{Count: 16}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x15}, + hid.Usage{Usage: 0x21}, + hid.ReportCount{Count: 44}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: 0xFF80}, + + hid.ReportID{ID: 0x80}, + hid.Usage{Usage: 0x20}, + hid.ReportCount{Count: 6}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDIdentity}, + hid.Usage{Usage: 0x21}, + hid.ReportCount{Count: 6}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x82}, + hid.Usage{Usage: 0x22}, + hid.ReportCount{Count: 5}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x83}, + hid.Usage{Usage: 0x23}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x84}, + hid.Usage{Usage: 0x24}, + hid.ReportCount{Count: 4}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x85}, + hid.Usage{Usage: 0x25}, + hid.ReportCount{Count: 6}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x86}, + hid.Usage{Usage: 0x26}, + hid.ReportCount{Count: 6}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x87}, + hid.Usage{Usage: 0x27}, + hid.ReportCount{Count: 35}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x88}, + hid.Usage{Usage: 0x28}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x89}, + hid.Usage{Usage: 0x29}, + hid.ReportCount{Count: 2}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x90}, + hid.Usage{Usage: 0x30}, + hid.ReportCount{Count: 5}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x91}, + hid.Usage{Usage: 0x31}, + hid.ReportCount{Count: 3}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x92}, + hid.Usage{Usage: 0x32}, + hid.ReportCount{Count: 3}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x93}, + hid.Usage{Usage: 0x33}, + hid.ReportCount{Count: 12}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0x94}, + hid.Usage{Usage: 0x34}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDSubcommand}, + hid.Usage{Usage: 0x40}, + hid.ReportCount{Count: 6}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xA1}, + hid.Usage{Usage: 0x41}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xA2}, + hid.Usage{Usage: 0x42}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDBoardInfo}, + hid.Usage{Usage: 0x43}, + hid.ReportCount{Count: 48}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: featureIDTelemetry}, + hid.Usage{Usage: 0x44}, + hid.ReportCount{Count: 13}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xF0}, + hid.Usage{Usage: 0x47}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xF1}, + hid.Usage{Usage: 0x48}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xF2}, + hid.Usage{Usage: 0x49}, + hid.ReportCount{Count: 15}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xA7}, + hid.Usage{Usage: 0x4A}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xA8}, + hid.Usage{Usage: 0x4B}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xA9}, + hid.Usage{Usage: 0x4C}, + hid.ReportCount{Count: 8}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAA}, + hid.Usage{Usage: 0x4E}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAB}, + hid.Usage{Usage: 0x4F}, + hid.ReportCount{Count: 57}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAC}, + hid.Usage{Usage: 0x50}, + hid.ReportCount{Count: 57}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAD}, + hid.Usage{Usage: 0x51}, + hid.ReportCount{Count: 11}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAE}, + hid.Usage{Usage: 0x52}, + hid.ReportCount{Count: 1}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xAF}, + hid.Usage{Usage: 0x53}, + hid.ReportCount{Count: 2}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xB0}, + hid.Usage{Usage: 0x54}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xE0}, + hid.Usage{Usage: 0x57}, + hid.ReportCount{Count: 2}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xB3}, + hid.Usage{Usage: 0x55}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xB4}, + hid.Usage{Usage: 0x55}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xB5}, + hid.Usage{Usage: 0x56}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xD0}, + hid.Usage{Usage: 0x58}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.ReportID{ID: 0xD4}, + hid.Usage{Usage: 0x59}, + hid.ReportCount{Count: 63}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + }}, + }}, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: EndpointIn, + BMAttributes: 0x03, + WMaxPacketSize: 64, + BInterval: 5, + }, + { + BEndpointAddress: EndpointOut, + BMAttributes: 0x03, + WMaxPacketSize: 64, + BInterval: 5, + }, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\u0409", // LangID: en-US (0x0409) + 1: "Sony Interactive Entertainment", + 2: "Wireless Controller", + }, +} + +// makeAudioOnlyDescriptor retains the native DS4 UAC interfaces while +// omitting the HID gamepad interface. This lets a client pair the audio +// function with a separate Xbox or Switch virtual controller without exposing +// a second game-visible pad. +func makeAudioOnlyDescriptor() usb.Descriptor { + desc := defaultDescriptor + desc.Interfaces = make([]usb.InterfaceConfig, 0, + len(defaultDescriptor.Interfaces)) + for _, iface := range defaultDescriptor.Interfaces { + if iface.HID == nil && iface.Descriptor.BInterfaceClass != 0x03 { + desc.Interfaces = append(desc.Interfaces, iface) + } + } + desc.Strings = make(map[uint8]string, len(defaultDescriptor.Strings)) + for key, value := range defaultDescriptor.Strings { + desc.Strings[key] = value + } + return desc +} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go new file mode 100644 index 00000000..7fdcda2e --- /dev/null +++ b/device/dualshock4/device.go @@ -0,0 +1,807 @@ +package dualshock4 + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/internal/microphonebuffer" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +const ( + microphoneTargetClientFrames = 6 // 60 ms absorbs the DS4's 8/8/8/16 ms framing and host scheduling jitter. + microphoneMaximumClientFrames = 20 // 200 ms emergency ceiling for full-duplex BT bursts; steady state remains about 55 ms. +) + +type DualShock4 struct { + inputCh chan *InputState + inputState *InputState + metaState *MetaState + + outputFunc func(OutputState) + speakerFunc func([]byte) + speakerResetFunc func() + outputState OutputState + outputSeen bool + descriptor usb.Descriptor + + probeSelector [3]byte + telemetrySubcommand byte + + usbPacketCounter uint32 + timestampBase time.Time + + speakerInterfaceActive bool + microphoneInterfaceActive bool + microphoneInput bool + speakerOutput bool + streamFrameVersion byte + microphoneBuffer microphonebuffer.Buffer + microphoneSignal chan struct{} + + mtx sync.Mutex +} + +func New(o *device.CreateOptions) (*DualShock4, error) { + metaState := &MetaState{ + SerialNumber: DefaultSerialString, + Board: DefaultBoardString, + BuildTime: DefaultBuildTime, + BatteryStatus: DefaultBatteryStatus, + TemperatureCelsius: DefaultTemperature, + BatteryVoltage: DefaultVoltage, + } + if o != nil && o.DeviceSpecific != "" { + var newMeta MetaState + err := json.Unmarshal([]byte(o.DeviceSpecific), &newMeta) + if err != nil { + return nil, fmt.Errorf("invalid JSON payload: %w", err) + } + if newMeta.SerialNumber != "" { + metaState.SerialNumber = newMeta.SerialNumber + } + if newMeta.Board != "" { + metaState.Board = newMeta.Board + } + if !newMeta.BuildTime.IsZero() { + metaState.BuildTime = newMeta.BuildTime + } + if newMeta.BatteryStatus != 0 { + metaState.BatteryStatus = newMeta.BatteryStatus + } + if newMeta.TemperatureCelsius != 0 { + metaState.TemperatureCelsius = newMeta.TemperatureCelsius + } + if newMeta.BatteryVoltage != 0 { + metaState.BatteryVoltage = newMeta.BatteryVoltage + } + } + + d := &DualShock4{ + descriptor: defaultDescriptor, + metaState: metaState, + microphoneBuffer: microphonebuffer.New( + USBMicrophonePacketSize, + USBMicrophoneChannels*USBMicrophoneBytesPerSample, + USBMicrophoneClientFrameSize, + microphoneTargetClientFrames, + microphoneMaximumClientFrames, + ), + microphoneSignal: make(chan struct{}, 1), + } + if o != nil { + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor + } + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct + } + if len(d.metaState.SerialNumber) > 0 && len(d.metaState.SerialNumber) <= 16 { + d.metaState.SerialNumber = fmt.Sprintf("%016s", d.metaState.SerialNumber) + } + } + + slog.Info("DS4 device instantiated", + "vid", d.descriptor.Device.IDVendor, + "pid", d.descriptor.Device.IDProduct, + "interfaces", len(d.descriptor.Interfaces)) + + d.inputState = NewInputState() + d.inputCh = make(chan *InputState, 1) + d.inputCh <- d.inputState + d.timestampBase = time.Now() + + return d, nil +} + +func (d *DualShock4) SetMetaState(meta MetaState) { + d.mtx.Lock() + defer d.mtx.Unlock() + d.metaState = &meta +} + +func (d *DualShock4) SetOutputCallback(f func(OutputState)) { + var latest OutputState + var replay bool + + d.mtx.Lock() + d.outputFunc = f + if f != nil && d.outputSeen { + latest = d.outputState + replay = true + } + d.mtx.Unlock() + + if replay { + f(latest) + } +} + +func (d *DualShock4) SetSpeakerCallback(f func([]byte)) { + d.mtx.Lock() + d.speakerFunc = f + d.mtx.Unlock() +} + +// SetSpeakerResetCallback installs the transport-side queue reset paired with +// SetSpeakerCallback. Interface transitions and endpoint pipe resets must drop +// speaker PCM from the previous USB presentation generation. +func (d *DualShock4) SetSpeakerResetCallback(f func()) { + d.mtx.Lock() + d.speakerResetFunc = f + d.mtx.Unlock() +} + +func (d *DualShock4) UpdateInputState(state *InputState) { + d.mtx.Lock() + d.inputState = state + d.mtx.Unlock() + select { + case <-d.inputCh: + default: + } + d.inputCh <- state +} + +func (d *DualShock4) GetDescriptor() *usb.Descriptor { + return &d.descriptor +} + +func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { + var res map[string]any + d.mtx.Lock() + defer d.mtx.Unlock() + + bytes, err := json.Marshal(d.metaState) + if err != nil { + return map[string]any{} + } + err = json.Unmarshal(bytes, &res) + if err != nil { + return map[string]any{} + } + res["speakerInterfaceActive"] = d.speakerInterfaceActive + res["microphoneInterfaceActive"] = d.microphoneInterfaceActive + microphoneState := d.microphoneBuffer.State() + res["queuedMicrophoneBytes"] = microphoneState.QueuedBytes + res["microphoneQueueTargetBytes"] = microphoneState.TargetBytes + res["microphoneQueueMaximumBytes"] = microphoneState.MaximumBytes + res["microphoneFilteredQueueBytes"] = microphoneState.FilteredBytes + res["microphoneQueuePrimed"] = microphoneState.Primed + res["microphoneUnderruns"] = microphoneState.Underruns + res["microphoneReprimes"] = microphoneState.Reprimes + res["microphoneDroppedBytes"] = microphoneState.DroppedBytes + res["microphonePacketsRead"] = microphoneState.PacketsRead + res["microphoneZeroPackets"] = microphoneState.ZeroPackets + res["microphoneOverflowEvents"] = microphoneState.OverflowEvents + res["microphoneShortPackets"] = microphoneState.ShortPackets + res["microphoneLongPackets"] = microphoneState.LongPackets + res["microphoneServoRatePPM"] = microphoneState.ServoRatePPM + res["microphoneLowWaterBytes"] = microphoneState.LowWaterBytes + res["microphoneHighWaterBytes"] = microphoneState.HighWaterBytes + res["microphoneQueueFrames"] = microphoneState.QueueFrames + res["microphoneQueueFastGaps"] = microphoneState.QueueFastGaps + res["microphoneQueueLateGaps"] = microphoneState.QueueLateGaps + res["microphoneQueueMinGapUS"] = microphoneState.QueueMinGapUS + res["microphoneQueueMaxGapUS"] = microphoneState.QueueMaxGapUS + res["microphoneReadFastGaps"] = microphoneState.ReadFastGaps + res["microphoneReadLateGaps"] = microphoneState.ReadLateGaps + res["microphoneReadMinGapUS"] = microphoneState.ReadMinGapUS + res["microphoneReadMaxGapUS"] = microphoneState.ReadMaxGapUS + return res +} + +func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { + d.mtx.Lock() + var resetSpeaker func() + switch iface { + case InterfaceSpeaker: + wasActive := d.speakerInterfaceActive + d.speakerInterfaceActive = alt != 0 + if wasActive != d.speakerInterfaceActive { + resetSpeaker = d.speakerResetFunc + } + case InterfaceMicrophone: + wasActive := d.microphoneInterfaceActive + d.microphoneInterfaceActive = alt != 0 + if wasActive != d.microphoneInterfaceActive { + d.microphoneBuffer.Reset() + d.drainMicrophoneSignal() + } + } + d.mtx.Unlock() + + // The transport reset may wait for an in-flight socket write. Never hold the + // device mutex across that wait: output and USB teardown callbacks also need + // to acquire it before the writer can finish shutting down. + if resetSpeaker != nil { + resetSpeaker() + } +} + +// ResetEndpoint implements usb.EndpointResetDevice. CLEAR_FEATURE(HALT) +// preserves the selected alternate setting while establishing a hard data +// generation boundary for the affected audio pipe. +func (d *DualShock4) ResetEndpoint(endpoint uint8) { + d.mtx.Lock() + var resetSpeaker func() + switch endpoint { + case EndpointAudioOut: + resetSpeaker = d.speakerResetFunc + case EndpointMicrophoneIn: + d.microphoneBuffer.Reset() + d.drainMicrophoneSignal() + } + d.mtx.Unlock() + + if resetSpeaker != nil { + resetSpeaker() + } +} + +func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + epNumber := ep & 0x0F + if dir == usbip.DirIn { + switch epNumber { + case 4: + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + d.mtx.Lock() + is := d.inputState + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(is, &ms) + } + return nil + case is := <-d.inputCh: + d.mtx.Lock() + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(is, &ms) + } + case EndpointMicrophoneIn & 0x0F: + return d.handleMicrophoneIn(ctx) + default: + return nil + } + } + + if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { + if len(out) >= 11 && out[0] == ReportIDOutput { + feedback := parseOutputReport(out) + d.mtx.Lock() + d.outputState = feedback + d.outputSeen = true + outputFunc := d.outputFunc + d.mtx.Unlock() + if outputFunc != nil { + outputFunc(feedback) + } + } + } + if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { + d.mtx.Lock() + if d.speakerInterfaceActive && d.speakerFunc != nil && len(out) > 0 { + // The USB/IP receive buffer is owned by the transfer handler. Give the + // device-stream writer an immutable copy; its owned enqueue path then + // forwards this same allocation without making a second copy. Complete + // the synchronous enqueue under the device lock so a subsequent + // interface or endpoint reset cannot flush the queue and then be raced + // by a pre-reset callback publishing stale PCM afterward. + d.speakerFunc(append([]byte(nil), out...)) + } + d.mtx.Unlock() + return nil + } + + return nil +} + +func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { + if len(frame) != USBMicrophoneClientFrameSize { + return + } + + d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.mtx.Unlock() + return + } + + d.microphoneBuffer.QueueFrame(frame) + d.mtx.Unlock() + + select { + case d.microphoneSignal <- struct{}{}: + default: + } +} + +// ResetMicrophonePCM clears capture transport state after the current API +// stream ends. The API generation coordinator suppresses this reset when that +// stream was displaced by a same-device replacement. +func (d *DualShock4) ResetMicrophonePCM() { + d.mtx.Lock() + d.microphoneBuffer.Reset() + d.drainMicrophoneSignal() + d.mtx.Unlock() +} + +func (d *DualShock4) handleMicrophoneIn(ctx context.Context) []byte { + packet := make([]byte, USBMicrophoneMaxPacketSize) + for { + d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + d.mtx.Unlock() + return packet[:actualLength] + } + d.mtx.Unlock() + + select { + case <-ctx.Done(): + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + case <-d.microphoneSignal: + case <-time.After(time.Millisecond): + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + } +} + +func (d *DualShock4) drainMicrophoneSignal() { + for { + select { + case <-d.microphoneSignal: + default: + return + } + } +} + +func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { + if response, handled := handleAudioControlRequest(bmRequestType, bRequest, wValue, wIndex, wLength); handled { + return response, true + } + + reportType := uint8(wValue >> 8) + reportID := uint8(wValue & 0xFF) + + switch bmRequestType { + case hidClassIN: + switch bRequest { + case hidGetReport: + if reportType == reportTypeInput && reportID == ReportIDInput { + d.mtx.Lock() + is := *d.inputState + ms := *d.metaState + d.mtx.Unlock() + b := d.buildUSBInputReport(&is, &ms) + if wLength > 0 && int(wLength) < len(b) { + b = b[:wLength] + } + return b, true + } + if reportType == reportTypeFeature { + if fn, ok := featureGetHandlers[reportID]; ok { + b := fn(d) + if wLength > 0 && int(wLength) < len(b) { + b = b[:wLength] + } + return b, true + } + } + case hidGetIdle: + return []byte{0x00}, true + case hidGetProtocol: + return []byte{0x01}, true + case 0x81: + return []byte{0x00}, true + case 0x82, 0x83, 0x84: + return []byte{0x00, 0x00}, true + } + case hidClassOUT: + if bRequest == hidSetReport { + switch { + case reportType == reportTypeFeature && reportID == featureIDSubcommand: + if len(data) >= 2 { + d.telemetrySubcommand = data[1] + } + return nil, true + case reportType == reportTypeFeature && reportID == featureIDProbe: + if len(data) >= 4 { + d.probeSelector[0] = data[1] + d.probeSelector[1] = data[2] + d.probeSelector[2] = data[3] + } + return nil, true + case reportType == reportTypeOutput && reportID == ReportIDOutput && len(data) >= 11: + feedback := parseOutputReport(data) + d.mtx.Lock() + d.outputState = feedback + d.outputSeen = true + outputFunc := d.outputFunc + d.mtx.Unlock() + if outputFunc != nil { + outputFunc(feedback) + } + return nil, true + } + } + } + + slog.Warn("DS4 control request unhandled", + "bmRequestType", bmRequestType, + "bRequest", bRequest, + "reportType", reportType, + "reportID", reportID, + "wIndex", wIndex, + "wLength", wLength, + "dataLen", len(data)) + + return nil, false +} + +const ( + audioClassRequestSetCurrent = 0x01 + audioClassRequestGetCurrent = 0x81 + audioClassRequestGetMinimum = 0x82 + audioClassRequestGetMaximum = 0x83 + audioClassRequestGetResolution = 0x84 + + audioClassEndpointOut = 0x22 + audioClassEndpointIn = 0xA2 + + audioControlSamplingFrequency = 0x01 +) + +// handleAudioControlRequest implements the endpoint sampling-frequency +// controls advertised by the real CUH-ZCT2 UAC1 descriptor. Windows validates +// these requests before opening the render and capture endpoints. +func handleAudioControlRequest(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16) ([]byte, bool) { + endpoint := uint8(wIndex) + if (endpoint != EndpointAudioOut && endpoint != EndpointMicrophoneIn) || + uint8(wValue>>8) != audioControlSamplingFrequency { + return nil, false + } + + var sampleRate int + switch endpoint { + case EndpointAudioOut: + sampleRate = USBSpeakerSampleRate + case EndpointMicrophoneIn: + sampleRate = USBMicrophoneSampleRate + } + + switch bmRequestType { + case audioClassEndpointIn: + switch bRequest { + case audioClassRequestGetCurrent, audioClassRequestGetMinimum, audioClassRequestGetMaximum: + response := []byte{byte(sampleRate), byte(sampleRate >> 8), byte(sampleRate >> 16)} + if wLength < uint16(len(response)) { + response = response[:wLength] + } + return response, true + case audioClassRequestGetResolution: + response := []byte{0x00, 0x00, 0x00} + if wLength < uint16(len(response)) { + response = response[:wLength] + } + return response, true + } + case audioClassEndpointOut: + if bRequest == audioClassRequestSetCurrent { + return nil, true + } + } + + return nil, false +} + +// featureGetHandlers maps feature report IDs to their builder functions. +var featureGetHandlers = map[byte]func(*DualShock4) []byte{ + featureIDStatus: (*DualShock4).featureReportStatus, + featureIDProbeResponse: (*DualShock4).featureReportProbeResponse, + featureIDCalibration: (*DualShock4).featureReportCalibration, + featureIDCalibrationBT: (*DualShock4).featureReportCalibrationBT, + featureIDCapabilities: (*DualShock4).featureReportCapabilities, + featureIDSerial: (*DualShock4).featureReportSerial, + featureIDTelemetry: (*DualShock4).featureReportTelemetry, + featureIDIdentity: (*DualShock4).featureReportIdentity, + featureIDBoardInfo: (*DualShock4).featureReportBoardInfo, +} + +func parseOutputReport(data []byte) OutputState { + return OutputState{ + RumbleSmall: data[4], + RumbleLarge: data[5], + LedRed: data[6], + LedGreen: data[7], + LedBlue: data[8], + FlashOn: data[9], + FlashOff: data[10], + } +} + +func (d *DualShock4) featureReportTelemetry() []byte { + d.mtx.Lock() + defer d.mtx.Unlock() + + s := serialStringToBytes(d.metaState.SerialNumber) + switch d.telemetrySubcommand { + case 0x02: + return []byte{ + featureIDTelemetry, + s[3], s[2], s[1], s[0], s[7], s[6], s[5], s[4], + 0x00, 0x00, 0x00, 0x00, 0x00, + } + case 0x0B: + return []byte{ + featureIDTelemetry, + s[3], s[2], s[1], s[0], s[7], s[6], s[5], s[4], + 0xAC, 0xA8, 0x1B, + 0x00, 0x00, + } + default: + volts := telemetryVoltageU16(d.metaState.BatteryVoltage) + temp := telemetryTemperatureU16(d.metaState.TemperatureCelsius) + return []byte{ + featureIDTelemetry, d.telemetrySubcommand, 0x03, 0x01, 0x00, 0x04, + byte(volts), byte(volts >> 8), + byte(temp), byte(temp >> 8), + 0x00, 0x00, 0x00, 0x00, + } + } +} + +func (d *DualShock4) featureReportIdentity() []byte { + d.mtx.Lock() + defer d.mtx.Unlock() + serial := serialStringToBytes(d.metaState.SerialNumber) + firmware := ds4FirmwareVersionString() + + buildDateStr := d.metaState.BuildTime.Format("Jan 02 2006") + + report := make([]byte, 64) + report[0] = featureIDIdentity + copy(report[1:9], serial[:]) + copy(report[10:18], serial[:]) + copy(report[18:34], d.metaState.SerialNumber) + copy(report[34:46], d.metaState.Board) + copy(report[46:57], buildDateStr) + copy(report[57:64], firmware[:7]) + return report +} + +func (d *DualShock4) featureReportBoardInfo() []byte { + report := make([]byte, 49) + report[0] = featureIDBoardInfo + + d.mtx.Lock() + buildDateStr := d.metaState.BuildTime.Format("Jan 02 2006") + buildTimeStr := d.metaState.BuildTime.Format("15:04:05") + d.mtx.Unlock() + + copy(report[1:16], buildDateStr) + copy(report[16:32], buildTimeStr) + binary.LittleEndian.PutUint16(report[33:35], HardwareVersionMajor) + binary.LittleEndian.PutUint16(report[35:37], HardwareVersionMinor) + binary.LittleEndian.PutUint32(report[37:41], SoftwareVersionMajor) + binary.LittleEndian.PutUint16(report[41:43], SoftwareVersionMinor) + + report[47] = 1 + + return report +} + +func (d *DualShock4) featureReportSerial() []byte { + d.mtx.Lock() + serial := serialStringToBytes(d.metaState.SerialNumber) + d.mtx.Unlock() + + report := make([]byte, 16) + report[0] = featureIDSerial + report[1] = serial[7] + report[2] = serial[6] + report[3] = serial[5] + report[4] = serial[4] + report[5] = serial[3] + report[6] = serial[2] + report[7] = serial[1] + copy(report[8:16], serial[:]) + + return report +} + +func (d *DualShock4) featureReportStatus() []byte { + d.mtx.Lock() + defer d.mtx.Unlock() + report := make([]byte, 5) + report[0] = featureIDStatus + report[1] = d.metaState.BatteryStatus & BatteryLevelMask + report[2] = 12 + binary.LittleEndian.PutUint16(report[3:5], 664) + return report +} + +func (d *DualShock4) featureReportProbeResponse() []byte { + b1 := d.probeSelector[0] + b2 := d.probeSelector[1] + b3 := d.probeSelector[2] + + report := [4]byte{featureIDProbeResponse, b1, b2, b3} + + switch { + case b1 == 0xFF && b2 == 0x00 && b3 == 0x0C: + report[1] = 0x01 + } + + return report[:] +} + +func (d *DualShock4) featureReportCapabilities() []byte { + report := make([]byte, 48) + report[0] = featureIDCapabilities + report[2] = 0x27 + + // Sensor + lightbar + vibration + touchpad capability bits. + report[4] = 0x02 | 0x04 | 0x08 | 0x40 + report[5] = 0x00 // gamepad + + binary.LittleEndian.PutUint16(report[10:12], 1) + binary.LittleEndian.PutUint16(report[12:14], 16) + binary.LittleEndian.PutUint16(report[14:16], 1) + binary.LittleEndian.PutUint16(report[16:18], 8192) + + return report +} + +func (d *DualShock4) featureReportCalibration() []byte { + return d.buildCalibrationReport(featureIDCalibration) +} + +func (d *DualShock4) featureReportCalibrationBT() []byte { + return d.buildCalibrationReport(featureIDCalibrationBT) +} + +func (d *DualShock4) buildCalibrationReport(id byte) []byte { + report := make([]byte, 37) + report[0] = id + + // 17 LE int16 fields packed sequentially from offset 1: + // bias(pitch,yaw,roll) | gyro±(x,y,z) | speed(x,y) | accel±(x,y,z) + for i, v := range [17]int16{ + 0, 0, 0, + 1024, -1024, 1024, -1024, 1024, -1024, + 64, 64, + 8192, -8192, 8192, -8192, 8192, -8192, + } { + binary.LittleEndian.PutUint16(report[1+i*2:], uint16(v)) + } + + return report +} + +func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { + b := make([]byte, InputReportSize) + + b[0] = ReportIDInput + + b[1] = uint8(int16(s.LX) + 128) + b[2] = uint8(int16(s.LY) + 128) + b[3] = uint8(int16(s.RX) + 128) + b[4] = uint8(int16(s.RY) + 128) + + usbDPad := uint8(DPadUSBNeutral) + switch { + case s.DPad&DPadUp != 0 && s.DPad&DPadRight != 0: + usbDPad = DPadUSBUpRight + case s.DPad&DPadUp != 0 && s.DPad&DPadLeft != 0: + usbDPad = DPadUSBUpLeft + case s.DPad&DPadDown != 0 && s.DPad&DPadRight != 0: + usbDPad = DPadUSBDownRight + case s.DPad&DPadDown != 0 && s.DPad&DPadLeft != 0: + usbDPad = DPadUSBDownLeft + case s.DPad&DPadUp != 0: + usbDPad = DPadUSBUp + case s.DPad&DPadDown != 0: + usbDPad = DPadUSBDown + case s.DPad&DPadLeft != 0: + usbDPad = DPadUSBLeft + case s.DPad&DPadRight != 0: + usbDPad = DPadUSBRight + } + + b[5] = (usbDPad & DPadMask) | (uint8(s.Buttons) & 0xF0) + b[6] = uint8(s.Buttons >> 8) + + counter := atomic.AddUint32(&d.usbPacketCounter, 1) & 0x3F + + psTouch := uint8(0) + if s.Buttons&ButtonPS != 0 { + psTouch |= ButtonPSUSB + } + if s.Buttons&ButtonTouchpadClick != 0 { + psTouch |= ButtonTouchpadClickUSB + } + b[7] = psTouch | uint8(counter< 0 { + if err := usbip.ReadExactly(imp.Conn, data); err != nil { + return nil, err + } + } + _ = imp.Conn.SetDeadline(time.Time{}) + return data, nil + } + + pollInputReport := func(want []byte, timeout time.Duration) ([]byte, error) { + deadline := time.Now().Add(timeout) + var last []byte + for { + got, err := readInputReport(250 * time.Millisecond) + if err != nil { + return nil, err + } + last = got + if len(got) == len(want) { + gg := append([]byte(nil), got...) + ww := append([]byte(nil), want...) + gg[7] &= 0x03 + ww[7] &= 0x03 + gg[10], gg[11] = 0, 0 + ww[10], ww[11] = 0, 0 + if assert.ObjectsAreEqual(ww, gg) { + return got, nil + } + } + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + } + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dev, err := dualshock4.New(nil) + if !assert.NoError(t, err) { + return + } + dev.UpdateInputState(&tc.inputState) + built := dev.HandleTransfer(context.Background(), 4, usbip.DirIn, nil) + bb := append([]byte(nil), built...) + exp := append([]byte(nil), tc.expectedReport...) + bb[7] &= 0x03 + exp[7] &= 0x03 + bb[10], bb[11] = 0, 0 + exp[10], exp[11] = 0, 0 + assert.Equal(t, exp, bb) + + if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { + return + } + got, err := pollInputReport(tc.expectedReport, viiperTesting.IntegrationTimeout) + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, got, dualshock4.InputReportSize) { + return + } + gg := append([]byte(nil), got...) + gg[7] &= 0x03 + gg[10], gg[11] = 0, 0 + assert.Equal(t, exp, gg) + }) + } +} + +func TestFeedback(t *testing.T) { + type testCase struct { + name string + outputState dualshock4.OutputState + outPacket []byte + } + + cases := []testCase{ + { + name: "off", + outputState: dualshock4.OutputState{ + RumbleSmall: 0, + RumbleLarge: 0, + LedRed: 0, + LedGreen: 0, + LedBlue: 0, + FlashOn: 0, + FlashOff: 0, + }, + outPacket: []byte{0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "rumble + led + flash", + outputState: dualshock4.OutputState{ + RumbleSmall: 0x12, + RumbleLarge: 0xFE, + LedRed: 0x01, + LedGreen: 0x02, + LedBlue: 0x03, + FlashOn: 0x04, + FlashOff: 0x05, + }, + outPacket: []byte{0x05, 0x00, 0x00, 0x00, 0x12, 0xFE, 0x01, 0x02, 0x03, 0x04, 0x05}, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() //nolint:errcheck + defer s.ApiServer.Close() //nolint:errcheck + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() //nolint:errcheck + _ = s.UsbServer.AddBus(b) + + client := viiperclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "dualshock4", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() //nolint:errcheck + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, devs, 1) { + return + } + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if imp != nil && imp.Conn != nil { + defer imp.Conn.Close() //nolint:errcheck + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !assert.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 3, tc.outPacket, nil)) { + return + } + var buf [7]byte + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) + _, err := io.ReadFull(stream, buf[:]) + if !assert.NoError(t, err) { + return + } + got := dualshock4.OutputState{} + if !assert.NoError(t, got.UnmarshalBinary(buf[:])) { + return + } + assert.Equal(t, tc.outputState, got) + }) + } +} + +func TestOutputCallbackReplaysLatestHostFeedback(t *testing.T) { + dev, err := dualshock4.New(nil) + require.NoError(t, err) + + dev.HandleTransfer(context.Background(), 3, usbip.DirOut, + []byte{0x05, 0x00, 0x00, 0x00, 0x12, 0xFE, 0x01, 0x02, 0x03, 0x04, 0x05}) + + gotCh := make(chan dualshock4.OutputState, 1) + dev.SetOutputCallback(func(out dualshock4.OutputState) { + gotCh <- out + }) + + select { + case got := <-gotCh: + assert.Equal(t, dualshock4.OutputState{ + RumbleSmall: 0x12, + RumbleLarge: 0xFE, + LedRed: 0x01, + LedGreen: 0x02, + LedBlue: 0x03, + FlashOn: 0x04, + FlashOff: 0x05, + }, got) + case <-time.After(viiperTesting.IntegrationTimeout): + t.Fatal("expected late callback to receive latest host feedback") + } +} diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go new file mode 100644 index 00000000..e03e728d --- /dev/null +++ b/device/dualshock4/handler.go @@ -0,0 +1,576 @@ +package dualshock4 + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "hash/crc32" + "io" + "log/slog" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("dualshock4", &handler{}) + api.RegisterDevice("dualshock4micv2", &handler{ + microphoneInput: true, streamFrameVersion: StreamFrameVersionV2, + }) + api.RegisterDevice("dualshock4audioduplexv3", &handler{ + microphoneInput: true, speakerOutput: true, + streamFrameVersion: StreamFrameVersionV3, + }) + api.RegisterDevice("dualshock4audioonlyduplexv3", &handler{ + microphoneInput: true, speakerOutput: true, audioOnly: true, + streamFrameVersion: StreamFrameVersionV3, + }) +} + +type handler struct { + microphoneInput bool + speakerOutput bool + audioOnly bool + streamFrameVersion byte +} + +var serials = map[string]struct{}{} + +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + if o == nil { + o = &device.CreateOptions{} + } + + metaState := MetaState{} + if o.DeviceSpecific != "" { + if err := json.Unmarshal([]byte(o.DeviceSpecific), &metaState); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + } + serial := DefaultSerialString + if metaState.SerialNumber != "" { + serial = metaState.SerialNumber + } + serial = fmt.Sprintf("%016s", serial) + if _, ok := serials[serial]; ok { + for i := 1; i < 16; i++ { + newSerial := fmt.Sprintf("%s%02X", serial[:len(serial)-2], i) + if _, ok := serials[newSerial]; !ok { + serial = newSerial + break + } + } + } + metaState.SerialNumber = serial + serials[serial] = struct{}{} + b, err := json.Marshal(metaState) + if err != nil { + return nil, fmt.Errorf("marshal meta state: %w", err) + } + o.DeviceSpecific = string(b) + ds4, err := New(o) + if err != nil { + return nil, err + } + ds4.microphoneInput = h.microphoneInput + ds4.speakerOutput = h.speakerOutput + if h.audioOnly { + ds4.descriptor = makeAudioOnlyDescriptor() + } + ds4.streamFrameVersion = h.streamFrameVersion + return ds4, nil +} + +func (h *handler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + defer func() { + ds4, ok := (*devPtr).(*DualShock4) + if !ok { + slog.Warn("device is not DualShock4 on disconnect") + return + } + delete(serials, ds4.metaState.SerialNumber) + slog.Debug("DS4 disconnected, serial released", "serial", ds4.metaState.SerialNumber) + }() + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + ds4, ok := (*devPtr).(*DualShock4) + if !ok { + return fmt.Errorf("%w: expected DualShock4", device.ErrWrongDeviceType) + } + + microphoneInput := h.microphoneInput || ds4.microphoneInput + speakerOutput := h.speakerOutput || ds4.speakerOutput + streamFrameVersion := h.streamFrameVersion + if ds4.streamFrameVersion != 0 { + streamFrameVersion = ds4.streamFrameVersion + } + logger.Info("DualShock 4 input stream configured", + "microphoneInput", microphoneInput, + "speakerOutput", speakerOutput, + "frameVersion", streamFrameVersion) + + var writer *dualShock4OutputWriter + if speakerOutput && streamFrameVersion == StreamFrameVersionV3 { + writer = newDualShock4OutputWriter(conn, streamFrameVersion) + ds4.SetOutputCallback(func(feedback OutputState) { + data, err := feedback.MarshalBinary() + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + writer.EnqueueControl(StreamFrameOutputState, data) + }) + ds4.SetSpeakerCallback(func(pcm []byte) { + writer.EnqueueAudioOwned(StreamFrameSpeakerPCM, pcm) + }) + ds4.SetSpeakerResetCallback(writer.ResetSpeaker) + go writer.Run() + defer func() { + ds4.SetOutputCallback(nil) + ds4.SetSpeakerCallback(nil) + ds4.SetSpeakerResetCallback(nil) + writer.Stop() + }() + } else { + ds4.SetOutputCallback(func(feedback OutputState) { + data, err := feedback.MarshalBinary() + if err != nil { + logger.Error("failed to marshal feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send feedback", "error", err) + } + }) + defer ds4.SetOutputCallback(nil) + } + + return readDualShock4InputStream(conn, ds4, logger, + microphoneInput, streamFrameVersion) + } +} + +type dualShock4OutputFrame struct { + frameType byte + payload []byte + pooledBuffer *dualShock4AudioBuffer + audio bool + generation uint64 +} + +type dualShock4AudioBuffer struct { + data []byte +} + +const dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond + +// dualShock4OutputWriter keeps USB isochronous completion independent from +// local TCP backpressure. Control feedback and speaker PCM share one writer so +// their framing sequence is strictly monotonic and conn.Write is never raced. +type dualShock4OutputWriter struct { + conn net.Conn + version byte + control chan dualShock4OutputFrame + audio chan dualShock4OutputFrame + stop chan struct{} + done chan struct{} + stopOnce sync.Once + enqueueLock sync.RWMutex + audioWrite sync.Mutex + stopped bool + audioGeneration atomic.Uint64 + sequence uint32 + packet []byte + audioPool sync.Pool +} + +func newDualShock4OutputWriter(conn net.Conn, version byte) *dualShock4OutputWriter { + return &dualShock4OutputWriter{ + conn: conn, version: version, + control: make(chan dualShock4OutputFrame, 32), + audio: make(chan dualShock4OutputFrame, 256), + stop: make(chan struct{}), done: make(chan struct{}), + } +} + +func (w *dualShock4OutputWriter) EnqueueControl(frameType byte, payload []byte) { + if len(payload) == 0 { + return + } + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + w.enqueueFrameLocked(w.control, dualShock4OutputFrame{ + frameType: frameType, + payload: append([]byte(nil), payload...), + }) +} + +func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { + if len(payload) == 0 { + return + } + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + var buffer *dualShock4AudioBuffer + if value := w.audioPool.Get(); value != nil { + buffer = value.(*dualShock4AudioBuffer) + } else { + buffer = &dualShock4AudioBuffer{} + } + owned := buffer.data + if cap(owned) < len(payload) { + owned = make([]byte, len(payload)) + } else { + owned = owned[:len(payload)] + } + buffer.data = owned + copy(owned, payload) + frame := dualShock4OutputFrame{ + frameType: frameType, payload: owned, pooledBuffer: buffer, audio: true, + generation: w.audioGeneration.Load(), + } + if !w.enqueueFrameLocked(w.audio, frame) { + w.releaseAudioBuffer(buffer) + } +} + +// EnqueueAudioOwned accepts the immutable buffer transferred by DualShock4's +// USB/IP callback. Keeping ownership avoids copying the 10 ms PCM block twice. +func (w *dualShock4OutputWriter) EnqueueAudioOwned(frameType byte, payload []byte) { + if len(payload) == 0 { + return + } + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + w.enqueueFrameLocked(w.audio, dualShock4OutputFrame{ + frameType: frameType, payload: payload, audio: true, + generation: w.audioGeneration.Load(), + }) +} + +// enqueueFrameLocked requires enqueueLock to be held for reading. Reset and +// shutdown take the write side before draining, so a producer cannot publish a +// stale frame after the final empty-queue observation. +func (w *dualShock4OutputWriter) enqueueFrameLocked( + queue chan dualShock4OutputFrame, frame dualShock4OutputFrame) bool { + select { + case queue <- frame: + return true + default: + // Never block the USB/IP isochronous or HID callback. The receiver + // bounds its own latency too, so dropping newest under pathological + // backpressure is preferable to stalling the virtual USB device. + return false + } +} + +func (w *dualShock4OutputWriter) Run() { + defer func() { + w.requestStop() + w.drainAudioQueue() + close(w.done) + }() + for { + // Give feedback priority without starving speaker packets. + select { + case frame := <-w.control: + if !w.writeAndRelease(frame) { + return + } + continue + default: + } + + select { + case <-w.stop: + return + case frame := <-w.control: + if !w.writeAndRelease(frame) { + return + } + case frame := <-w.audio: + if !w.writeAndRelease(frame) { + return + } + } + } +} + +func (w *dualShock4OutputWriter) writeAndRelease(frame dualShock4OutputFrame) bool { + if frame.audio { + w.audioWrite.Lock() + defer w.audioWrite.Unlock() + if frame.generation != w.audioGeneration.Load() { + w.release(frame) + return true + } + } + + ok := w.write(frame) + w.release(frame) + return ok +} + +// ResetSpeaker advances the audio generation, drains every queued frame, and +// waits for an already-started write. Once it returns, no speaker PCM accepted +// before the interface or endpoint reset can appear on the client stream. +func (w *dualShock4OutputWriter) ResetSpeaker() { + w.enqueueLock.Lock() + w.audioGeneration.Add(1) + w.drainAudioQueue() + w.enqueueLock.Unlock() + + deadlineArmed := false + if w.conn != nil { + if err := w.conn.SetWriteDeadline( + time.Now().Add(dualShock4SpeakerResetWriteTimeout)); err != nil { + w.failStream() + } else { + deadlineArmed = true + } + } + + w.audioWrite.Lock() + if deadlineArmed { + w.clearWriteDeadlineIfViable() + } + w.audioWrite.Unlock() +} + +func (w *dualShock4OutputWriter) clearWriteDeadlineIfViable() { + w.enqueueLock.RLock() + if w.stopped { + w.enqueueLock.RUnlock() + return + } + err := w.conn.SetWriteDeadline(time.Time{}) + w.enqueueLock.RUnlock() + if err != nil { + w.failStream() + } +} + +func (w *dualShock4OutputWriter) drainAudioQueue() { + for { + select { + case frame := <-w.audio: + w.release(frame) + default: + return + } + } +} + +func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { + if len(frame.payload) > int(^uint16(0)) { + return true + } + packetLength := StreamFrameV2HeaderSize + len(frame.payload) + if cap(w.packet) < packetLength { + w.packet = make([]byte, packetLength) + } else { + w.packet = w.packet[:packetLength] + } + header := w.packet[:StreamFrameV2HeaderSize] + header[0] = StreamFrameMagic0 + header[1] = StreamFrameMagic1 + header[2] = StreamFrameMagic2 + header[3] = StreamFrameMagic3 + header[4] = w.version + header[5] = frame.frameType + binary.LittleEndian.PutUint16(header[6:8], uint16(len(frame.payload))) + binary.LittleEndian.PutUint32(header[8:12], w.sequence) + w.sequence++ + binary.LittleEndian.PutUint32(header[12:16], + dualShock4FramedStreamCRC(header[4:12], frame.payload)) + copy(w.packet[StreamFrameV2HeaderSize:], frame.payload) + remaining := w.packet + for len(remaining) > 0 { + n, err := w.conn.Write(remaining) + if err != nil || n <= 0 { + w.failStream() + return false + } + remaining = remaining[n:] + } + return true +} + +func (w *dualShock4OutputWriter) failStream() { + w.requestStop() + if w.conn != nil { + _ = w.conn.Close() + } +} + +func (w *dualShock4OutputWriter) release(frame dualShock4OutputFrame) { + if frame.pooledBuffer != nil { + w.releaseAudioBuffer(frame.pooledBuffer) + } +} + +func (w *dualShock4OutputWriter) releaseAudioBuffer(buffer *dualShock4AudioBuffer) { + buffer.data = buffer.data[:0] + w.audioPool.Put(buffer) +} + +func (w *dualShock4OutputWriter) Stop() { + w.requestStop() + _ = w.conn.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) + select { + case <-w.done: + case <-time.After(300 * time.Millisecond): + } +} + +func (w *dualShock4OutputWriter) requestStop() { + w.stopOnce.Do(func() { + w.enqueueLock.Lock() + w.stopped = true + close(w.stop) + w.enqueueLock.Unlock() + }) +} + +func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, + logger *slog.Logger, microphoneInput bool, frameVersion byte) error { + if !microphoneInput { + buf := make([]byte, InputStateSize) + for { + if _, err := io.ReadFull(conn, buf); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read input state: %w", err) + } + + var state InputState + if err := state.UnmarshalBinary(buf); err != nil { + return fmt.Errorf("unmarshal input state: %w", err) + } + ds4.UpdateInputState(&state) + } + } + + if frameVersion != StreamFrameVersionV2 && + frameVersion != StreamFrameVersionV3 { + return fmt.Errorf("unsupported DualShock 4 framed stream version 0x%02X", + frameVersion) + } + + header := make([]byte, StreamFrameV2HeaderSize) + input := make([]byte, InputStateSize) + microphonePCM := make([]byte, USBMicrophoneClientFrameSize) + var expectedSequence uint32 + sequenceInitialized := false + for { + if _, err := io.ReadFull(conn, header); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read DualShock 4 stream frame header: %w", err) + } + + if header[0] != StreamFrameMagic0 || header[1] != StreamFrameMagic1 || + header[2] != StreamFrameMagic2 || header[3] != StreamFrameMagic3 { + return fmt.Errorf("invalid DualShock 4 framed stream magic %02X %02X %02X %02X", + header[0], header[1], header[2], header[3]) + } + if header[4] != frameVersion { + return fmt.Errorf("unsupported DualShock 4 framed stream version 0x%02X", + header[4]) + } + + frameType := header[5] + payloadLen := int(binary.LittleEndian.Uint16(header[6:8])) + var payload []byte + switch frameType { + case StreamFrameInputState: + if payloadLen != InputStateSize { + return fmt.Errorf("invalid framed DualShock 4 input state length %d", + payloadLen) + } + payload = input + case StreamFrameMicrophonePCM: + if payloadLen != USBMicrophoneClientFrameSize { + return fmt.Errorf("invalid DualShock 4 microphone pcm frame length %d", + payloadLen) + } + payload = microphonePCM + default: + return fmt.Errorf("unknown DualShock 4 framed stream packet type 0x%02X length %d", + frameType, payloadLen) + } + + if _, err := io.ReadFull(conn, payload); err != nil { + return fmt.Errorf("read DualShock 4 framed packet type 0x%02X: %w", + frameType, err) + } + + sequence := binary.LittleEndian.Uint32(header[8:12]) + if sequenceInitialized && sequence != expectedSequence { + return fmt.Errorf("DualShock 4 framed stream sequence mismatch: got %d expected %d", + sequence, expectedSequence) + } + expectedSequence = sequence + 1 + sequenceInitialized = true + + receivedCRC := binary.LittleEndian.Uint32(header[12:16]) + calculatedCRC := dualShock4FramedStreamCRC(header[4:12], payload) + if receivedCRC != calculatedCRC { + return fmt.Errorf("DualShock 4 framed stream CRC mismatch for sequence %d: got %08X expected %08X", + sequence, receivedCRC, calculatedCRC) + } + + switch frameType { + case StreamFrameInputState: + var state InputState + if err := state.UnmarshalBinary(input); err != nil { + return fmt.Errorf("unmarshal framed DualShock 4 input state: %w", err) + } + ds4.UpdateInputState(&state) + case StreamFrameMicrophonePCM: + ds4.QueueMicrophonePCMFrame(microphonePCM) + } + } +} + +func dualShock4FramedStreamCRC(headerFields, payload []byte) uint32 { + hash := crc32.NewIEEE() + _, _ = hash.Write(headerFields) + _, _ = hash.Write(payload) + return hash.Sum32() +} + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + ds4, ok := (*dev).(*DualShock4) + if !ok { + return fmt.Errorf("%w: expected DualShock4", device.ErrWrongDeviceType) + } + var metaState MetaState + err := json.Unmarshal([]byte(meta), &metaState) + if err != nil { + return fmt.Errorf("unmarshal meta state: %w", err) + } + ds4.SetMetaState(metaState) + + return nil +} diff --git a/device/dualshock4/helpers.go b/device/dualshock4/helpers.go new file mode 100644 index 00000000..a20201c7 --- /dev/null +++ b/device/dualshock4/helpers.go @@ -0,0 +1,69 @@ +package dualshock4 + +import ( + "encoding/hex" + "fmt" + "math" +) + +// GyroDpsToRaw converts a gyro angular velocity value in degrees/second (°/s) +// into the fixed-point raw int16 wire/report representation. +func GyroDpsToRaw(dps float64) int16 { + return int16(min(max(math.Round(dps*GyroCountsPerDps), math.MinInt16), math.MaxInt16)) +} + +// GyroRawToDps converts a fixed-point raw gyro value into degrees/second (°/s). +func GyroRawToDps(raw int16) float64 { + return float64(raw) / GyroCountsPerDps +} + +// AccelMS2ToRaw converts an acceleration value in meters/second^2 (m/s²) +// into the fixed-point raw int16 wire/report representation. +func AccelMS2ToRaw(ms2 float64) int16 { + return int16(min(max(math.Round(ms2*AccelCountsPerMS2), math.MinInt16), math.MaxInt16)) +} + +// AccelRawToMS2 converts a fixed-point raw accelerometer value into m/s². +func AccelRawToMS2(raw int16) float64 { + return float64(raw) / AccelCountsPerMS2 +} + +// DefaultAccelRaw returns the default ("neutral") accelerometer vector for a +// controller lying flat on a table. +func DefaultAccelRaw() (x, y, z int16) { + return DefaultAccelXRaw, DefaultAccelYRaw, DefaultAccelZRaw +} + +func ds4FirmwareVersionString() string { + return fmt.Sprintf("%04X.%04X", uint16(SoftwareVersionMajor), SoftwareVersionMinor) +} + +func telemetryVoltageU16(voltage float64) uint16 { + raw := int(math.Round(voltage * 1000.0 / 1.5625)) + raw = min(max(raw, 0), 0xFFF) + return uint16(raw) +} + +func telemetryTemperatureU16(temperature float64) uint16 { + return uint16(min(max(math.Round((2470.0-temperature*26.0)/0.78125), 0), 4095)) +} + +func encodeTouchCoords(b []byte, x, y uint16) { + x = min(x, TouchpadMaxX) + y = min(y, TouchpadMaxY) + b[0] = uint8(x & 0xFF) + b[1] = uint8((x>>8)&0x0F) | uint8((y&0x0F)<<4) + b[2] = uint8(y >> 4) +} + +func serialStringToBytes(str string) [8]byte { + var out [8]byte + if len(str) != 16 { + return out + } + n, err := hex.Decode(out[:], []byte(str)) + if err != nil || n != len(out) { + return out + } + return out +} diff --git a/device/dualshock4/state.go b/device/dualshock4/state.go new file mode 100644 index 00000000..ab58151f --- /dev/null +++ b/device/dualshock4/state.go @@ -0,0 +1,192 @@ +package dualshock4 + +import ( + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "time" +) + +// nolint +// viiper:wire dualshock4 c2s stickLX:i8 stickLY:i8 stickRX:i8 stickRY:i8 buttons:u16 dpad:u8 triggerL2:u8 triggerR2:u8 touch1X:u16 touch1Y:u16 touch1Active:bool touch2X:u16 touch2Y:u16 touch2Active:bool gyroX:i16 gyroY:i16 gyroZ:i16 accelX:i16 accelY:i16 accelZ:i16 +type InputState struct { + LX, LY int8 + RX, RY int8 + Buttons uint16 + DPad uint8 + L2, R2 uint8 + + Touch1X, Touch1Y uint16 + Touch1Active bool + Touch2X, Touch2Y uint16 + Touch2Active bool + + GyroX, GyroY, GyroZ int16 + AccelX, AccelY, AccelZ int16 +} + +// NewInputState returns a DualShock 4 input state in its neutral/resting state. +func NewInputState() *InputState { + x, y, z := DefaultAccelRaw() + return &InputState{ + AccelX: x, + AccelY: y, + AccelZ: z, + } +} + +func (s *InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, 31) + b[0] = uint8(s.LX) + b[1] = uint8(s.LY) + b[2] = uint8(s.RX) + b[3] = uint8(s.RY) + binary.LittleEndian.PutUint16(b[4:6], s.Buttons) + b[6] = s.DPad + b[7] = s.L2 + b[8] = s.R2 + binary.LittleEndian.PutUint16(b[9:11], s.Touch1X) + binary.LittleEndian.PutUint16(b[11:13], s.Touch1Y) + if s.Touch1Active { + b[13] = 1 + } else { + b[13] = 0 + } + binary.LittleEndian.PutUint16(b[14:16], s.Touch2X) + binary.LittleEndian.PutUint16(b[16:18], s.Touch2Y) + if s.Touch2Active { + b[18] = 1 + } else { + b[18] = 0 + } + binary.LittleEndian.PutUint16(b[19:21], uint16(s.GyroX)) + binary.LittleEndian.PutUint16(b[21:23], uint16(s.GyroY)) + binary.LittleEndian.PutUint16(b[23:25], uint16(s.GyroZ)) + binary.LittleEndian.PutUint16(b[25:27], uint16(s.AccelX)) + binary.LittleEndian.PutUint16(b[27:29], uint16(s.AccelY)) + binary.LittleEndian.PutUint16(b[29:31], uint16(s.AccelZ)) + return b, nil +} + +func (s *InputState) UnmarshalBinary(data []byte) error { + if len(data) < 31 { + return io.ErrUnexpectedEOF + } + s.LX = int8(data[0]) + s.LY = int8(data[1]) + s.RX = int8(data[2]) + s.RY = int8(data[3]) + s.Buttons = binary.LittleEndian.Uint16(data[4:6]) + s.DPad = data[6] + s.L2 = data[7] + s.R2 = data[8] + s.Touch1X = binary.LittleEndian.Uint16(data[9:11]) + s.Touch1Y = binary.LittleEndian.Uint16(data[11:13]) + s.Touch1Active = data[13] != 0 + s.Touch2X = binary.LittleEndian.Uint16(data[14:16]) + s.Touch2Y = binary.LittleEndian.Uint16(data[16:18]) + s.Touch2Active = data[18] != 0 + s.GyroX = int16(binary.LittleEndian.Uint16(data[19:21])) + s.GyroY = int16(binary.LittleEndian.Uint16(data[21:23])) + s.GyroZ = int16(binary.LittleEndian.Uint16(data[23:25])) + s.AccelX = int16(binary.LittleEndian.Uint16(data[25:27])) + s.AccelY = int16(binary.LittleEndian.Uint16(data[27:29])) + s.AccelZ = int16(binary.LittleEndian.Uint16(data[29:31])) + return nil +} + +// nolint +// viiper:wire dualshock4 s2c rumbleSmall:u8 rumbleLarge:u8 ledRed:u8 ledGreen:u8 ledBlue:u8 flashOn:u8 flashOff:u8 +type OutputState struct { + RumbleSmall uint8 // (0-255) + RumbleLarge uint8 // (0-255) + LedRed uint8 // (0-255) + LedGreen uint8 // (0-255) + LedBlue uint8 // (0-255) + FlashOn uint8 // (units of 2.5ms) + FlashOff uint8 // (units of 2.5ms) +} + +func (f *OutputState) MarshalBinary() ([]byte, error) { + return []byte{ + f.RumbleSmall, + f.RumbleLarge, + f.LedRed, + f.LedGreen, + f.LedBlue, + f.FlashOn, + f.FlashOff, + }, nil +} + +func (f *OutputState) UnmarshalBinary(data []byte) error { + if len(data) < 7 { + return io.ErrUnexpectedEOF + } + f.RumbleSmall = data[0] + f.RumbleLarge = data[1] + f.LedRed = data[2] + f.LedGreen = data[3] + f.LedBlue = data[4] + f.FlashOn = data[5] + f.FlashOff = data[6] + return nil +} + +type MetaState struct { + SerialNumber string `json:"serial_number"` + Board string `json:"board"` + BuildTime time.Time `json:"build_time"` + + BatteryStatus byte `json:"battery_status"` + TemperatureCelsius float64 `json:"temperature_celsius"` + BatteryVoltage float64 `json:"battery_voltage"` +} + +func (m *MetaState) ToMap() map[string]any { + bytes, err := json.Marshal(m) + if err != nil { + slog.Error("marshal meta state for map", "error", err) + return map[string]any{} + } + var res map[string]any + err = json.Unmarshal(bytes, &res) + if err != nil { + slog.Error("unmarshal meta state for map", "error", err) + return map[string]any{} + } + return res +} + +func (m *MetaState) UpdateFromMap(data map[string]any) { + bytes, err := json.Marshal(data) + if err != nil { + slog.Error("marshal meta state for update", "error", err) + return + } + var newMeta MetaState + err = json.Unmarshal(bytes, &newMeta) + if err != nil { + slog.Error("unmarshal meta state for update", "error", err) + return + } + if newMeta.SerialNumber != "" { + m.SerialNumber = newMeta.SerialNumber + } + if newMeta.Board != "" { + m.Board = newMeta.Board + } + if !newMeta.BuildTime.IsZero() { + m.BuildTime = newMeta.BuildTime + } + if newMeta.BatteryStatus != 0 { + m.BatteryStatus = newMeta.BatteryStatus + } + if newMeta.TemperatureCelsius != 0 { + m.TemperatureCelsius = newMeta.TemperatureCelsius + } + if newMeta.BatteryVoltage != 0 { + m.BatteryVoltage = newMeta.BatteryVoltage + } +} diff --git a/device/errors.go b/device/errors.go new file mode 100644 index 00000000..1aab6ca1 --- /dev/null +++ b/device/errors.go @@ -0,0 +1,5 @@ +package device + +import "errors" + +var ErrWrongDeviceType = errors.New("wrong device type") diff --git a/device/internal/microphonebuffer/buffer.go b/device/internal/microphonebuffer/buffer.go new file mode 100644 index 00000000..1c42f9ef --- /dev/null +++ b/device/internal/microphonebuffer/buffer.go @@ -0,0 +1,406 @@ +// Package microphonebuffer provides the small PCM jitter buffer shared by +// VIIPER's virtual controller microphone endpoints. +package microphonebuffer + +import "time" + +// State is a point-in-time snapshot of a Buffer. +type State struct { + QueuedBytes int + TargetBytes int + MaximumBytes int + FilteredBytes int + Primed bool + Underruns uint64 + Reprimes uint64 + DroppedBytes uint64 + PacketsRead uint64 + ZeroPackets uint64 + OverflowEvents uint64 + ShortPackets uint64 + LongPackets uint64 + ServoRatePPM int + LowWaterBytes int + HighWaterBytes int + QueueFrames uint64 + QueueFastGaps uint64 + QueueLateGaps uint64 + QueueMinGapUS int64 + QueueMaxGapUS int64 + ReadFastGaps uint64 + ReadLateGaps uint64 + ReadMinGapUS int64 + ReadMaxGapUS int64 +} + +// Buffer stores complete client PCM frames and emits one USB isochronous +// packet at a time. It is intentionally not internally synchronized; each +// controller already protects it with the device mutex. +type Buffer struct { + data []byte + head int + size int + packetSize int + pcmFrameSize int + frameSize int + targetSize int + recoverySize int + queueCenter int + lowRailSize int + highRailSize int + nominalPacketFrames int + filteredQueueQ16 int64 + filterInitialized bool + servoAccumulator int64 + servoRatePPM int + + primed bool + needsReprime bool + underruns uint64 + reprimes uint64 + droppedBytes uint64 + packetsRead uint64 + zeroPackets uint64 + overflowEvents uint64 + shortPackets uint64 + longPackets uint64 + lowWaterBytes int + highWaterBytes int + queueFrames uint64 + queueFastGaps uint64 + queueLateGaps uint64 + queueMinGap time.Duration + queueMaxGap time.Duration + readFastGaps uint64 + readLateGaps uint64 + readMinGap time.Duration + readMaxGap time.Duration + lastQueueTime time.Time + lastReadTime time.Time +} + +const ( + occupancyFilterShift = 6 // EWMA alpha = 1/64 at the 1 ms USB cadence. + servoGainPPMPerMS = 2000 + servoLimitPPM = 10000 + servoPulseScale = 1000000 + recoveryClientFrames = 2 +) + +// New creates a bounded PCM buffer. targetFrames is the number of complete +// client frames required before initial capture starts. Recovery after a true +// underrun uses a smaller two-frame phase cushion; explicit Reset returns to +// the full initial target. maximumFrames is the hard latency bound; overflow +// always discards the oldest audio so the endpoint presents the freshest +// capture data. +func New(packetSize, pcmFrameSize, frameSize, targetFrames, maximumFrames int) Buffer { + if packetSize <= 0 || pcmFrameSize <= 0 || + packetSize%pcmFrameSize != 0 || frameSize <= 0 || + frameSize%packetSize != 0 { + panic("microphonebuffer: invalid PCM, packet, or client frame size") + } + if targetFrames <= 0 || maximumFrames < targetFrames { + panic("microphonebuffer: invalid target or maximum frame count") + } + + capacity := frameSize * maximumFrames + return Buffer{ + data: make([]byte, capacity), + packetSize: packetSize, + pcmFrameSize: pcmFrameSize, + frameSize: frameSize, + targetSize: frameSize * targetFrames, + recoverySize: frameSize * min(targetFrames, recoveryClientFrames), + queueCenter: frameSize*targetFrames - frameSize/2, + lowRailSize: max(pcmFrameSize, + frameSize*targetFrames-frameSize*3/2), + highRailSize: min(capacity-pcmFrameSize, + frameSize*targetFrames+frameSize/2), + nominalPacketFrames: packetSize / pcmFrameSize, + lowWaterBytes: -1, + } +} + +// QueueFrame appends one complete client PCM frame. It returns false for an +// invalid frame length and leaves the buffer unchanged. +func (b *Buffer) QueueFrame(frame []byte) bool { + if len(frame) != b.frameSize { + return false + } + b.recordQueueTime(time.Now()) + + if overflow := b.size + len(frame) - len(b.data); overflow > 0 { + b.discard(overflow) + b.droppedBytes += uint64(overflow) + b.overflowEvents++ + } + + b.write(frame) + primeSize := b.targetSize + if b.needsReprime { + primeSize = b.recoverySize + } + if !b.primed && b.size >= primeSize { + b.primed = true + if b.needsReprime { + b.reprimes++ + b.needsReprime = false + } + } + b.recordWatermark() + + return true +} + +// ReadPacket copies one adaptive USB packet into dst and returns its actual +// byte length. Packets contain exactly one fewer, the nominal number, or one +// additional interleaved PCM sample-frame. USB Audio accepts these variable +// isochronous packet lengths to reconcile the source and host clocks without +// resampling or dropping waveform samples. dst is never modified on failure. +func (b *Buffer) ReadPacket(dst []byte) (int, bool) { + if len(dst) < b.packetSize+b.pcmFrameSize { + return 0, false + } + if !b.primed { + return 0, false + } + + actualSize := b.nextPacketSize() + if b.size < actualSize { + // USB Audio accepts the nominal packet and one fewer PCM sample-frame. + // Use the largest legal packet still available instead of turning a + // single clock-phase deficit into a capture gap. Packet accounting is + // committed only afterward so servo telemetry describes what reached the + // host and any unserved long-packet correction remains owed. + shortSize := b.packetSize - b.pcmFrameSize + if b.size >= b.packetSize { + actualSize = b.packetSize + } else if b.size >= shortSize { + actualSize = shortSize + } else { + b.primed = false + b.needsReprime = true + b.underruns++ + // Every normal write/read is PCM-frame aligned. Preserve any valid + // tail instead of discarding fresh capture; defensively trim only a + // malformed partial sample-frame from the logical tail. + b.size -= b.size % b.pcmFrameSize + if b.size == 0 { + b.head = 0 + } + b.resetServo() + b.recordWatermark() + return 0, false + } + } + + b.commitPacketSize(actualSize) + b.read(dst[:actualSize]) + b.recordReadTime(time.Now()) + b.packetsRead++ + b.recordWatermark() + return actualSize, true +} + +// RecordZeroPacket records a zero-filled packet actually returned to the USB +// host. Failed internal read attempts are deliberately not counted here. +func (b *Buffer) RecordZeroPacket() { + b.zeroPackets++ +} + +// Reset discards queued PCM and returns to initial priming. Lifetime telemetry +// remains intact so interface close/open cycles do not erase diagnostics. +func (b *Buffer) Reset() { + b.head = 0 + b.size = 0 + b.primed = false + b.needsReprime = false + b.resetServo() + b.lastQueueTime = time.Time{} + b.lastReadTime = time.Time{} + b.recordWatermark() +} + +// State returns buffer depth, policy, and lifetime recovery telemetry. +func (b *Buffer) State() State { + return State{ + QueuedBytes: b.size, + TargetBytes: b.targetSize, + MaximumBytes: len(b.data), + FilteredBytes: b.filteredQueueBytes(), + Primed: b.primed, + Underruns: b.underruns, + Reprimes: b.reprimes, + DroppedBytes: b.droppedBytes, + PacketsRead: b.packetsRead, + ZeroPackets: b.zeroPackets, + OverflowEvents: b.overflowEvents, + ShortPackets: b.shortPackets, + LongPackets: b.longPackets, + ServoRatePPM: b.servoRatePPM, + LowWaterBytes: b.lowWaterBytes, + HighWaterBytes: b.highWaterBytes, + QueueFrames: b.queueFrames, + QueueFastGaps: b.queueFastGaps, + QueueLateGaps: b.queueLateGaps, + QueueMinGapUS: b.queueMinGap.Microseconds(), + QueueMaxGapUS: b.queueMaxGap.Microseconds(), + ReadFastGaps: b.readFastGaps, + ReadLateGaps: b.readLateGaps, + ReadMinGapUS: b.readMinGap.Microseconds(), + ReadMaxGapUS: b.readMaxGap.Microseconds(), + } +} + +func (b *Buffer) recordQueueTime(now time.Time) { + b.queueFrames++ + if !b.lastQueueTime.IsZero() { + gap := now.Sub(b.lastQueueTime) + if b.queueMinGap == 0 || gap < b.queueMinGap { + b.queueMinGap = gap + } + if gap > b.queueMaxGap { + b.queueMaxGap = gap + } + if gap < 5*time.Millisecond { + b.queueFastGaps++ + } + if gap > 15*time.Millisecond { + b.queueLateGaps++ + } + } + b.lastQueueTime = now +} + +func (b *Buffer) recordReadTime(now time.Time) { + if !b.lastReadTime.IsZero() { + gap := now.Sub(b.lastReadTime) + if b.readMinGap == 0 || gap < b.readMinGap { + b.readMinGap = gap + } + if gap > b.readMaxGap { + b.readMaxGap = gap + } + if gap < 500*time.Microsecond { + b.readFastGaps++ + } + if gap > 1500*time.Microsecond { + b.readLateGaps++ + } + } + b.lastReadTime = now +} + +func (b *Buffer) nextPacketSize() int { + // Observe the queue at the common post-service point. At exact 10 ms input + // and 1 ms USB output cadence this removes the nominal packet phase offset, + // so the controller does not manufacture corrections for a healthy clock. + projectedSize := max(0, b.size-b.packetSize) + currentQ16 := int64(projectedSize) << 16 + if !b.filterInitialized { + // Begin at the desired clock point so initial target priming does not + // immediately request long packets before the filter observes a trend. + b.filteredQueueQ16 = int64(b.queueCenter) << 16 + b.filterInitialized = true + } + b.filteredQueueQ16 += (currentQ16 - b.filteredQueueQ16) >> occupancyFilterShift + + filtered := b.filteredQueueBytes() + bytesPerMillisecond := max(1, b.frameSize/10) + deadband := bytesPerMillisecond + ratePPM := 0 + switch { + case filtered < b.lowRailSize: + ratePPM = -servoLimitPPM + case filtered > b.highRailSize: + ratePPM = servoLimitPPM + case filtered > b.queueCenter+deadband: + ratePPM = (filtered - b.queueCenter - deadband) * servoGainPPMPerMS / + bytesPerMillisecond + case filtered < b.queueCenter-deadband: + ratePPM = -((b.queueCenter - deadband - filtered) * servoGainPPMPerMS / + bytesPerMillisecond) + } + if ratePPM > servoLimitPPM { + ratePPM = servoLimitPPM + } else if ratePPM < -servoLimitPPM { + ratePPM = -servoLimitPPM + } + b.servoRatePPM = ratePPM + b.servoAccumulator += int64(ratePPM * b.nominalPacketFrames) + + packetSize := b.packetSize + if b.servoAccumulator >= servoPulseScale { + packetSize += b.pcmFrameSize + } else if b.servoAccumulator <= -servoPulseScale { + packetSize -= b.pcmFrameSize + } + return packetSize +} + +func (b *Buffer) commitPacketSize(packetSize int) { + switch packetSize { + case b.packetSize - b.pcmFrameSize: + // A short packet consumes one fewer sample-frame than nominal, whether + // selected by the servo or used as the starvation fallback. + b.servoAccumulator += servoPulseScale + b.shortPackets++ + case b.packetSize + b.pcmFrameSize: + b.servoAccumulator -= servoPulseScale + b.longPackets++ + } +} + +func (b *Buffer) filteredQueueBytes() int { + if !b.filterInitialized { + return 0 + } + return int((b.filteredQueueQ16 + 1<<15) >> 16) +} + +func (b *Buffer) resetServo() { + b.filteredQueueQ16 = 0 + b.filterInitialized = false + b.servoAccumulator = 0 + b.servoRatePPM = 0 +} + +func (b *Buffer) recordWatermark() { + if !b.primed { + return + } + if b.lowWaterBytes < 0 || b.size < b.lowWaterBytes { + b.lowWaterBytes = b.size + } + if b.size > b.highWaterBytes { + b.highWaterBytes = b.size + } +} + +func (b *Buffer) write(src []byte) { + tail := (b.head + b.size) % len(b.data) + first := min(len(src), len(b.data)-tail) + copy(b.data[tail:tail+first], src[:first]) + copy(b.data, src[first:]) + b.size += len(src) +} + +func (b *Buffer) read(dst []byte) { + first := min(len(dst), len(b.data)-b.head) + copy(dst[:first], b.data[b.head:b.head+first]) + copy(dst[first:], b.data[:len(dst)-first]) + b.discard(len(dst)) +} + +func (b *Buffer) discard(count int) { + if count <= 0 { + return + } + if count >= b.size { + b.head = 0 + b.size = 0 + return + } + b.head = (b.head + count) % len(b.data) + b.size -= count +} diff --git a/device/internal/microphonebuffer/buffer_test.go b/device/internal/microphonebuffer/buffer_test.go new file mode 100644 index 00000000..1d9262c4 --- /dev/null +++ b/device/internal/microphonebuffer/buffer_test.go @@ -0,0 +1,497 @@ +package microphonebuffer + +import ( + "bytes" + "testing" +) + +func readNominalPacket(buffer *Buffer, dst []byte) bool { + packet := make([]byte, buffer.packetSize+buffer.pcmFrameSize) + actual, ok := buffer.ReadPacket(packet) + if ok { + copy(dst, packet[:min(actual, len(dst))]) + } + return ok +} + +func TestBufferWaitsForTargetBeforeEmittingPCM(t *testing.T) { + buffer := New(2, 1, 4, 3, 4) + dst := []byte{0xAA, 0xAA} + + for value := byte(1); value <= 2; value++ { + if !buffer.QueueFrame(bytes.Repeat([]byte{value}, 4)) { + t.Fatal("valid frame was rejected") + } + if readNominalPacket(&buffer, dst) { + t.Fatalf("PCM emitted before target after frame %d", value) + } + if !bytes.Equal(dst, []byte{0xAA, 0xAA}) { + t.Fatalf("failed read modified destination: % x", dst) + } + } + + buffer.QueueFrame(bytes.Repeat([]byte{3}, 4)) + if !readNominalPacket(&buffer, dst) { + t.Fatal("PCM did not start at target depth") + } + if !bytes.Equal(dst, []byte{1, 1}) { + t.Fatalf("unexpected first packet: % x", dst) + } + + state := buffer.State() + if !state.Primed || state.QueuedBytes != 10 || state.TargetBytes != 12 || state.MaximumBytes != 16 { + t.Fatalf("unexpected state after prime: %+v", state) + } +} + +func TestBufferUnderrunUsesBoundedRecoveryPrime(t *testing.T) { + buffer := New(2, 1, 4, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 4)) + } + + dst := make([]byte, 2) + for packet := 0; packet < 6; packet++ { + if !readNominalPacket(&buffer, dst) { + t.Fatalf("buffer starved while draining primed packet %d", packet) + } + } + if readNominalPacket(&buffer, dst) { + t.Fatal("empty buffer emitted PCM") + } + if readNominalPacket(&buffer, dst) { + t.Fatal("starved buffer emitted PCM twice") + } + state := buffer.State() + if state.Underruns != 1 || state.Reprimes != 0 || state.Primed { + t.Fatalf("unexpected underrun state: %+v", state) + } + + buffer.QueueFrame(bytes.Repeat([]byte{4}, 4)) + if readNominalPacket(&buffer, dst) { + t.Fatal("PCM resumed before the recovery cushion was rebuilt") + } + buffer.QueueFrame(bytes.Repeat([]byte{5}, 4)) + if !readNominalPacket(&buffer, dst) || !bytes.Equal(dst, []byte{4, 4}) { + t.Fatalf("unexpected packet after reprime: % x", dst) + } + state = buffer.State() + if state.Underruns != 1 || state.Reprimes != 1 || !state.Primed { + t.Fatalf("unexpected reprime state: %+v", state) + } +} + +func TestBufferFallsBackToLegalShortPacketBeforeUnderrun(t *testing.T) { + buffer := New(8, 2, 16, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 16)) + } + buffer.discard(buffer.size - 6) + + packet := bytes.Repeat([]byte{0xAA}, 10) + actual, ok := buffer.ReadPacket(packet) + if !ok || actual != 6 { + t.Fatalf("expected legal six-byte short packet, got len=%d ok=%t state=%+v", + actual, ok, buffer.State()) + } + if !bytes.Equal(packet[:actual], bytes.Repeat([]byte{3}, actual)) { + t.Fatalf("short packet changed PCM: % x", packet[:actual]) + } + if !bytes.Equal(packet[actual:], bytes.Repeat([]byte{0xAA}, len(packet)-actual)) { + t.Fatalf("short read modified bytes beyond actual length: % x", packet) + } + + state := buffer.State() + if state.Underruns != 0 || state.ShortPackets != 1 || + state.PacketsRead != 1 || !state.Primed { + t.Fatalf("short fallback was counted as an underrun: %+v", state) + } +} + +func TestBufferFallsBackFromLongToNominalAndKeepsServoDebt(t *testing.T) { + buffer := New(8, 2, 16, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 16)) + } + buffer.discard(buffer.size - 8) + buffer.servoAccumulator = servoPulseScale + + packet := make([]byte, 10) + actual, ok := buffer.ReadPacket(packet) + if !ok || actual != 8 { + t.Fatalf("expected nominal fallback from unavailable long packet, got len=%d ok=%t", + actual, ok) + } + state := buffer.State() + if state.LongPackets != 0 || state.ShortPackets != 0 || state.Underruns != 0 { + t.Fatalf("nominal fallback was accounted as a different packet: %+v", state) + } + if buffer.servoAccumulator < servoPulseScale { + t.Fatalf("unserved long-packet correction was lost: accumulator=%d", + buffer.servoAccumulator) + } +} + +func TestBufferTrueUnderrunRetainsAlignedTail(t *testing.T) { + buffer := New(8, 2, 16, 3, 4) + residual := []byte{0xA1, 0xA2, 0xA3, 0xA4} + buffer.write(residual) + buffer.primed = true + + packet := bytes.Repeat([]byte{0xCC}, 10) + actual, ok := buffer.ReadPacket(packet) + if ok || actual != 0 { + t.Fatalf("sub-short tail unexpectedly emitted len=%d ok=%t", actual, ok) + } + if !bytes.Equal(packet, bytes.Repeat([]byte{0xCC}, len(packet))) { + t.Fatalf("failed read modified destination: % x", packet) + } + state := buffer.State() + if state.QueuedBytes != len(residual) || state.Primed || state.Underruns != 1 { + t.Fatalf("true underrun discarded aligned PCM: %+v", state) + } + if _, ok := buffer.ReadPacket(packet); ok { + t.Fatal("unprimed recovery buffer unexpectedly emitted PCM") + } + state = buffer.State() + if state.Underruns != 1 || state.ZeroPackets != 0 { + t.Fatalf("internal retry corrupted underrun/zero telemetry: %+v", state) + } + buffer.RecordZeroPacket() + if state = buffer.State(); state.ZeroPackets != 1 { + t.Fatalf("host-visible zero packet was not counted: %+v", state) + } + + firstFrame := bytes.Repeat([]byte{0xB1}, 16) + secondFrame := bytes.Repeat([]byte{0xB2}, 16) + buffer.QueueFrame(firstFrame) + if buffer.State().Primed { + t.Fatal("one client frame did not provide the two-frame recovery cushion") + } + buffer.QueueFrame(secondFrame) + state = buffer.State() + if !state.Primed || state.Reprimes != 1 { + t.Fatalf("two client frames did not complete bounded recovery: %+v", state) + } + + actual, ok = buffer.ReadPacket(packet) + if !ok || actual != 8 { + t.Fatalf("recovered buffer did not emit nominal PCM: len=%d ok=%t", actual, ok) + } + want := append(append([]byte(nil), residual...), firstFrame[:4]...) + if !bytes.Equal(packet[:actual], want) { + t.Fatalf("recovery did not preserve PCM order: got % x want % x", + packet[:actual], want) + } +} + +func TestBufferRecoveryCushionDoesNotImmediatelyUnderrunAgain(t *testing.T) { + tests := []struct { + name string + packetSize int + pcmFrameSize int + clientSize int + }{ + {name: "DS4", packetSize: 32, pcmFrameSize: 2, clientSize: 320}, + {name: "DualSense", packetSize: 192, pcmFrameSize: 4, clientSize: 1920}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buffer := New(test.packetSize, test.pcmFrameSize, test.clientSize, 6, 20) + residualSize := test.packetSize - 2*test.pcmFrameSize + buffer.write(bytes.Repeat([]byte{0x61}, residualSize)) + buffer.primed = true + packet := make([]byte, test.packetSize+test.pcmFrameSize) + if _, ok := buffer.ReadPacket(packet); ok { + t.Fatal("sub-short tail unexpectedly emitted") + } + + frame := bytes.Repeat([]byte{0x62}, test.clientSize) + buffer.QueueFrame(frame) + if buffer.State().Primed { + t.Fatal("recovery resumed with only one client frame") + } + buffer.QueueFrame(frame) + if !buffer.State().Primed { + t.Fatal("recovery did not resume with two client frames") + } + + for millisecond := 0; millisecond < 5000; millisecond++ { + if (millisecond+1)%10 == 0 { + buffer.QueueFrame(frame) + } + actual, ok := buffer.ReadPacket(packet) + if !ok { + t.Fatalf("recovered stream immediately re-underran at %d ms: %+v", + millisecond, buffer.State()) + } + if actual != test.packetSize-test.pcmFrameSize && + actual != test.packetSize && + actual != test.packetSize+test.pcmFrameSize { + t.Fatalf("recovery emitted illegal packet length %d", actual) + } + } + + state := buffer.State() + if state.Underruns != 1 || state.Reprimes != 1 || !state.Primed { + t.Fatalf("recovery entered an underrun loop: %+v", state) + } + }) + } +} + +func TestBufferOverflowKeepsNewestFrames(t *testing.T) { + buffer := New(2, 1, 4, 3, 4) + for value := byte(0); value < 6; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 4)) + } + + state := buffer.State() + if state.QueuedBytes != 16 || state.DroppedBytes != 8 { + t.Fatalf("unexpected bounded queue state: %+v", state) + } + + dst := make([]byte, 2) + if !readNominalPacket(&buffer, dst) || !bytes.Equal(dst, []byte{2, 2}) { + t.Fatalf("queue did not retain newest PCM: % x", dst) + } +} + +func TestBufferResetPreservesCountersAndRequiresInitialPrime(t *testing.T) { + buffer := New(2, 1, 4, 3, 4) + for value := byte(1); value <= 3; value++ { + buffer.QueueFrame(bytes.Repeat([]byte{value}, 4)) + } + dst := make([]byte, 2) + for range 6 { + if !readNominalPacket(&buffer, dst) { + t.Fatal("primed buffer starved before the reset setup underrun") + } + } + if readNominalPacket(&buffer, dst) { + t.Fatal("empty buffer emitted PCM") + } + + buffer.QueueFrame([]byte{4, 4, 4, 4}) + buffer.QueueFrame([]byte{5, 5, 5, 5}) + if buffer.State().Reprimes != 1 { + t.Fatal("expected recovery before reset") + } + buffer.Reset() + + state := buffer.State() + if state.QueuedBytes != 0 || state.Primed || state.Underruns != 1 || state.Reprimes != 1 { + t.Fatalf("unexpected reset state: %+v", state) + } + + buffer.QueueFrame([]byte{6, 6, 6, 6}) + buffer.QueueFrame([]byte{7, 7, 7, 7}) + if readNominalPacket(&buffer, dst) { + t.Fatal("explicit reset resumed at the smaller recovery threshold") + } + buffer.QueueFrame([]byte{8, 8, 8, 8}) + if !readNominalPacket(&buffer, dst) || !bytes.Equal(dst, []byte{6, 6}) { + t.Fatalf("explicit reset did not require a fresh full target: % x", dst) + } + if buffer.State().Reprimes != 1 { + t.Fatal("initial prime after reset was incorrectly counted as a reprime") + } +} + +func TestAdaptiveClockServoTracksIndependentSourceClocksWithoutLosingPCM(t *testing.T) { + tests := []struct { + name string + packetSize int + pcmFrameSize int + clientSize int + ppm int + }{ + {name: "DS4 source fast", packetSize: 32, pcmFrameSize: 2, clientSize: 320, ppm: 5000}, + {name: "DS4 source slow", packetSize: 32, pcmFrameSize: 2, clientSize: 320, ppm: -5000}, + {name: "DualSense source fast", packetSize: 192, pcmFrameSize: 4, clientSize: 1920, ppm: 5000}, + {name: "DualSense source slow", packetSize: 192, pcmFrameSize: 4, clientSize: 1920, ppm: -5000}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buffer := New(test.packetSize, test.pcmFrameSize, test.clientSize, 3, 4) + var sourceOffset uint64 + var outputOffset uint64 + queueFrame := func() { + frame := make([]byte, test.clientSize) + for index := range frame { + frame[index] = byte(sourceOffset%251 + 1) + sourceOffset++ + } + if !buffer.QueueFrame(frame) { + t.Fatal("valid source frame was rejected") + } + } + for range 3 { + queueFrame() + } + + packet := make([]byte, test.packetSize+test.pcmFrameSize) + var sourcePhase int64 + for millisecond := 0; millisecond < 120000; millisecond++ { + sourcePhase += int64(1000000 + test.ppm) + for sourcePhase >= 10000000 { + queueFrame() + sourcePhase -= 10000000 + } + + actual, ok := buffer.ReadPacket(packet) + if !ok { + t.Fatalf("buffer starved at %d ms: %+v", millisecond, buffer.State()) + } + if actual != test.packetSize-test.pcmFrameSize && + actual != test.packetSize && actual != test.packetSize+test.pcmFrameSize { + t.Fatalf("invalid adaptive packet length %d", actual) + } + for _, sampleByte := range packet[:actual] { + want := byte(outputOffset%251 + 1) + if sampleByte != want { + t.Fatalf("PCM changed at output byte %d: got %02x want %02x", + outputOffset, sampleByte, want) + } + outputOffset++ + } + } + + state := buffer.State() + if state.Underruns != 0 || state.Reprimes != 0 || + state.OverflowEvents != 0 || state.DroppedBytes != 0 { + t.Fatalf("clock servo touched a queue rail: %+v", state) + } + if test.ppm > 0 && state.LongPackets == 0 { + t.Fatalf("fast source never produced a long USB packet: %+v", state) + } + if test.ppm < 0 && state.ShortPackets == 0 { + t.Fatalf("slow source never produced a short USB packet: %+v", state) + } + }) + } +} + +func TestAdaptiveClockServoAbsorbsAlternatingClientJitter(t *testing.T) { + for _, intervals := range [][]int{{8, 12}, {9, 11}} { + buffer := New(32, 2, 320, 3, 4) + frame := bytes.Repeat([]byte{0x5A}, 320) + for range 3 { + buffer.QueueFrame(frame) + } + packet := make([]byte, 34) + intervalIndex := 0 + untilFrame := intervals[intervalIndex] + for millisecond := 0; millisecond < 120000; millisecond++ { + untilFrame-- + if untilFrame == 0 { + buffer.QueueFrame(frame) + intervalIndex = (intervalIndex + 1) % len(intervals) + untilFrame = intervals[intervalIndex] + } + if _, ok := buffer.ReadPacket(packet); !ok { + t.Fatalf("%v jitter starved at %d ms: %+v", intervals, millisecond, buffer.State()) + } + } + state := buffer.State() + if state.Underruns != 0 || state.OverflowEvents != 0 || state.DroppedBytes != 0 { + t.Fatalf("%v jitter touched a queue rail: %+v", intervals, state) + } + } +} + +func TestAdaptiveClockServoDoesNotCorrectAnExactClockAfterSettling(t *testing.T) { + buffer := New(32, 2, 320, 3, 4) + frame := bytes.Repeat([]byte{0x33}, 320) + for range 3 { + buffer.QueueFrame(frame) + } + packet := make([]byte, 34) + var settledShort uint64 + var settledLong uint64 + for millisecond := 0; millisecond < 120000; millisecond++ { + if (millisecond+1)%10 == 0 { + buffer.QueueFrame(frame) + } + if _, ok := buffer.ReadPacket(packet); !ok { + t.Fatalf("exact clock starved at %d ms: %+v", millisecond, buffer.State()) + } + if millisecond == 59999 { + state := buffer.State() + settledShort = state.ShortPackets + settledLong = state.LongPackets + } + } + state := buffer.State() + if state.ShortPackets != settledShort || state.LongPackets != settledLong { + t.Fatalf("exact clock kept correcting after settling: before=%d/%d after=%d/%d state=%+v", + settledShort, settledLong, state.ShortPackets, state.LongPackets, state) + } + if state.Underruns != 0 || state.OverflowEvents != 0 { + t.Fatalf("exact clock touched a queue rail: %+v", state) + } +} + +func TestProductionCushionAbsorbsFortyMillisecondProducerStalls(t *testing.T) { + tests := []struct { + name string + packetSize int + pcmFrameSize int + clientSize int + }{ + {name: "DS4", packetSize: 32, pcmFrameSize: 2, clientSize: 320}, + {name: "DualSense", packetSize: 192, pcmFrameSize: 4, clientSize: 1920}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buffer := New(test.packetSize, test.pcmFrameSize, test.clientSize, 6, 10) + frame := bytes.Repeat([]byte{0x66}, test.clientSize) + for range 6 { + buffer.QueueFrame(frame) + } + packet := make([]byte, test.packetSize+test.pcmFrameSize) + pendingFrames := 0 + for millisecond := 0; millisecond < 120000; millisecond++ { + if (millisecond+1)%10 == 0 { + pendingFrames++ + } + stallPhase := millisecond % 2000 + if !(stallPhase >= 1000 && stallPhase < 1040) { + for pendingFrames > 0 { + buffer.QueueFrame(frame) + pendingFrames-- + } + } + if _, ok := buffer.ReadPacket(packet); !ok { + t.Fatalf("40 ms producer stall starved at %d ms: %+v", + millisecond, buffer.State()) + } + } + state := buffer.State() + if state.Underruns != 0 || state.OverflowEvents != 0 || + state.DroppedBytes != 0 { + t.Fatalf("production cushion touched a rail: %+v", state) + } + }) + } +} + +func TestAdaptiveClockServoResetClearsControlState(t *testing.T) { + buffer := New(32, 2, 320, 3, 4) + frame := bytes.Repeat([]byte{0x44}, 320) + for range 4 { + buffer.QueueFrame(frame) + } + packet := make([]byte, 34) + for range 200 { + if _, ok := buffer.ReadPacket(packet); !ok { + break + } + } + buffer.Reset() + state := buffer.State() + if state.FilteredBytes != 0 || state.ServoRatePPM != 0 || state.Primed { + t.Fatalf("reset retained adaptive clock state: %+v", state) + } +} diff --git a/viiper/pkg/device/keyboard/codemap.go b/device/keyboard/const.go similarity index 56% rename from viiper/pkg/device/keyboard/codemap.go rename to device/keyboard/const.go index 566a395c..2570577a 100644 --- a/viiper/pkg/device/keyboard/codemap.go +++ b/device/keyboard/const.go @@ -1,160 +1,336 @@ -package keyboard - -// KeyName maps HID usage codes to human-readable key names. -var KeyName = map[uint8]string{ - // Letters - KeyA: "A", KeyB: "B", KeyC: "C", KeyD: "D", KeyE: "E", KeyF: "F", KeyG: "G", - KeyH: "H", KeyI: "I", KeyJ: "J", KeyK: "K", KeyL: "L", KeyM: "M", KeyN: "N", - KeyO: "O", KeyP: "P", KeyQ: "Q", KeyR: "R", KeyS: "S", KeyT: "T", KeyU: "U", - KeyV: "V", KeyW: "W", KeyX: "X", KeyY: "Y", KeyZ: "Z", - - // Numbers - Key1: "1", Key2: "2", Key3: "3", Key4: "4", Key5: "5", - Key6: "6", Key7: "7", Key8: "8", Key9: "9", Key0: "0", - - // Special keys - KeyEnter: "Enter", - KeyEscape: "Escape", - KeyBackspace: "Backspace", - KeyTab: "Tab", - KeySpace: "Space", - KeyMinus: "Minus", - KeyEqual: "Equal", - KeyLeftBrace: "LeftBrace", - KeyRightBrace: "RightBrace", - KeyBackslash: "Backslash", - KeySemicolon: "Semicolon", - KeyApostrophe: "Apostrophe", - KeyGrave: "Grave", - KeyComma: "Comma", - KeyPeriod: "Period", - KeySlash: "Slash", - KeyCapsLock: "CapsLock", - - // Function keys - KeyF1: "F1", KeyF2: "F2", KeyF3: "F3", KeyF4: "F4", KeyF5: "F5", KeyF6: "F6", - KeyF7: "F7", KeyF8: "F8", KeyF9: "F9", KeyF10: "F10", KeyF11: "F11", KeyF12: "F12", - KeyF13: "F13", KeyF14: "F14", KeyF15: "F15", KeyF16: "F16", KeyF17: "F17", KeyF18: "F18", - KeyF19: "F19", KeyF20: "F20", KeyF21: "F21", KeyF22: "F22", KeyF23: "F23", KeyF24: "F24", - - // Control keys - KeyPrintScreen: "PrintScreen", - KeyScrollLock: "ScrollLock", - KeyPause: "Pause", - KeyInsert: "Insert", - KeyHome: "Home", - KeyPageUp: "PageUp", - KeyDelete: "Delete", - KeyEnd: "End", - KeyPageDown: "PageDown", - - // Arrow keys - KeyRight: "Right", - KeyLeft: "Left", - KeyDown: "Down", - KeyUp: "Up", - - // Numpad - KeyNumLock: "NumLock", - KeyKpSlash: "Kp/", - KeyKpAsterisk: "Kp*", - KeyKpMinus: "Kp-", - KeyKpPlus: "Kp+", - KeyKpEnter: "KpEnter", - KeyKp1: "Kp1", - KeyKp2: "Kp2", - KeyKp3: "Kp3", - KeyKp4: "Kp4", - KeyKp5: "Kp5", - KeyKp6: "Kp6", - KeyKp7: "Kp7", - KeyKp8: "Kp8", - KeyKp9: "Kp9", - KeyKp0: "Kp0", - KeyKpDot: "Kp.", - - // Additional - KeyApplication: "Application", - KeyMute: "Mute", - KeyVolumeUp: "VolumeUp", - KeyVolumeDown: "VolumeDown", - - // Media control - KeyMediaPlayPause: "MediaPlayPause", - KeyMediaStop: "MediaStop", - KeyMediaNext: "MediaNext", - KeyMediaPrevious: "MediaPrevious", -} - -// CharToKey maps ASCII characters to their corresponding HID usage codes. -// For shifted characters (uppercase, symbols), use with NeedsShift(). -var CharToKey = map[byte]uint8{ - // Lowercase letters - 'a': KeyA, 'b': KeyB, 'c': KeyC, 'd': KeyD, 'e': KeyE, 'f': KeyF, 'g': KeyG, - 'h': KeyH, 'i': KeyI, 'j': KeyJ, 'k': KeyK, 'l': KeyL, 'm': KeyM, 'n': KeyN, - 'o': KeyO, 'p': KeyP, 'q': KeyQ, 'r': KeyR, 's': KeyS, 't': KeyT, 'u': KeyU, - 'v': KeyV, 'w': KeyW, 'x': KeyX, 'y': KeyY, 'z': KeyZ, - - // Uppercase letters (same keys, need shift) - 'A': KeyA, 'B': KeyB, 'C': KeyC, 'D': KeyD, 'E': KeyE, 'F': KeyF, 'G': KeyG, - 'H': KeyH, 'I': KeyI, 'J': KeyJ, 'K': KeyK, 'L': KeyL, 'M': KeyM, 'N': KeyN, - 'O': KeyO, 'P': KeyP, 'Q': KeyQ, 'R': KeyR, 'S': KeyS, 'T': KeyT, 'U': KeyU, - 'V': KeyV, 'W': KeyW, 'X': KeyX, 'Y': KeyY, 'Z': KeyZ, - - // Numbers (top row) - '1': Key1, '2': Key2, '3': Key3, '4': Key4, '5': Key5, - '6': Key6, '7': Key7, '8': Key8, '9': Key9, '0': Key0, - - // Shifted number row symbols - '!': Key1, '@': Key2, '#': Key3, '$': Key4, '%': Key5, - '^': Key6, '&': Key7, '*': Key8, '(': Key9, ')': Key0, - - // Unshifted symbols - '-': KeyMinus, - '=': KeyEqual, - '[': KeyLeftBrace, - ']': KeyRightBrace, - '\\': KeyBackslash, - ';': KeySemicolon, - '\'': KeyApostrophe, - '`': KeyGrave, - ',': KeyComma, - '.': KeyPeriod, - '/': KeySlash, - - // Shifted symbols - '_': KeyMinus, - '+': KeyEqual, - '{': KeyLeftBrace, - '}': KeyRightBrace, - '|': KeyBackslash, - ':': KeySemicolon, - '"': KeyApostrophe, - '~': KeyGrave, - '<': KeyComma, - '>': KeyPeriod, - '?': KeySlash, - - // Whitespace - ' ': KeySpace, - '\n': KeyEnter, - '\r': KeyEnter, - '\t': KeyTab, -} - -// ShiftChars defines which characters require the Shift modifier. -var ShiftChars = map[byte]bool{ - // Uppercase letters - 'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true, 'G': true, - 'H': true, 'I': true, 'J': true, 'K': true, 'L': true, 'M': true, 'N': true, - 'O': true, 'P': true, 'Q': true, 'R': true, 'S': true, 'T': true, 'U': true, - 'V': true, 'W': true, 'X': true, 'Y': true, 'Z': true, - - // Shifted number row - '!': true, '@': true, '#': true, '$': true, '%': true, - '^': true, '&': true, '*': true, '(': true, ')': true, - - // Shifted symbols - '_': true, '+': true, '{': true, '}': true, '|': true, - ':': true, '"': true, '~': true, '<': true, '>': true, '?': true, -} +package keyboard + +// Modifier key bitmasks +const ( + ModLeftCtrl = 0x01 + ModLeftShift = 0x02 + ModLeftAlt = 0x04 + ModLeftGUI = 0x08 // Windows/Command key + ModRightCtrl = 0x10 + ModRightShift = 0x20 + ModRightAlt = 0x40 + ModRightGUI = 0x80 +) + +// LED bitmasks +const ( + LEDNumLock = 0x01 + LEDCapsLock = 0x02 + LEDScrollLock = 0x04 + LEDCompose = 0x08 + LEDKana = 0x10 +) + +// HID Usage codes for keyboard keys (USB HID Keyboard/Keypad usage page) +const ( + // Letters A-Z + KeyA = 0x04 + KeyB = 0x05 + KeyC = 0x06 + KeyD = 0x07 + KeyE = 0x08 + KeyF = 0x09 + KeyG = 0x0A + KeyH = 0x0B + KeyI = 0x0C + KeyJ = 0x0D + KeyK = 0x0E + KeyL = 0x0F + KeyM = 0x10 + KeyN = 0x11 + KeyO = 0x12 + KeyP = 0x13 + KeyQ = 0x14 + KeyR = 0x15 + KeyS = 0x16 + KeyT = 0x17 + KeyU = 0x18 + KeyV = 0x19 + KeyW = 0x1A + KeyX = 0x1B + KeyY = 0x1C + KeyZ = 0x1D + + // Numbers 1-0 (top row) + Key1 = 0x1E + Key2 = 0x1F + Key3 = 0x20 + Key4 = 0x21 + Key5 = 0x22 + Key6 = 0x23 + Key7 = 0x24 + Key8 = 0x25 + Key9 = 0x26 + Key0 = 0x27 + + // Special keys + KeyEnter = 0x28 + KeyEscape = 0x29 + KeyBackspace = 0x2A + KeyTab = 0x2B + KeySpace = 0x2C + KeyMinus = 0x2D // - and _ + KeyEqual = 0x2E // = and + + KeyLeftBrace = 0x2F // [ and { + KeyRightBrace = 0x30 // ] and } + KeyBackslash = 0x31 // \ and | + KeyNonUSHash = 0x32 // Non-US # and ~ + KeySemicolon = 0x33 // ; and : + KeyApostrophe = 0x34 // ' and " + KeyGrave = 0x35 // ` and ~ + KeyComma = 0x36 // , and < + KeyPeriod = 0x37 // . and > + KeySlash = 0x38 // / and ? + KeyCapsLock = 0x39 + + // Function keys + KeyF1 = 0x3A + KeyF2 = 0x3B + KeyF3 = 0x3C + KeyF4 = 0x3D + KeyF5 = 0x3E + KeyF6 = 0x3F + KeyF7 = 0x40 + KeyF8 = 0x41 + KeyF9 = 0x42 + KeyF10 = 0x43 + KeyF11 = 0x44 + KeyF12 = 0x45 + + // Control keys + KeyPrintScreen = 0x46 + KeyScrollLock = 0x47 + KeyPause = 0x48 + KeyInsert = 0x49 + KeyHome = 0x4A + KeyPageUp = 0x4B + KeyDelete = 0x4C + KeyEnd = 0x4D + KeyPageDown = 0x4E + + // Arrow keys + KeyRight = 0x4F + KeyLeft = 0x50 + KeyDown = 0x51 + KeyUp = 0x52 + + // Numpad + KeyNumLock = 0x53 + KeyKpSlash = 0x54 // Keypad / + KeyKpAsterisk = 0x55 // Keypad * + KeyKpMinus = 0x56 // Keypad - + KeyKpPlus = 0x57 // Keypad + + KeyKpEnter = 0x58 // Keypad Enter + KeyKp1 = 0x59 // Keypad 1 and End + KeyKp2 = 0x5A // Keypad 2 and Down + KeyKp3 = 0x5B // Keypad 3 and PageDn + KeyKp4 = 0x5C // Keypad 4 and Left + KeyKp5 = 0x5D // Keypad 5 + KeyKp6 = 0x5E // Keypad 6 and Right + KeyKp7 = 0x5F // Keypad 7 and Home + KeyKp8 = 0x60 // Keypad 8 and Up + KeyKp9 = 0x61 // Keypad 9 and PageUp + KeyKp0 = 0x62 // Keypad 0 and Insert + KeyKpDot = 0x63 // Keypad . and Delete + + // Additional keys + KeyNonUSBackslash = 0x64 // Non-US \ and | + KeyApplication = 0x65 // Application (Windows Menu key) + KeyPower = 0x66 // Power (not commonly used) + KeyKpEqual = 0x67 // Keypad = + + // Extended function keys + KeyF13 = 0x68 + KeyF14 = 0x69 + KeyF15 = 0x6A + KeyF16 = 0x6B + KeyF17 = 0x6C + KeyF18 = 0x6D + KeyF19 = 0x6E + KeyF20 = 0x6F + KeyF21 = 0x70 + KeyF22 = 0x71 + KeyF23 = 0x72 + KeyF24 = 0x73 + + // Execution keys + KeyExecute = 0x74 + KeyHelp = 0x75 + KeyMenu = 0x76 + KeySelect = 0x77 + KeyStop = 0x78 + KeyAgain = 0x79 // Redo + KeyUndo = 0x7A + KeyCut = 0x7B + KeyCopy = 0x7C + KeyPaste = 0x7D + KeyFind = 0x7E + KeyMute = 0x7F + KeyVolumeUp = 0x80 + KeyVolumeDown = 0x81 + + // Media control keys + KeyMediaPlayPause = 0xE8 // Play/Pause + KeyMediaStop = 0xE9 // Stop + KeyMediaNext = 0xEB // Next Track + KeyMediaPrevious = 0xEC // Previous Track +) + +// KeyName maps HID usage codes to human-readable key names. +var KeyName = map[uint8]string{ + // Letters + KeyA: "A", KeyB: "B", KeyC: "C", KeyD: "D", KeyE: "E", KeyF: "F", KeyG: "G", + KeyH: "H", KeyI: "I", KeyJ: "J", KeyK: "K", KeyL: "L", KeyM: "M", KeyN: "N", + KeyO: "O", KeyP: "P", KeyQ: "Q", KeyR: "R", KeyS: "S", KeyT: "T", KeyU: "U", + KeyV: "V", KeyW: "W", KeyX: "X", KeyY: "Y", KeyZ: "Z", + + // Numbers + Key1: "1", Key2: "2", Key3: "3", Key4: "4", Key5: "5", + Key6: "6", Key7: "7", Key8: "8", Key9: "9", Key0: "0", + + // Special keys + KeyEnter: "Enter", + KeyEscape: "Escape", + KeyBackspace: "Backspace", + KeyTab: "Tab", + KeySpace: "Space", + KeyMinus: "Minus", + KeyEqual: "Equal", + KeyLeftBrace: "LeftBrace", + KeyRightBrace: "RightBrace", + KeyBackslash: "Backslash", + KeySemicolon: "Semicolon", + KeyApostrophe: "Apostrophe", + KeyGrave: "Grave", + KeyComma: "Comma", + KeyPeriod: "Period", + KeySlash: "Slash", + KeyCapsLock: "CapsLock", + + // Function keys + KeyF1: "F1", KeyF2: "F2", KeyF3: "F3", KeyF4: "F4", KeyF5: "F5", KeyF6: "F6", + KeyF7: "F7", KeyF8: "F8", KeyF9: "F9", KeyF10: "F10", KeyF11: "F11", KeyF12: "F12", + KeyF13: "F13", KeyF14: "F14", KeyF15: "F15", KeyF16: "F16", KeyF17: "F17", KeyF18: "F18", + KeyF19: "F19", KeyF20: "F20", KeyF21: "F21", KeyF22: "F22", KeyF23: "F23", KeyF24: "F24", + + // Control keys + KeyPrintScreen: "PrintScreen", + KeyScrollLock: "ScrollLock", + KeyPause: "Pause", + KeyInsert: "Insert", + KeyHome: "Home", + KeyPageUp: "PageUp", + KeyDelete: "Delete", + KeyEnd: "End", + KeyPageDown: "PageDown", + + // Arrow keys + KeyRight: "Right", + KeyLeft: "Left", + KeyDown: "Down", + KeyUp: "Up", + + // Numpad + KeyNumLock: "NumLock", + KeyKpSlash: "Kp/", + KeyKpAsterisk: "Kp*", + KeyKpMinus: "Kp-", + KeyKpPlus: "Kp+", + KeyKpEnter: "KpEnter", + KeyKp1: "Kp1", + KeyKp2: "Kp2", + KeyKp3: "Kp3", + KeyKp4: "Kp4", + KeyKp5: "Kp5", + KeyKp6: "Kp6", + KeyKp7: "Kp7", + KeyKp8: "Kp8", + KeyKp9: "Kp9", + KeyKp0: "Kp0", + KeyKpDot: "Kp.", + + // Additional + KeyApplication: "Application", + KeyMute: "Mute", + KeyVolumeUp: "VolumeUp", + KeyVolumeDown: "VolumeDown", + + // Media control + KeyMediaPlayPause: "MediaPlayPause", + KeyMediaStop: "MediaStop", + KeyMediaNext: "MediaNext", + KeyMediaPrevious: "MediaPrevious", +} + +// CharToKey maps ASCII characters to their corresponding HID usage codes. +// For shifted characters (uppercase, symbols), use with NeedsShift(). +var CharToKey = map[byte]uint8{ + // Lowercase letters + 'a': KeyA, 'b': KeyB, 'c': KeyC, 'd': KeyD, 'e': KeyE, 'f': KeyF, 'g': KeyG, + 'h': KeyH, 'i': KeyI, 'j': KeyJ, 'k': KeyK, 'l': KeyL, 'm': KeyM, 'n': KeyN, + 'o': KeyO, 'p': KeyP, 'q': KeyQ, 'r': KeyR, 's': KeyS, 't': KeyT, 'u': KeyU, + 'v': KeyV, 'w': KeyW, 'x': KeyX, 'y': KeyY, 'z': KeyZ, + + // Uppercase letters (same keys, need shift) + 'A': KeyA, 'B': KeyB, 'C': KeyC, 'D': KeyD, 'E': KeyE, 'F': KeyF, 'G': KeyG, + 'H': KeyH, 'I': KeyI, 'J': KeyJ, 'K': KeyK, 'L': KeyL, 'M': KeyM, 'N': KeyN, + 'O': KeyO, 'P': KeyP, 'Q': KeyQ, 'R': KeyR, 'S': KeyS, 'T': KeyT, 'U': KeyU, + 'V': KeyV, 'W': KeyW, 'X': KeyX, 'Y': KeyY, 'Z': KeyZ, + + // Numbers (top row) + '1': Key1, '2': Key2, '3': Key3, '4': Key4, '5': Key5, + '6': Key6, '7': Key7, '8': Key8, '9': Key9, '0': Key0, + + // Shifted number row symbols + '!': Key1, '@': Key2, '#': Key3, '$': Key4, '%': Key5, + '^': Key6, '&': Key7, '*': Key8, '(': Key9, ')': Key0, + + // Unshifted symbols + '-': KeyMinus, + '=': KeyEqual, + '[': KeyLeftBrace, + ']': KeyRightBrace, + '\\': KeyBackslash, + ';': KeySemicolon, + '\'': KeyApostrophe, + '`': KeyGrave, + ',': KeyComma, + '.': KeyPeriod, + '/': KeySlash, + + // Shifted symbols + '_': KeyMinus, + '+': KeyEqual, + '{': KeyLeftBrace, + '}': KeyRightBrace, + '|': KeyBackslash, + ':': KeySemicolon, + '"': KeyApostrophe, + '~': KeyGrave, + '<': KeyComma, + '>': KeyPeriod, + '?': KeySlash, + + // Whitespace + ' ': KeySpace, + '\n': KeyEnter, + '\r': KeyEnter, + '\t': KeyTab, +} + +// ShiftChars defines which characters require the Shift modifier. +var ShiftChars = map[byte]bool{ + // Uppercase letters + 'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true, 'G': true, + 'H': true, 'I': true, 'J': true, 'K': true, 'L': true, 'M': true, 'N': true, + 'O': true, 'P': true, 'Q': true, 'R': true, 'S': true, 'T': true, 'U': true, + 'V': true, 'W': true, 'X': true, 'Y': true, 'Z': true, + + // Shifted number row + '!': true, '@': true, '#': true, '$': true, '%': true, + '^': true, '&': true, '*': true, '(': true, ')': true, + + // Shifted symbols + '_': true, '+': true, '{': true, '}': true, '|': true, + ':': true, '"': true, '~': true, '<': true, '>': true, '?': true, +} diff --git a/device/keyboard/device.go b/device/keyboard/device.go new file mode 100644 index 00000000..babd17b3 --- /dev/null +++ b/device/keyboard/device.go @@ -0,0 +1,247 @@ +// Package keyboard provides a HID keyboard device implementation with full N-key rollover. +package keyboard + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" + "github.com/Alia5/VIIPER/usbip" +) + +// Keyboard implements the Device interface for a full HID keyboard with LED support. +type Keyboard struct { + tick uint64 + inputCh chan InputState + stateMu sync.Mutex + ledState uint8 + ledSeen bool + ledCallback func(LEDState) + descriptor usb.Descriptor +} + +// New returns a new Keyboard device. +func New(o *device.CreateOptions) (*Keyboard, error) { + d := &Keyboard{ + descriptor: defaultDescriptor, + } + if o != nil { + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor + } + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct + } + } + d.inputCh = make(chan InputState, 1) + d.inputCh <- *NewInputState() + return d, nil +} + +// SetLEDCallback sets a callback that will be invoked when LED state changes. +func (k *Keyboard) SetLEDCallback(f func(LEDState)) { + var latest LEDState + var replay bool + + k.stateMu.Lock() + k.ledCallback = f + if f != nil && k.ledSeen { + latest = ledStateFromMask(k.ledState) + replay = true + } + k.stateMu.Unlock() + + if replay { + f(latest) + } +} + +// GetLEDState returns the current LED state from the host. +func (k *Keyboard) GetLEDState() LEDState { + k.stateMu.Lock() + defer k.stateMu.Unlock() + return LEDState{ + NumLock: k.ledState&LEDNumLock != 0, + CapsLock: k.ledState&LEDCapsLock != 0, + ScrollLock: k.ledState&LEDScrollLock != 0, + Compose: k.ledState&LEDCompose != 0, + Kana: k.ledState&LEDKana != 0, + } +} + +// UpdateInputState updates the device's current input state (thread-safe). +func (k *Keyboard) UpdateInputState(state InputState) { + select { + case <-k.inputCh: + default: + } + k.inputCh <- state +} + +// HandleTransfer implements interrupt IN/OUT for Keyboard. +func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + if dir == usbip.DirIn { + switch ep { + case 1: // 0x81 - keyboard input reports + atomic.AddUint64(&k.tick, 1) + select { + case <-ctx.Done(): + return nil + case st := <-k.inputCh: + return st.BuildReport() + } + default: + return nil + } + } + if dir == usbip.DirOut && ep == 1 { + // 0x01 - LED state from host + if len(out) >= 1 { + ledState := ledStateFromMask(out[0]) + + k.stateMu.Lock() + k.ledState = out[0] + k.ledSeen = true + ledCallback := k.ledCallback + k.stateMu.Unlock() + + if ledCallback != nil { + ledCallback(ledState) + } + } + } + return nil +} + +func ledStateFromMask(mask uint8) LEDState { + return LEDState{ + NumLock: mask&LEDNumLock != 0, + CapsLock: mask&LEDCapsLock != 0, + ScrollLock: mask&LEDScrollLock != 0, + Compose: mask&LEDCompose != 0, + Kana: mask&LEDKana != 0, + } +} + +// HID Report Descriptor for a full keyboard with 256-bit key bitmap and LED output. +var reportDescriptor = hid.ReportDescriptor{ + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageKeyboard}, + hid.Collection{ + Kind: hid.CollectionApplication, + Items: []hid.Item{ + // Input Report: Modifiers (1 byte) + hid.UsagePage{Page: hid.UsagePageKeyboard}, + hid.UsageMinimum{Min: 0xE0}, // Left Control + hid.UsageMaximum{Max: 0xE7}, // Right GUI + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 8}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + // Input Report: Reserved byte (1 byte) + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainConst}, + + // Input Report: Key array bitmap (32 bytes = 256 bits) + hid.UsagePage{Page: hid.UsagePageKeyboard}, + hid.UsageMinimum{Min: 0x00}, + hid.UsageMaximum{Max: 0xFF}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 256}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + // Output Report: LEDs (1 byte) + hid.UsagePage{Page: hid.UsagePageLEDs}, + hid.UsageMinimum{Min: 0x01}, // Num Lock + hid.UsageMaximum{Max: 0x05}, // Kana + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 5}, + hid.Output{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportSize{Bits: 3}, + hid.ReportCount{Count: 1}, + hid.Output{Flags: hid.MainConst}, + }, + }, + }, +} + +// Descriptor defines the static USB descriptor for the keyboard. +var defaultDescriptor = usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0x00, + BDeviceSubClass: 0x00, + BDeviceProtocol: 0x00, + BMaxPacketSize0: 0x40, // 64 bytes + IDVendor: 0x2E8A, + IDProduct: 0x0010, + BcdDevice: 0x0100, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x03, + BNumConfigurations: 0x01, + Speed: 2, // Full speed + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0x03, // HID + BInterfaceSubClass: 0x00, // No Subclass + BInterfaceProtocol: 0x00, // None + IInterface: 0x00, + }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, // Length auto-filled from Report + }, + }, + ReportDescriptor: reportDescriptor, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: 0x81, + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 0x0040, + BInterval: 0x05, // 5 ms + }, + { + BEndpointAddress: 0x01, + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 0x0008, + BInterval: 0x05, // 5 ms + }, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\u0409", // LangID: en-US (0x0409) + 1: "VIIPER", + 2: "HID Keyboard", + 3: "1337", + }, +} + +func (k *Keyboard) GetDescriptor() *usb.Descriptor { + return &k.descriptor +} + +func (k *Keyboard) GetDeviceSpecificArgs() map[string]any { + return map[string]any{} +} diff --git a/viiper/pkg/device/keyboard/handler.go b/device/keyboard/handler.go similarity index 85% rename from viiper/pkg/device/keyboard/handler.go rename to device/keyboard/handler.go index 76537098..5ad11066 100644 --- a/viiper/pkg/device/keyboard/handler.go +++ b/device/keyboard/handler.go @@ -1,86 +1,91 @@ -package keyboard - -import ( - "fmt" - "io" - "log/slog" - "net" - - "viiper/internal/server/api" - "viiper/pkg/usb" -) - -func init() { - api.RegisterDevice("keyboard", &handler{}) -} - -type handler struct{} - -func (h *handler) CreateDevice() usb.Device { return New() } - -func (h *handler) StreamHandler() api.StreamHandlerFunc { - return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { - if devPtr == nil || *devPtr == nil { - return fmt.Errorf("nil device") - } - kdev, ok := (*devPtr).(*Keyboard) - if !ok { - return fmt.Errorf("device is not keyboard") - } - - // Set LED callback to write LED state to client - kdev.SetLEDCallback(func(led LEDState) { - ledByte := uint8(0) - if led.NumLock { - ledByte |= LEDNumLock - } - if led.CapsLock { - ledByte |= LEDCapsLock - } - if led.ScrollLock { - ledByte |= LEDScrollLock - } - if led.Compose { - ledByte |= LEDCompose - } - if led.Kana { - ledByte |= LEDKana - } - if _, err := conn.Write([]byte{ledByte}); err != nil { - logger.Warn("failed to write LED state", "error", err) - } - }) - - // Read loop: Client → Device (key presses) - for { - // Read header (2 bytes minimum: modifiers + key count) - header := make([]byte, 2) - if _, err := io.ReadFull(conn, header); err != nil { - if err == io.EOF { - logger.Info("client disconnected") - return nil - } - return fmt.Errorf("read header: %w", err) - } - - keyCount := header[1] - - // Read key codes - keys := make([]byte, keyCount) - if keyCount > 0 { - if _, err := io.ReadFull(conn, keys); err != nil { - return fmt.Errorf("read keys: %w", err) - } - } - - // Build full packet and unmarshal - fullPacket := append(header, keys...) - var state InputState - if err := state.UnmarshalBinary(fullPacket); err != nil { - return fmt.Errorf("unmarshal input state: %w", err) - } - - kdev.UpdateInputState(state) - } - } -} +package keyboard + +import ( + "fmt" + "io" + "log/slog" + "net" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("keyboard", &handler{}) +} + +type handler struct{} + +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } + +func (h *handler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + kdev, ok := (*devPtr).(*Keyboard) + if !ok { + return fmt.Errorf("device is not keyboard") + } + + // Set LED callback to write LED state to client + kdev.SetLEDCallback(func(led LEDState) { + ledByte := uint8(0) + if led.NumLock { + ledByte |= LEDNumLock + } + if led.CapsLock { + ledByte |= LEDCapsLock + } + if led.ScrollLock { + ledByte |= LEDScrollLock + } + if led.Compose { + ledByte |= LEDCompose + } + if led.Kana { + ledByte |= LEDKana + } + if _, err := conn.Write([]byte{ledByte}); err != nil { + logger.Warn("failed to write LED state", "error", err) + } + }) + + // Read loop: Client → Device (key presses) + for { + // Read header (2 bytes minimum: modifiers + key count) + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read header: %w", err) + } + + keyCount := header[1] + + // Read key codes + keys := make([]byte, keyCount) + if keyCount > 0 { + if _, err := io.ReadFull(conn, keys); err != nil { + return fmt.Errorf("read keys: %w", err) + } + } + + // Build full packet and unmarshal + fullPacket := append(header, keys...) + var state InputState + if err := state.UnmarshalBinary(fullPacket); err != nil { + return fmt.Errorf("unmarshal input state: %w", err) + } + + kdev.UpdateInputState(state) + } + } +} + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} diff --git a/viiper/pkg/device/keyboard/helpers.go b/device/keyboard/helpers.go similarity index 96% rename from viiper/pkg/device/keyboard/helpers.go rename to device/keyboard/helpers.go index ab49fc0e..88df3c4f 100644 --- a/viiper/pkg/device/keyboard/helpers.go +++ b/device/keyboard/helpers.go @@ -1,87 +1,87 @@ -package keyboard - -// CharToHID converts an ASCII character to its HID usage code. -// Returns 0 if the character is not supported. -func CharToHID(c byte) uint8 { - if code, ok := CharToKey[c]; ok { - return code - } - return 0 -} - -// NeedsShift returns true if the character requires the Shift modifier. -func NeedsShift(c byte) bool { - return ShiftChars[c] -} - -// TypeString converts a string into a sequence of InputState press/release pairs. -// Automatically handles shift modifiers for uppercase letters and symbols. -// Returns a slice of states alternating between press and release. -// -// Example: -// -// states := TypeString("Hi!") -// // Returns: [Press Shift+H, Release, Press i, Release, Press Shift+1, Release] -func TypeString(s string) []InputState { - var states []InputState - for i := 0; i < len(s); i++ { - c := s[i] - press, release := TypeChar(c) - states = append(states, press, release) - } - return states -} - -// TypeChar converts a single character to a press/release InputState pair. -// Automatically adds Shift modifier if needed. -func TypeChar(c byte) (press, release InputState) { - keyCode := CharToHID(c) - if keyCode == 0 { - // Unsupported character, return empty states - return InputState{}, InputState{} - } - - modifiers := uint8(0) - if NeedsShift(c) { - modifiers = ModLeftShift - } - - press = PressKeyWithMod(modifiers, keyCode) - release = Release() - return -} - -// PressKey creates an InputState with the specified keys pressed. -// No modifiers are set. -// -// Example: -// -// state := PressKey(KeyA, KeyB) // Press A and B simultaneously -func PressKey(keys ...uint8) InputState { - return PressKeyWithMod(0, keys...) -} - -// PressKeyWithMod creates an InputState with modifiers and keys pressed. -// -// Example: -// -// state := PressKeyWithMod(ModLeftCtrl, KeyC) // Ctrl+C -// state := PressKeyWithMod(ModLeftShift, KeyA) // Shift+A -func PressKeyWithMod(modifiers uint8, keys ...uint8) InputState { - var state InputState - state.Modifiers = modifiers - - // Set bits for each key in the bitmap - for _, key := range keys { - byteIdx := key / 8 - bitIdx := uint(key % 8) - state.KeyBitmap[byteIdx] |= 1 << bitIdx - } - - return state -} - -// Release creates an empty InputState with all keys released. -func Release() InputState { - return InputState{} -} +package keyboard + +// CharToHID converts an ASCII character to its HID usage code. +// Returns 0 if the character is not supported. +func CharToHID(c byte) uint8 { + if code, ok := CharToKey[c]; ok { + return code + } + return 0 +} + +// NeedsShift returns true if the character requires the Shift modifier. +func NeedsShift(c byte) bool { + return ShiftChars[c] +} + +// TypeString converts a string into a sequence of InputState press/release pairs. +// Automatically handles shift modifiers for uppercase letters and symbols. +// Returns a slice of states alternating between press and release. +// +// Example: +// +// states := TypeString("Hi!") +// // Returns: [Press Shift+H, Release, Press i, Release, Press Shift+1, Release] +func TypeString(s string) []InputState { + var states []InputState + for i := 0; i < len(s); i++ { + c := s[i] + press, release := TypeChar(c) + states = append(states, press, release) + } + return states +} + +// TypeChar converts a single character to a press/release InputState pair. +// Automatically adds Shift modifier if needed. +func TypeChar(c byte) (press, release InputState) { + keyCode := CharToHID(c) + if keyCode == 0 { + // Unsupported character, return empty states + return InputState{}, InputState{} + } + + modifiers := uint8(0) + if NeedsShift(c) { + modifiers = ModLeftShift + } + + press = PressKeyWithMod(modifiers, keyCode) + release = Release() + return +} + +// PressKey creates an InputState with the specified keys pressed. +// No modifiers are set. +// +// Example: +// +// state := PressKey(KeyA, KeyB) // Press A and B simultaneously +func PressKey(keys ...uint8) InputState { + return PressKeyWithMod(0, keys...) +} + +// PressKeyWithMod creates an InputState with modifiers and keys pressed. +// +// Example: +// +// state := PressKeyWithMod(ModLeftCtrl, KeyC) // Ctrl+C +// state := PressKeyWithMod(ModLeftShift, KeyA) // Shift+A +func PressKeyWithMod(modifiers uint8, keys ...uint8) InputState { + var state InputState + state.Modifiers = modifiers + + // Set bits for each key in the bitmap + for _, key := range keys { + byteIdx := key / 8 + bitIdx := uint(key % 8) + state.KeyBitmap[byteIdx] |= 1 << bitIdx + } + + return state +} + +// Release creates an empty InputState with all keys released. +func Release() InputState { + return InputState{} +} diff --git a/viiper/pkg/device/keyboard/inputstate.go b/device/keyboard/inputstate.go similarity index 70% rename from viiper/pkg/device/keyboard/inputstate.go rename to device/keyboard/inputstate.go index 9cda3107..aa6d118e 100644 --- a/viiper/pkg/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -1,114 +1,113 @@ -package keyboard - -import ( - "io" -) - -// InputState represents the keyboard state used to build a report. -// Internally uses a 256-bit bitmap for N-key rollover support. -// viiper:wire keyboard c2s modifiers:u8 count:u8 keys:u8*count -type InputState struct { - Modifiers uint8 // bit 0-7: LCtrl, LShift, LAlt, LGui, RCtrl, RShift, RAlt, RGui - KeyBitmap [32]uint8 // 256 bits for HID usage codes 0x00-0xFF -} - -// LEDState represents the state of keyboard LEDs controlled by the host. -// viiper:wire keyboard s2c leds:u8 -type LEDState struct { - NumLock bool - CapsLock bool - ScrollLock bool - Compose bool - Kana bool -} - -// UnmarshalBinary decodes a 1-byte LED bitmask into LEDState. -// Bits are defined by LEDNumLock, LEDCapsLock, LEDScrollLock, LEDCompose, LEDKana. -func (st *LEDState) UnmarshalBinary(data []byte) error { - if len(data) < 1 { - return io.ErrUnexpectedEOF - } - b := data[0] - st.NumLock = b&LEDNumLock != 0 - st.CapsLock = b&LEDCapsLock != 0 - st.ScrollLock = b&LEDScrollLock != 0 - st.Compose = b&LEDCompose != 0 - st.Kana = b&LEDKana != 0 - return nil -} - -// BuildReport encodes an InputState into the 34-byte HID keyboard report. -// -// Report layout (34 bytes): -// -// Byte 0: Modifiers (8 bits) -// Byte 1: Reserved (0x00) -// Bytes 2-33: Key bitmap (256 bits, 32 bytes) -func (st InputState) BuildReport() []byte { - b := make([]byte, 34) - b[0] = st.Modifiers - b[1] = 0x00 // Reserved - copy(b[2:34], st.KeyBitmap[:]) - return b -} - -// MarshalBinary encodes InputState to variable-length wire format. -// -// Wire format: -// -// Byte 0: Modifiers -// Byte 1: Key count -// Bytes 2+: Key codes (HID usage codes of pressed keys) -func (st *InputState) MarshalBinary() ([]byte, error) { - // Count pressed keys - var keys []uint8 - for i := 0; i < 256; i++ { - byteIdx := i / 8 - bitIdx := uint(i % 8) - if st.KeyBitmap[byteIdx]&(1<> 8) + b[3] = byte(m.DY) + b[4] = byte(m.DY >> 8) + b[5] = byte(m.Wheel) + b[6] = byte(m.Wheel >> 8) + b[7] = byte(m.Pan) + b[8] = byte(m.Pan >> 8) + return b +} + +// MarshalBinary encodes InputState to 9 bytes. +func (m *InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, 9) + b[0] = m.Buttons + b[1] = byte(m.DX) + b[2] = byte(m.DX >> 8) + b[3] = byte(m.DY) + b[4] = byte(m.DY >> 8) + b[5] = byte(m.Wheel) + b[6] = byte(m.Wheel >> 8) + b[7] = byte(m.Pan) + b[8] = byte(m.Pan >> 8) + return b, nil +} + +// UnmarshalBinary decodes 9 bytes into InputState. +func (m *InputState) UnmarshalBinary(data []byte) error { + if len(data) < 9 { + return io.ErrUnexpectedEOF + } + m.Buttons = data[0] + m.DX = int16(data[1]) | int16(data[2])<<8 + m.DY = int16(data[3]) | int16(data[4])<<8 + m.Wheel = int16(data[5]) | int16(data[6])<<8 + m.Pan = int16(data[7]) | int16(data[8])<<8 + return nil +} diff --git a/device/mouse/mouse_test.go b/device/mouse/mouse_test.go new file mode 100644 index 00000000..6fa32835 --- /dev/null +++ b/device/mouse/mouse_test.go @@ -0,0 +1,193 @@ +package mouse_test + +import ( + "context" + "testing" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" + "github.com/stretchr/testify/assert" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register devices +) + +func TestInputReports(t *testing.T) { + type testCase struct { + name string + inputState mouse.InputState + expectedReport []byte + } + + cases := []testCase{ + { + name: "No movement, no buttons", + inputState: mouse.InputState{ + Buttons: 0, + DX: 0, + DY: 0, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "Left down", + inputState: mouse.InputState{ + Buttons: mouse.BtnLeft, + DX: 0, + DY: 0, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "right down", + inputState: mouse.InputState{ + Buttons: mouse.BtnRight, + DX: 0, + DY: 0, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "all down", + inputState: mouse.InputState{ + Buttons: mouse.BtnRight | mouse.BtnLeft | mouse.BtnMiddle | mouse.BtnBack | mouse.BtnForward, + DX: 0, + DY: 0, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "Move 50 xy", + inputState: mouse.InputState{ + Buttons: 0, + DX: 50, + DY: 50, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x00, 0x32, 0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "Move -50 xy", + inputState: mouse.InputState{ + Buttons: 0, + DX: -50, + DY: -50, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x00, 0xce, 0xff, 0xce, 0xff, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "Wheel up 1", + inputState: mouse.InputState{ + Buttons: 0, + DX: 0, + DY: 0, + Pan: 0, + Wheel: 1, + }, + expectedReport: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00}, + }, + { + name: "Wheel down 1", + inputState: mouse.InputState{ + Buttons: 0, + DX: 0, + DY: 0, + Pan: 0, + Wheel: -1, + }, + expectedReport: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00}, + }, + { + name: "Pan right 1", + inputState: mouse.InputState{ + Buttons: 0, + DX: 0, + DY: 0, + Pan: 1, + Wheel: 0, + }, + expectedReport: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}, + }, + { + name: "Move right 100, down 50, left button", + inputState: mouse.InputState{ + Buttons: mouse.BtnLeft, + DX: 100, + DY: 50, + Pan: 0, + Wheel: 0, + }, + expectedReport: []byte{0x01, 0x64, 0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() //nolint:errcheck + defer s.ApiServer.Close() //nolint:errcheck + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() //nolint:errcheck + _ = s.UsbServer.AddBus(b) + + client := viiperclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "mouse", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() //nolint:errcheck + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, devs, 1) { + return + } + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if imp != nil && imp.Conn != nil { + defer imp.Conn.Close() //nolint:errcheck + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedReport, tc.inputState.BuildReport()) + if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { + return + } + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.expectedReport, got) + }) + } +} diff --git a/device/ns2pro/commands.go b/device/ns2pro/commands.go new file mode 100644 index 00000000..0a99477b --- /dev/null +++ b/device/ns2pro/commands.go @@ -0,0 +1,146 @@ +package ns2pro + +import "encoding/binary" + +func (d *NS2Pro) handleBulkOut(out []byte) { + if len(out) < 8 { + return + } + cmd := out[0] + seq := out[2] + sub := out[3] + + switch cmd { + case cmdFlash: + d.handleFlashCommand(seq, sub, out) + case cmdUSB: + d.handleUSBCommand(seq, sub, out) + case cmdFeature: + d.handleFeatureCommand(seq, sub, out) + case cmdPlayerLED: + d.handlePlayerLEDCommand(seq, sub, out) + default: + d.enqueueResponse(commandHeader(cmd, seq, sub)) + } +} + +func (d *NS2Pro) handlePlayerLEDCommand(seq, sub uint8, out []byte) { + if sub == subPlayerLEDSet && len(out) >= 9 { + d.emitOutput(OutputState{ + Flags: OutputFlagLED, + PlayerLedMask: out[8], + }) + } + d.enqueueResponse(commandHeader(cmdPlayerLED, seq, sub)) +} + +func (d *NS2Pro) handleFlashCommand(seq, sub uint8, out []byte) { + if sub != subFlashRead || len(out) < 16 { + d.enqueueResponse(commandHeader(cmdFlash, seq, sub)) + return + } + + address := binary.LittleEndian.Uint32(out[12:16]) + resp := make([]byte, 16+flashBlockSize) + copy(resp[0:8], commandHeader(cmdFlash, seq, sub)) + resp[8] = flashBlockSize + binary.LittleEndian.PutUint32(resp[12:16], address) + copy(resp[16:], d.minimalFlashBlock(address)) + d.enqueueResponse(resp) +} + +func (d *NS2Pro) handleUSBCommand(seq, sub uint8, out []byte) { + switch sub { + case subUSBEnableReports: + if len(out) >= 9 { + d.protoMu.Lock() + d.usbReportsEnabled = out[8] != 0 + d.protoMu.Unlock() + } + d.enqueueResponse(append(commandHeader(cmdUSB, seq, sub), 0x01, 0x00, 0x00, 0x00)) + case subUSBSelectReport: + if len(out) >= 9 { + switch out[8] { + case ReportIDCommon, ReportIDPro: + d.protoMu.Lock() + d.activeReportID = out[8] + d.protoMu.Unlock() + } + } + d.enqueueResponse(commandHeader(cmdUSB, seq, sub)) + case subUSBStartReports: + d.protoMu.Lock() + d.usbReportsEnabled = true + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdUSB, seq, sub), 0x01, 0x00, 0x00, 0x00)) + default: + d.enqueueResponse(commandHeader(cmdUSB, seq, sub)) + } +} + +func (d *NS2Pro) handleFeatureCommand(seq, sub uint8, out []byte) { + flags := uint8(0) + if len(out) >= 9 { + flags = out[8] + } + + switch sub { + case subFeatureInfo: + payload := make([]byte, 12) + copy(payload[4:], featureInfo(flags)) + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), payload...)) + case subFeatureSetMask: + d.protoMu.Lock() + d.featureMask = flags + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case subFeatureReset: + d.protoMu.Lock() + d.featureMask = 0 + d.featureFlags = 0 + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case subFeatureEnable: + d.protoMu.Lock() + d.featureFlags |= d.maskedFeatures(flags) + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case subFeatureDisable: + d.protoMu.Lock() + d.featureFlags &^= d.maskedFeatures(flags) + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) + default: + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) + } +} + +// maskedFeatures must be called with protoMu held. +func (d *NS2Pro) maskedFeatures(flags uint8) uint8 { + if d.featureMask == 0 { + return flags + } + return flags & d.featureMask +} + +func featureInfo(flags uint8) []byte { + out := make([]byte, 8) + for _, entry := range featureInfoMap { + if flags&entry.feature != 0 { + out[entry.index] = entry.value + } + } + return out +} + +var featureInfoMap = []struct { + feature uint8 + index int + value byte +}{ + {FeatureButtons, 0, 0x07}, + {FeatureSticks, 1, 0x07}, + {FeatureIMU, 2, 0x01}, + {FeatureMouse, 4, 0x03}, + {FeatureRumble, 5, 0x03}, +} diff --git a/device/ns2pro/const.go b/device/ns2pro/const.go new file mode 100644 index 00000000..04700249 --- /dev/null +++ b/device/ns2pro/const.go @@ -0,0 +1,127 @@ +package ns2pro + +const ( + DefaultVID = 0x057E + DefaultPID = 0x2069 + DefaultSerialEnding = "00" + DefaultSerial = "VIIPER-NS2PRO-" + DefaultSerialEnding + DefaultBatteryVolts uint16 = 3800 +) + +const ( + ButtonB uint32 = 1 << iota + ButtonA + ButtonY + ButtonX + ButtonR + ButtonZR + ButtonPlus + ButtonRightStick + ButtonDown + ButtonRight + ButtonLeft + ButtonUp + ButtonL + ButtonZL + ButtonMinus + ButtonLeftStick + ButtonHome + ButtonCapture + ButtonGR + ButtonGL + ButtonC + ButtonHeadset +) + +const ( + OutputFlagRumble = 0x01 + OutputFlagLED = 0x02 +) + +const ( + StickMin uint16 = 0 + StickCenter uint16 = 0x0800 + StickMax uint16 = 0x0FFF + BatteryMax uint8 = 9 +) + +const ( + EndpointHIDIn = 0x81 + EndpointHIDOut = 0x01 + EndpointBulkOut = 0x02 + EndpointBulkIn = 0x82 +) + +const ( + ReportIDCommon = 0x05 + ReportIDPro = 0x09 + ReportIDOutput = 0x02 +) + +const ( + InputReportSize = 64 + OutputReportSize = 64 + InputWireSize = 24 + OutputRumbleSize = 32 + OutputWireSize = 34 +) + +const ( + FeatureButtons = 0x01 + FeatureSticks = 0x02 + FeatureIMU = 0x04 + FeatureMouse = 0x10 + FeatureRumble = 0x20 +) + +const ( + hidClassRequestIn = 0xA1 + hidClassRequestOut = 0x21 + + hidGetReport = 0x01 + hidSetReport = 0x09 + + reportTypeInput = 0x01 + reportTypeOutput = 0x02 +) + +const ( + audioSetCur = 0x01 + audioGetCur = 0x81 + audioGetMin = 0x82 + audioGetMax = 0x83 + audioGetRes = 0x84 +) + +const ( + requestTypeMask = 0x60 + requestClass = 0x20 + recipientMask = 0x1F + recipientIface = 0x01 + recipientEndpoint = 0x02 +) + +const ( + cmdFlash = 0x02 + cmdUSB = 0x03 + cmdPlayerLED = 0x09 + cmdFeature = 0x0C +) + +const ( + subFlashRead = 0x01 + + subUSBEnableReports = 0x03 + subUSBSelectReport = 0x0A + subUSBStartReports = 0x0D + + subFeatureInfo = 0x01 + subFeatureSetMask = 0x02 + subFeatureReset = 0x03 + subFeatureEnable = 0x04 + subFeatureDisable = 0x05 + + subPlayerLEDSet = 0x07 +) + +const flashBlockSize = 0x40 diff --git a/device/ns2pro/descriptor.go b/device/ns2pro/descriptor.go new file mode 100644 index 00000000..0e8a4ac9 --- /dev/null +++ b/device/ns2pro/descriptor.go @@ -0,0 +1,149 @@ +package ns2pro + +import ( + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" +) + +const microsoftOS10VendorCode = 0x20 + +const microsoftOS10DeviceInterfaceGUID = "{7D8F1E5C-9D89-4E0D-A2F0-6D10F1F89A8F}" + +func MakeDescriptor() usb.Descriptor { + return usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0xEF, + BDeviceSubClass: 0x02, + BDeviceProtocol: 0x01, + BMaxPacketSize0: 0x40, + IDVendor: DefaultVID, + IDProduct: DefaultPID, + BcdDevice: 0x0200, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x03, + BNumConfigurations: 0x01, + Speed: 2, + }, + Configuration: usb.ConfigurationDescriptor{ + BConfigurationValue: 0x01, + IConfiguration: 0x04, + BMAttributes: 0xC0, + BMaxPower: 0xFA, + }, + MicrosoftOS10: &usb.MicrosoftOS10Descriptor{ + VendorCode: microsoftOS10VendorCode, + InterfaceNumber: 0x01, + CompatibleID: "WINUSB", + DeviceInterfaceGUID: microsoftOS10DeviceInterfaceGUID, + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0x03, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x05, + }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, + }, + }, + ReportDescriptor: reportDescriptor, + }, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: EndpointHIDIn, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 4}, + {BEndpointAddress: EndpointHIDOut, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 4}, + }, + }, + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x01, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0xFF, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x06, + }, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: EndpointBulkOut, BMAttributes: 0x02, WMaxPacketSize: 64, BInterval: 0}, + {BEndpointAddress: EndpointBulkIn, BMAttributes: 0x02, WMaxPacketSize: 64, BInterval: 0}, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\u0409", + 1: "Nintendo", + 2: "Switch 2 Pro Controller", + 3: DefaultSerialEnding, + 4: "Nintendo Switch 2 Pro Controller", + 5: "Nintendo Switch 2 Pro Controller", + 6: "Pro Controller", + }, + } +} + +var reportDescriptor = hid.ReportDescriptor{ + Items: []hid.Item{ + hidShort(hid.ItemTypeGlobal, 0x0, 0x01), + hidShort(hid.ItemTypeLocal, 0x0, 0x05), + hidShort(hid.ItemTypeMain, 0xA, 0x01), + hidShort(hid.ItemTypeGlobal, 0x8, ReportIDCommon), + hidShort(hid.ItemTypeGlobal, 0x0, 0xFF), + hidShort(hid.ItemTypeLocal, 0x0, 0x01), + hidShort(hid.ItemTypeGlobal, 0x1, 0x00), + hidShort(hid.ItemTypeGlobal, 0x2, 0xFF, 0x00), + hidShort(hid.ItemTypeGlobal, 0x9, 0x3F), + hidShort(hid.ItemTypeGlobal, 0x7, 0x08), + hidShort(hid.ItemTypeMain, 0x8, 0x02), + hidShort(hid.ItemTypeGlobal, 0x8, ReportIDPro), + hidShort(hid.ItemTypeLocal, 0x0, 0x01), + hidShort(hid.ItemTypeGlobal, 0x9, 0x02), + hidShort(hid.ItemTypeMain, 0x8, 0x02), + hidShort(hid.ItemTypeGlobal, 0x0, 0x09), + hidShort(hid.ItemTypeLocal, 0x1, 0x01), + hidShort(hid.ItemTypeLocal, 0x2, 0x15), + hidShort(hid.ItemTypeGlobal, 0x2, 0x01), + hidShort(hid.ItemTypeGlobal, 0x9, 0x15), + hidShort(hid.ItemTypeGlobal, 0x7, 0x01), + hidShort(hid.ItemTypeMain, 0x8, 0x02), + hidShort(hid.ItemTypeGlobal, 0x9, 0x01), + hidShort(hid.ItemTypeGlobal, 0x7, 0x03), + hidShort(hid.ItemTypeMain, 0x8, 0x03), + hidShort(hid.ItemTypeGlobal, 0x0, 0x01), + hidShort(hid.ItemTypeLocal, 0x0, 0x01), + hidShort(hid.ItemTypeMain, 0xA, 0x00), + hidShort(hid.ItemTypeLocal, 0x0, 0x30), + hidShort(hid.ItemTypeLocal, 0x0, 0x31), + hidShort(hid.ItemTypeLocal, 0x0, 0x33), + hidShort(hid.ItemTypeLocal, 0x0, 0x35), + hidShort(hid.ItemTypeGlobal, 0x2, 0xFF, 0x0F), + hidShort(hid.ItemTypeGlobal, 0x9, 0x04), + hidShort(hid.ItemTypeGlobal, 0x7, 0x0C), + hidShort(hid.ItemTypeMain, 0x8, 0x02), + hidShort(hid.ItemTypeMain, 0xC), + hidShort(hid.ItemTypeGlobal, 0x0, 0xFF), + hidShort(hid.ItemTypeLocal, 0x0, 0x02), + hidShort(hid.ItemTypeGlobal, 0x2, 0xFF, 0x00), + hidShort(hid.ItemTypeGlobal, 0x9, 0x34), + hidShort(hid.ItemTypeGlobal, 0x7, 0x08), + hidShort(hid.ItemTypeMain, 0x8, 0x02), + hidShort(hid.ItemTypeGlobal, 0x8, ReportIDOutput), + hidShort(hid.ItemTypeLocal, 0x0, 0x01), + hidShort(hid.ItemTypeGlobal, 0x9, 0x3F), + hidShort(hid.ItemTypeMain, 0x9, 0x02), + hidShort(hid.ItemTypeMain, 0xC), + }} + +func hidShort(itemType hid.ItemType, tag uint8, data ...uint8) hid.AnyItem { + return hid.AnyItem{Type: itemType, Tag: tag, Data: hid.Data(data)} +} diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go new file mode 100644 index 00000000..3ad1b5bc --- /dev/null +++ b/device/ns2pro/device.go @@ -0,0 +1,326 @@ +// Package ns2pro provides a Nintendo Switch 2 Pro Controller compatible HID device. +package ns2pro + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type NS2Pro struct { + inputCh chan struct{} + bulkCh chan struct{} + stateMu sync.Mutex + inputState *InputState + metaState *MetaState + outputMu sync.RWMutex + outputCallback func(OutputState) + outputVersion uint64 + descriptor usb.Descriptor + + protoMu sync.Mutex + activeReportID uint8 + featureMask uint8 + featureFlags uint8 + usbReportsEnabled bool + reportCounter32 uint32 + reportCounter8 uint8 + motionStart time.Time + lastMotionTS uint32 + bulkInQueue [][]byte +} + +func New(o *device.CreateOptions) (*NS2Pro, error) { + metaState := defaultMetaState() + if o != nil && o.DeviceSpecific != "" { + var newMeta MetaState + if err := json.Unmarshal([]byte(o.DeviceSpecific), &newMeta); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + if newMeta.SerialNumber != "" { + metaState.SerialNumber = newMeta.SerialNumber + } + if newMeta.BatteryLevel != 0 { + metaState.BatteryLevel = newMeta.BatteryLevel + } + if newMeta.Charging { + metaState.Charging = true + } + if newMeta.ExternalPower { + metaState.ExternalPower = true + } + if newMeta.BatteryVolts != 0 { + metaState.BatteryVolts = newMeta.BatteryVolts + } + } + + inputCh := make(chan struct{}, 1) + inputCh <- struct{}{} + d := &NS2Pro{ + inputCh: inputCh, + bulkCh: make(chan struct{}, 1), + inputState: defaultInputState(), + metaState: metaState, + descriptor: MakeDescriptor(), + activeReportID: ReportIDPro, + featureFlags: FeatureButtons | FeatureSticks, + motionStart: time.Now(), + } + serialEnding := DefaultSerialEnding + if len(metaState.SerialNumber) >= 2 { + serialEnding = metaState.SerialNumber[len(metaState.SerialNumber)-2:] + } + d.descriptor.Strings[3] = serialEnding + + if o != nil { + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor + } + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct + } + } + return d, nil +} + +func (d *NS2Pro) SetOutputCallback(f func(OutputState)) func() { + d.outputMu.Lock() + d.outputVersion++ + version := d.outputVersion + d.outputCallback = f + d.outputMu.Unlock() + + return func() { + d.outputMu.Lock() + if d.outputVersion == version { + d.outputCallback = nil + } + d.outputMu.Unlock() + } +} + +func (d *NS2Pro) UpdateInputState(state InputState) { + d.stateMu.Lock() + d.inputState = &state + d.stateMu.Unlock() + select { + case d.inputCh <- struct{}{}: + default: + } +} + +func (d *NS2Pro) SetMetaState(meta MetaState) { + d.stateMu.Lock() + defer d.stateMu.Unlock() + d.metaState = &meta + if d.descriptor.Strings != nil { + serialEnding := DefaultSerialEnding + if len(meta.SerialNumber) >= 2 { + serialEnding = meta.SerialNumber[len(meta.SerialNumber)-2:] + } + d.descriptor.Strings[3] = serialEnding + } +} + +func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + switch { + case dir == usbip.DirIn && ep == 1: + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) && d.reportsEnabled() { + return d.nextInputReport() + } + return nil + case <-d.inputCh: + if d.reportsEnabled() { + return d.nextInputReport() + } + } + } + case dir == usbip.DirIn && ep == 2: + for { + if resp := d.popBulkIn(); resp != nil { + return resp + } + select { + case <-ctx.Done(): + return nil + case <-d.bulkCh: + } + } + case dir == usbip.DirOut && ep == 1: + d.handleOutputReport(out) + case dir == usbip.DirOut && ep == 2: + d.handleBulkOut(out) + } + return nil +} + +func (d *NS2Pro) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex uint16, wLength uint16, data []byte) ([]byte, bool) { + reportType := uint8(wValue >> 8) + reportID := uint8(wValue) + + if bmRequestType == hidClassRequestIn && bRequest == hidGetReport && reportType == reportTypeInput { + switch reportID { + case ReportIDCommon, ReportIDPro, 0: + return d.inputReportForID(reportID), true + } + } + + if bmRequestType == hidClassRequestOut && bRequest == hidSetReport && reportType == reportTypeOutput && reportID == ReportIDOutput { + d.handleOutputReport(data) + return nil, true + } + + if isAudioClassRequest(bmRequestType) { + switch bRequest { + case audioSetCur: + return nil, true + case audioGetCur, audioGetMin, audioGetMax, audioGetRes: + return make([]byte, wLength), true + } + } + + return nil, false +} + +func isAudioClassRequest(bmRequestType uint8) bool { + return bmRequestType&requestTypeMask == requestClass && + (bmRequestType&recipientMask == recipientIface || bmRequestType&recipientMask == recipientEndpoint) +} + +func (d *NS2Pro) GetDescriptor() *usb.Descriptor { + return &d.descriptor +} + +func (d *NS2Pro) GetDeviceSpecificArgs() map[string]any { + d.stateMu.Lock() + defer d.stateMu.Unlock() + if d.metaState == nil { + return map[string]any{} + } + b, err := json.Marshal(d.metaState) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return map[string]any{} + } + return out +} + +func (d *NS2Pro) reportsEnabled() bool { + d.protoMu.Lock() + defer d.protoMu.Unlock() + return d.usbReportsEnabled +} + +func (d *NS2Pro) nextInputReport() []byte { + d.protoMu.Lock() + reportID := d.activeReportID + d.protoMu.Unlock() + return d.inputReportForID(reportID) +} + +func (d *NS2Pro) inputReportForID(reportID uint8) []byte { + d.stateMu.Lock() + st := *d.inputState + meta := *d.metaState + d.stateMu.Unlock() + + d.protoMu.Lock() + if reportID == 0 { + reportID = d.activeReportID + } + features := d.featureFlags + var report []byte + switch reportID { + case ReportIDCommon: + d.reportCounter32++ + var motionTS uint32 + if features&FeatureIMU != 0 { + motionTS = uint32(time.Since(d.motionStart).Microseconds()) + d.lastMotionTS = motionTS + } + report = st.buildCommonReport(d.reportCounter32, motionTS, features, meta) + default: + d.reportCounter8++ + report = st.buildProReport(d.reportCounter8, features, meta) + } + d.protoMu.Unlock() + return report +} + +func (d *NS2Pro) serialNumber() string { + d.stateMu.Lock() + defer d.stateMu.Unlock() + if d.metaState == nil { + return "" + } + return d.metaState.SerialNumber +} + +func (d *NS2Pro) handleOutputReport(out []byte) { + if len(out) == 0 { + return + } + + payload := out + if out[0] == ReportIDOutput { + payload = out[1:] + } else if len(out) != OutputRumbleSize { + return + } + if len(payload) < OutputRumbleSize { + return + } + + feedback := OutputState{} + copy(feedback.LeftRumble[:], payload[0:16]) + copy(feedback.RightRumble[:], payload[16:32]) + feedback.Flags = OutputFlagRumble + d.emitOutput(feedback) +} + +func (d *NS2Pro) emitOutput(feedback OutputState) { + d.outputMu.RLock() + callback := d.outputCallback + d.outputMu.RUnlock() + if callback != nil { + callback(feedback) + } +} + +func (d *NS2Pro) enqueueResponse(resp []byte) { + d.protoMu.Lock() + d.bulkInQueue = append(d.bulkInQueue, append([]byte(nil), resp...)) + d.protoMu.Unlock() + select { + case d.bulkCh <- struct{}{}: + default: + } +} + +func (d *NS2Pro) popBulkIn() []byte { + d.protoMu.Lock() + defer d.protoMu.Unlock() + if len(d.bulkInQueue) == 0 { + return nil + } + chunk := d.bulkInQueue[0] + d.bulkInQueue = d.bulkInQueue[1:] + return append([]byte(nil), chunk...) +} + +func commandHeader(cmd, seq, sub uint8) []byte { + return []byte{cmd, 0x01, seq, sub, 0x10, 0x78, 0x00, 0x00} +} diff --git a/device/ns2pro/flash.go b/device/ns2pro/flash.go new file mode 100644 index 00000000..b992b49f --- /dev/null +++ b/device/ns2pro/flash.go @@ -0,0 +1,28 @@ +package ns2pro + +func (d *NS2Pro) minimalFlashBlock(address uint32) []byte { + block := make([]byte, 0x40) + switch address { + case 0x13000: + serial := d.serialNumber() + if serial == "" { + serial = DefaultSerial + } + copy(block[2:], []byte(serial)) + case 0x13080, 0x130C0: + encodeStickCalibration(block[0x28:], StickCenter, StickCenter, 2047, 2047, 2048, 2048) + case 0x13040, 0x13100, 0x1FC040, 0x1FC080: + // Zeroed data is intentional: no gyro/accel bias and no user calibration magic. + default: + } + return block +} + +func encodeStickCalibration(out []byte, neutralX, neutralY, maxX, maxY, minX, minY uint16) { + if len(out) < 9 { + return + } + packStick12(out[0:3], neutralX, neutralY) + packStick12(out[3:6], maxX, maxY) + packStick12(out[6:9], minX, minY) +} diff --git a/device/ns2pro/handler.go b/device/ns2pro/handler.go new file mode 100644 index 00000000..5af917b1 --- /dev/null +++ b/device/ns2pro/handler.go @@ -0,0 +1,135 @@ +package ns2pro + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("ns2pro", &handler{}) +} + +type handler struct{} + +var serials = map[string]struct{}{} + +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + if o == nil { + o = &device.CreateOptions{} + } + + metaState := *defaultMetaState() + if o.DeviceSpecific != "" { + if err := json.Unmarshal([]byte(o.DeviceSpecific), &metaState); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + } + + serial := metaState.SerialNumber + if serial == "" { + serial = DefaultSerial + } + if _, ok := serials[serial]; ok { + if len(serial) < 2 { + serial = DefaultSerial + } + for i := 1; i < 16; i++ { + newSerial := fmt.Sprintf("%s%02X", serial[:len(serial)-2], i) + if _, exists := serials[newSerial]; !exists { + serial = newSerial + break + } + } + } + + metaState.SerialNumber = serial + serials[serial] = struct{}{} + + b, err := json.Marshal(metaState) + if err != nil { + return nil, fmt.Errorf("marshal meta state: %w", err) + } + o.DeviceSpecific = string(b) + + return New(o) +} + +func (h *handler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + defer func() { + if devPtr == nil || *devPtr == nil { + return + } + ns2, ok := (*devPtr).(*NS2Pro) + if !ok { + slog.Warn("device is not ns2pro on disconnect") + return + } + serial := ns2.serialNumber() + if serial == "" { + return + } + delete(serials, serial) + slog.Debug("ns2pro disconnected, serial released", "serial", serial) + }() + + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + ns2, ok := (*devPtr).(*NS2Pro) + if !ok { + return fmt.Errorf("device is not ns2pro") + } + + clearOutputCallback := ns2.SetOutputCallback(func(feedback OutputState) { + data, err := feedback.MarshalBinary() + if err != nil { + logger.Error("failed to marshal ns2pro feedback", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send ns2pro feedback", "error", err) + } + }) + defer clearOutputCallback() + + buf := make([]byte, InputWireSize) + for { + if _, err := io.ReadFull(conn, buf); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read input state: %w", err) + } + + var state InputState + if err := state.UnmarshalBinary(buf); err != nil { + return fmt.Errorf("unmarshal input state: %w", err) + } + ns2.UpdateInputState(state) + } + } +} + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + ns2, ok := (*dev).(*NS2Pro) + if !ok { + return fmt.Errorf("%w: expected ns2pro", device.ErrWrongDeviceType) + } + + metaState := *defaultMetaState() + if err := json.Unmarshal([]byte(meta), &metaState); err != nil { + return fmt.Errorf("unmarshal meta state: %w", err) + } + + ns2.SetMetaState(metaState) + return nil +} diff --git a/device/ns2pro/inputstate.go b/device/ns2pro/inputstate.go new file mode 100644 index 00000000..62436134 --- /dev/null +++ b/device/ns2pro/inputstate.go @@ -0,0 +1,274 @@ +package ns2pro + +import ( + "encoding/binary" + "io" +) + +// nolint +// viiper:wire ns2pro c2s buttons:u32 lx:u16 ly:u16 rx:u16 ry:u16 accelX:i16 accelY:i16 accelZ:i16 gyroX:i16 gyroY:i16 gyroZ:i16 +type InputState struct { + Buttons uint32 + + LX, LY uint16 + RX, RY uint16 + + AccelX, AccelY, AccelZ int16 + GyroX, GyroY, GyroZ int16 +} + +// NewInputState returns an NS2 Pro input state in its neutral/resting state. +func NewInputState() *InputState { + return &InputState{ + LX: StickCenter, + LY: StickCenter, + RX: StickCenter, + RY: StickCenter, + } +} + +func defaultInputState() *InputState { return NewInputState() } + +type MetaState struct { + SerialNumber string `json:"serial_number"` + BatteryLevel uint8 `json:"battery_level"` + Charging bool `json:"charging"` + ExternalPower bool `json:"external_power"` + BatteryVolts uint16 `json:"battery_volts"` +} + +func defaultMetaState() *MetaState { + return &MetaState{ + SerialNumber: DefaultSerial, + BatteryLevel: BatteryMax, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + } +} + +func (s *InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, InputWireSize) + binary.LittleEndian.PutUint32(b[0:4], s.Buttons) + binary.LittleEndian.PutUint16(b[4:6], s.LX) + binary.LittleEndian.PutUint16(b[6:8], s.LY) + binary.LittleEndian.PutUint16(b[8:10], s.RX) + binary.LittleEndian.PutUint16(b[10:12], s.RY) + binary.LittleEndian.PutUint16(b[12:14], uint16(s.AccelX)) + binary.LittleEndian.PutUint16(b[14:16], uint16(s.AccelY)) + binary.LittleEndian.PutUint16(b[16:18], uint16(s.AccelZ)) + binary.LittleEndian.PutUint16(b[18:20], uint16(s.GyroX)) + binary.LittleEndian.PutUint16(b[20:22], uint16(s.GyroY)) + binary.LittleEndian.PutUint16(b[22:24], uint16(s.GyroZ)) + return b, nil +} + +func (s *InputState) UnmarshalBinary(data []byte) error { + if len(data) < InputWireSize { + return io.ErrUnexpectedEOF + } + s.Buttons = binary.LittleEndian.Uint32(data[0:4]) + s.LX = binary.LittleEndian.Uint16(data[4:6]) + s.LY = binary.LittleEndian.Uint16(data[6:8]) + s.RX = binary.LittleEndian.Uint16(data[8:10]) + s.RY = binary.LittleEndian.Uint16(data[10:12]) + s.AccelX = int16(binary.LittleEndian.Uint16(data[12:14])) + s.AccelY = int16(binary.LittleEndian.Uint16(data[14:16])) + s.AccelZ = int16(binary.LittleEndian.Uint16(data[16:18])) + s.GyroX = int16(binary.LittleEndian.Uint16(data[18:20])) + s.GyroY = int16(binary.LittleEndian.Uint16(data[20:22])) + s.GyroZ = int16(binary.LittleEndian.Uint16(data[22:24])) + return nil +} + +// nolint +// viiper:wire ns2pro s2c leftRumble:u8*16 rightRumble:u8*16 flags:u8 playerLedMask:u8 +type OutputState struct { + LeftRumble [16]byte + RightRumble [16]byte + Flags uint8 + PlayerLedMask uint8 +} + +func (o *OutputState) MarshalBinary() ([]byte, error) { + b := make([]byte, OutputWireSize) + copy(b[0:16], o.LeftRumble[:]) + copy(b[16:32], o.RightRumble[:]) + b[32] = o.Flags + b[33] = o.PlayerLedMask + return b, nil +} + +func (o *OutputState) UnmarshalBinary(data []byte) error { + if len(data) < OutputWireSize { + return io.ErrUnexpectedEOF + } + copy(o.LeftRumble[:], data[0:16]) + copy(o.RightRumble[:], data[16:32]) + o.Flags = data[32] + o.PlayerLedMask = data[33] + return nil +} + +func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features uint8, meta MetaState) []byte { + b := make([]byte, InputReportSize) + b[0] = ReportIDCommon + binary.LittleEndian.PutUint32(b[1:5], counter) + + buttons := s.commonButtonBytes() + copy(b[5:9], buttons[:]) + packStick12(b[11:14], s.LX, s.LY) + packStick12(b[14:17], s.RX, s.RY) + + binary.LittleEndian.PutUint16(b[0x20:0x22], meta.BatteryVolts) + b[0x22] = chargingState(meta) + b[0x2A] = 0x01 + + if features&FeatureIMU != 0 { + binary.LittleEndian.PutUint32(b[0x2B:0x2F], motionTimestamp) + binary.LittleEndian.PutUint16(b[0x31:0x33], uint16(s.AccelX)) + binary.LittleEndian.PutUint16(b[0x33:0x35], uint16(s.AccelY)) + binary.LittleEndian.PutUint16(b[0x35:0x37], uint16(s.AccelZ)) + binary.LittleEndian.PutUint16(b[0x37:0x39], uint16(s.GyroX)) + binary.LittleEndian.PutUint16(b[0x39:0x3B], uint16(s.GyroY)) + binary.LittleEndian.PutUint16(b[0x3B:0x3D], uint16(s.GyroZ)) + } + + return b +} + +func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState) []byte { + b := make([]byte, InputReportSize) + b[0] = ReportIDPro + b[1] = counter + b[2] = powerInfo(meta) + + buttons := s.proButtonBytes() + copy(b[3:6], buttons[:]) + packStick12(b[6:9], s.LX, s.LY) + packStick12(b[9:12], s.RX, s.RY) + + if features&FeatureRumble != 0 { + b[12] = 0x38 + } else { + b[12] = 0x30 + } + b[13] = 0x00 + b[14] = 0x00 + b[15] = 0x00 + return b +} + +func (s InputState) commonButtonBytes() [4]byte { + var out [4]byte + encodeButtonMap(s.Buttons, commonButtonMap, out[:]) + return out +} + +func (s InputState) proButtonBytes() [3]byte { + var out [3]byte + encodeButtonMap(s.Buttons, proButtonMap, out[:]) + return out +} + +type buttonReportBit struct { + button uint32 + index int + mask byte +} + +func encodeButtonMap(buttons uint32, mapping []buttonReportBit, out []byte) { + for _, bit := range mapping { + if buttons&bit.button != 0 { + out[bit.index] |= bit.mask + } + } +} + +var commonButtonMap = []buttonReportBit{ + {ButtonY, 0, 0x01}, + {ButtonX, 0, 0x02}, + {ButtonB, 0, 0x04}, + {ButtonA, 0, 0x08}, + {ButtonR, 0, 0x40}, + {ButtonZR, 0, 0x80}, + {ButtonMinus, 1, 0x01}, + {ButtonPlus, 1, 0x02}, + {ButtonRightStick, 1, 0x04}, + {ButtonLeftStick, 1, 0x08}, + {ButtonHome, 1, 0x10}, + {ButtonCapture, 1, 0x20}, + {ButtonC, 1, 0x40}, + {ButtonDown, 2, 0x01}, + {ButtonUp, 2, 0x02}, + {ButtonRight, 2, 0x04}, + {ButtonLeft, 2, 0x08}, + {ButtonL, 2, 0x40}, + {ButtonZL, 2, 0x80}, + {ButtonGR, 3, 0x01}, + {ButtonGL, 3, 0x02}, + {ButtonHeadset, 3, 0x10}, +} + +var proButtonMap = []buttonReportBit{ + {ButtonB, 0, 0x01}, + {ButtonA, 0, 0x02}, + {ButtonY, 0, 0x04}, + {ButtonX, 0, 0x08}, + {ButtonR, 0, 0x10}, + {ButtonZR, 0, 0x20}, + {ButtonPlus, 0, 0x40}, + {ButtonRightStick, 0, 0x80}, + {ButtonDown, 1, 0x01}, + {ButtonRight, 1, 0x02}, + {ButtonLeft, 1, 0x04}, + {ButtonUp, 1, 0x08}, + {ButtonL, 1, 0x10}, + {ButtonZL, 1, 0x20}, + {ButtonMinus, 1, 0x40}, + {ButtonLeftStick, 1, 0x80}, + {ButtonHome, 2, 0x01}, + {ButtonCapture, 2, 0x02}, + {ButtonGR, 2, 0x04}, + {ButtonGL, 2, 0x08}, + {ButtonC, 2, 0x10}, +} + +func packStick12(out []byte, x, y uint16) { + if len(out) < 3 { + return + } + x = clampStick(x) + y = clampStick(y) + out[0] = byte(x) + out[1] = byte((x>>8)&0x0F) | byte((y&0x0F)<<4) + out[2] = byte(y >> 4) +} + +func clampStick(v uint16) uint16 { + if v > StickMax { + return StickMax + } + return v +} + +func powerInfo(meta MetaState) uint8 { + level := meta.BatteryLevel + if level > BatteryMax { + level = BatteryMax + } + out := (level & 0x0F) << 2 + if meta.ExternalPower { + out |= 0x01 + } + if meta.Charging { + out |= 0x02 + } + return out +} + +func chargingState(meta MetaState) uint8 { + if meta.Charging { + return 0x34 + } + return 0x20 +} diff --git a/device/ns2pro/ns2pro_test.go b/device/ns2pro/ns2pro_test.go new file mode 100644 index 00000000..f5f93e5b --- /dev/null +++ b/device/ns2pro/ns2pro_test.go @@ -0,0 +1,578 @@ +package ns2pro + +import ( + "context" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "testing" + "time" + "unicode/utf16" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + apihandler "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildReport05(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + state := InputState{ + Buttons: ButtonA | ButtonB | ButtonR | ButtonZR | ButtonMinus | ButtonPlus | ButtonLeftStick | ButtonRightStick | ButtonHome | ButtonCapture | ButtonC | ButtonDown | ButtonRight | ButtonLeft | ButtonUp | ButtonL | ButtonZL | ButtonGR | ButtonGL | ButtonHeadset, + LX: 0x0123, + LY: 0x0456, + RX: 0x0789, + RY: 0x0ABC, + AccelX: 0x1122, + AccelY: -0x1234, + AccelZ: 0x3344, + GyroX: -0x0102, + GyroY: 0x5566, + GyroZ: -0x0777, + } + dev.UpdateInputState(state) + dev.SetMetaState(MetaState{ + SerialNumber: DefaultSerial, + BatteryLevel: 7, + Charging: true, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + }) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, enableReportsCommand()) + + report := dev.HandleTransfer(context.Background(), 1, usbip.DirIn, nil) + require.Len(t, report, InputReportSize) + assert.Equal(t, byte(ReportIDCommon), report[0]) + assert.Equal(t, uint32(1), binary.LittleEndian.Uint32(report[1:5])) + assert.Equal(t, []byte{0xCC, 0x7F, 0xCF, 0x13}, report[5:9]) + + var leftStick [3]byte + var rightStick [3]byte + packStick12(leftStick[:], state.LX, state.LY) + packStick12(rightStick[:], state.RX, state.RY) + assert.Equal(t, leftStick[:], report[11:14]) + assert.Equal(t, rightStick[:], report[14:17]) + assert.Equal(t, DefaultBatteryVolts, binary.LittleEndian.Uint16(report[0x20:0x22])) + assert.Equal(t, byte(0x34), report[0x22]) + assert.Equal(t, byte(0x01), report[0x2A]) + ts1 := binary.LittleEndian.Uint32(report[0x2B:0x2F]) + assert.Equal(t, uint16(state.AccelX), binary.LittleEndian.Uint16(report[0x31:0x33])) + assert.Equal(t, uint16(state.AccelY), binary.LittleEndian.Uint16(report[0x33:0x35])) + assert.Equal(t, uint16(state.AccelZ), binary.LittleEndian.Uint16(report[0x35:0x37])) + assert.Equal(t, uint16(state.GyroX), binary.LittleEndian.Uint16(report[0x37:0x39])) + assert.Equal(t, uint16(state.GyroY), binary.LittleEndian.Uint16(report[0x39:0x3B])) + assert.Equal(t, uint16(state.GyroZ), binary.LittleEndian.Uint16(report[0x3B:0x3D])) + + kCtx, kCancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer kCancel() + next := dev.HandleTransfer(kCtx, 1, usbip.DirIn, nil) + require.Len(t, next, InputReportSize) + assert.Equal(t, uint32(2), binary.LittleEndian.Uint32(next[1:5])) + assert.Greater(t, binary.LittleEndian.Uint32(next[0x2B:0x2F]), ts1) +} + +func TestHIDReportDescriptorMatchesCapture(t *testing.T) { + got, err := reportDescriptor.Bytes() + require.NoError(t, err) + assert.Equal(t, + mustHexBytes(t, "05010905a101850505ff0901150026ff00953f750881028509090195028102050919012915250195157501810295017503810305010901a100093009310933093526ff0f9504750c8102c005ff090226ff0095347508810285020901953f9102c0"), + []byte(got), + ) +} + +func TestDescriptor(t *testing.T) { + desc := MakeDescriptor() + assert.Equal(t, uint16(DefaultVID), desc.Device.IDVendor) + assert.Equal(t, uint16(DefaultPID), desc.Device.IDProduct) + assert.Equal(t, uint16(0x0200), desc.Device.BcdDevice) + assert.Equal(t, "Switch 2 Pro Controller", desc.Strings[2]) + assert.Equal(t, DefaultSerialEnding, desc.Strings[3]) + assert.Equal(t, "Nintendo Switch 2 Pro Controller", desc.Strings[4]) + assert.Equal(t, "Nintendo Switch 2 Pro Controller", desc.Strings[5]) + assert.Equal(t, "Pro Controller", desc.Strings[6]) + assert.Equal(t, byte(0x04), desc.Configuration.IConfiguration) + assert.Equal(t, byte(0xC0), desc.Configuration.BMAttributes) + assert.Equal(t, byte(0xFA), desc.Configuration.BMaxPower) + require.NotNil(t, desc.MicrosoftOS10) + assert.Equal(t, byte(microsoftOS10VendorCode), desc.MicrosoftOS10.VendorCode) + assert.Equal(t, uint8(2), desc.NumInterfaces()) + require.Empty(t, desc.Associations) + require.Len(t, desc.Interfaces, 2) + + hidIface := desc.Interfaces[0] + assert.Equal(t, byte(0x03), hidIface.Descriptor.BInterfaceClass) + require.NotNil(t, hidIface.HID) + require.Len(t, hidIface.Endpoints, 2) + assert.Equal(t, byte(EndpointHIDIn), hidIface.Endpoints[0].BEndpointAddress) + assert.Equal(t, byte(EndpointHIDOut), hidIface.Endpoints[1].BEndpointAddress) + report, err := hidIface.HID.ReportBytes() + require.NoError(t, err) + assert.Len(t, report, 97) + + bulkIface := desc.Interfaces[1] + assert.Equal(t, byte(0xFF), bulkIface.Descriptor.BInterfaceClass) + require.Len(t, bulkIface.Endpoints, 2) + assert.Equal(t, byte(EndpointBulkOut), bulkIface.Endpoints[0].BEndpointAddress) + assert.Equal(t, byte(EndpointBulkIn), bulkIface.Endpoints[1].BEndpointAddress) +} + +func TestCreateDeviceDeduplicatesSerial(t *testing.T) { + serials = map[string]struct{}{} + t.Cleanup(func() { + serials = map[string]struct{}{} + }) + + h := &handler{} + dev1, err := h.CreateDevice(nil) + require.NoError(t, err) + dev2, err := h.CreateDevice(nil) + require.NoError(t, err) + + ns1, ok := dev1.(*NS2Pro) + require.True(t, ok) + ns2, ok := dev2.(*NS2Pro) + require.True(t, ok) + + serial1 := ns1.GetDescriptor().Strings[3] + serial2 := ns2.GetDescriptor().Strings[3] + + assert.Equal(t, "00", serial1) + assert.Equal(t, "01", serial2) + assert.NotEqual(t, serial1, serial2) +} + +func TestBuildReport09(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + state := InputState{ + Buttons: ButtonB | ButtonA | ButtonX | ButtonZR | ButtonPlus | ButtonRightStick | ButtonDown | ButtonUp | ButtonL | ButtonZL | ButtonMinus | ButtonLeftStick | ButtonHome | ButtonCapture | ButtonGR | ButtonGL | ButtonC, + LX: 0x0001, + LY: 0x0002, + RX: 0x0FFE, + RY: 0x0FFF, + } + dev.UpdateInputState(state) + dev.SetMetaState(MetaState{ + SerialNumber: DefaultSerial, + BatteryLevel: 5, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + }) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, selectReportCommand(ReportIDPro)) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, enableReportsCommand()) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + + report := dev.HandleTransfer(context.Background(), 1, usbip.DirIn, nil) + require.Len(t, report, InputReportSize) + assert.Equal(t, byte(ReportIDPro), report[0]) + assert.Equal(t, byte(1), report[1]) + assert.Equal(t, byte(0x15), report[2]) + assert.Equal(t, []byte{0xEB, 0xF9, 0x1F}, report[3:6]) + assert.Equal(t, byte(0x30), report[12]) + + var leftStick [3]byte + var rightStick [3]byte + packStick12(leftStick[:], state.LX, state.LY) + packStick12(rightStick[:], state.RX, state.RY) + assert.Equal(t, leftStick[:], report[6:9]) + assert.Equal(t, rightStick[:], report[9:12]) +} + +func TestBulkCommands(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, enableReportsCommand()) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) + report := dev.HandleTransfer(context.Background(), 1, usbip.DirIn, nil) + require.Len(t, report, InputReportSize) + assert.Equal(t, byte(ReportIDCommon), report[0]) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, flashReadCommand(0x13000)) + resp := dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil) + require.Len(t, resp, 0x50) + assert.Equal(t, []byte{0x02, 0x01, 0x01, 0x01}, resp[0:4]) + assert.Equal(t, byte(0x40), resp[8]) + assert.Equal(t, uint32(0x13000), binary.LittleEndian.Uint32(resp[12:16])) + flash := resp[16:] + require.Len(t, flash, 64) + assert.Equal(t, "VIIPER-NS2PRO-00", string(flash[2:18])) +} + +func TestFlashSerialUsesMetaStateSerial(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + dev.SetMetaState(MetaState{ + SerialNumber: "CUSTOM-NS2PRO-AB", + BatteryLevel: BatteryMax, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + }) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, flashReadCommand(0x13000)) + resp := dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil) + require.Len(t, resp, 0x50) + + flash := resp[16:] + require.Len(t, flash, 64) + assert.Equal(t, "CUSTOM-NS2PRO-AB", string(flash[2:18])) +} + +func TestSDLUSBInitializationSequence(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + for _, address := range []uint32{0x13000, 0x13040, 0x13080, 0x130C0, 0x13100, 0x1FC040, 0x1FC080} { + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, flashReadCommand(address)) + resp := dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil) + require.Len(t, resp, 0x50, "flash block %#x", address) + assert.Equal(t, []byte{0x02, 0x01, 0x01, 0x01}, resp[0:4]) + assert.Equal(t, byte(0x40), resp[8]) + assert.Equal(t, address, binary.LittleEndian.Uint32(resp[12:16])) + } + + initSequence := [][]byte{ + {0x07, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}, + {0x0C, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}, + {0x11, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}, + {0x0A, 0x91, 0x00, 0x08, 0x00, 0x14, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x35, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x0C, 0x91, 0x00, 0x04, 0x00, 0x04, 0x00, 0x00, 0x27, 0x00, 0x00, 0x00}, + {0x01, 0x91, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x91, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}, + {0x08, 0x91, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00}, + {0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00}, + {0x03, 0x91, 0x00, 0x0D, 0x00, 0x08, 0x00, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + } + for _, cmd := range initSequence { + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, cmd) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil), "cmd %#x sub %#x", cmd[0], cmd[3]) + } + + dev.SetMetaState(MetaState{ + SerialNumber: DefaultSerial, + BatteryLevel: 9, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + }) + dev.UpdateInputState(InputState{}) + time.Sleep(2 * time.Millisecond) + report := dev.HandleTransfer(context.Background(), 1, usbip.DirIn, nil) + require.Len(t, report, InputReportSize) + assert.Equal(t, byte(ReportIDCommon), report[0]) + assert.Equal(t, uint32(1), binary.LittleEndian.Uint32(report[1:5])) + assert.NotZero(t, binary.LittleEndian.Uint32(report[0x2B:0x2F])) +} + +func TestRumbleOutput(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + var got OutputState + called := false + dev.SetOutputCallback(func(out OutputState) { + got = out + called = true + }) + + packet := make([]byte, OutputReportSize) + packet[0] = ReportIDOutput + for i := 0; i < 16; i++ { + packet[1+i] = byte(i) + packet[17+i] = byte(0x80 + i) + } + dev.HandleTransfer(context.Background(), 1, usbip.DirOut, packet) + + require.True(t, called) + assert.Equal(t, byte(OutputFlagRumble), got.Flags) + for i := 0; i < 16; i++ { + assert.Equal(t, byte(i), got.LeftRumble[i]) + assert.Equal(t, byte(0x80+i), got.RightRumble[i]) + } + + called = false + payload := make([]byte, OutputRumbleSize) + for i := 0; i < 16; i++ { + payload[i] = byte(0x40 + i) + payload[16+i] = byte(0xC0 + i) + } + _, handled := dev.HandleControl(0x21, 0x09, 0x0202, 0, 0, payload) + require.True(t, handled) + require.True(t, called) + assert.Equal(t, byte(OutputFlagRumble), got.Flags) + assert.Equal(t, byte(0x40), got.LeftRumble[0]) + assert.Equal(t, byte(0xC0), got.RightRumble[0]) +} + +func TestPlayerLEDOutput(t *testing.T) { + dev, err := New(nil) + require.NoError(t, err) + + var got OutputState + called := false + dev.SetOutputCallback(func(out OutputState) { + got = out + called = true + }) + + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, []byte{0x09, 0x91, 0x12, 0x07, 0x00, 0x08, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00}) + resp := dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil) + + require.True(t, called) + assert.Equal(t, byte(OutputFlagLED), got.Flags) + assert.Equal(t, byte(0x06), got.PlayerLedMask) + assert.Equal(t, []byte{0x09, 0x01, 0x12, 0x07}, resp[:4]) +} + +func TestMicrosoftOS10WinUSBDescriptor(t *testing.T) { + desc := MakeDescriptor() + require.NotNil(t, desc.MicrosoftOS10) + + resp, handled := desc.MicrosoftOS10.ControlResponse(0, 0x0004) + require.True(t, handled) + require.Len(t, resp, 40) + assert.Equal(t, uint32(40), binary.LittleEndian.Uint32(resp[0:4])) + assert.Equal(t, uint16(0x0100), binary.LittleEndian.Uint16(resp[4:6])) + assert.Equal(t, uint16(0x0004), binary.LittleEndian.Uint16(resp[6:8])) + assert.Equal(t, byte(0x01), resp[8]) + assert.Equal(t, byte(0x01), resp[16]) + assert.Equal(t, []byte("WINUSB\x00\x00"), resp[18:26]) +} + +func TestMicrosoftOS10ExtendedPropertiesDescriptor(t *testing.T) { + desc := MakeDescriptor() + require.NotNil(t, desc.MicrosoftOS10) + + resp, handled := desc.MicrosoftOS10.ControlResponse(0, 0x0005) + require.True(t, handled) + require.Len(t, resp, 142) + assert.Equal(t, uint32(142), binary.LittleEndian.Uint32(resp[0:4])) + assert.Equal(t, uint16(0x0100), binary.LittleEndian.Uint16(resp[4:6])) + assert.Equal(t, uint16(0x0005), binary.LittleEndian.Uint16(resp[6:8])) + assert.Equal(t, uint16(1), binary.LittleEndian.Uint16(resp[8:10])) + assert.Equal(t, uint32(132), binary.LittleEndian.Uint32(resp[10:14])) + assert.Equal(t, uint32(1), binary.LittleEndian.Uint32(resp[14:18])) + assert.Equal(t, uint16(40), binary.LittleEndian.Uint16(resp[18:20])) + assert.Equal(t, uint32(78), binary.LittleEndian.Uint32(resp[60:64])) + assert.Contains(t, utf16leToString(resp[20:60]), "DeviceInterfaceGUID") + assert.Contains(t, utf16leToString(resp[64:]), microsoftOS10DeviceInterfaceGUID) +} + +func TestStreamInputAndRumble(t *testing.T) { + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", apihandler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + require.NoError(t, s.ApiServer.Start()) + + b, err := virtualbus.NewWithBusID(1) + require.NoError(t, err) + defer b.Close() + require.NoError(t, s.UsbServer.AddBus(b)) + + client := viiperclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "ns2pro", nil) + require.NoError(t, err) + defer stream.Close() + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + require.NoError(t, err) + require.Len(t, devs, 1) + imp, err := usbipClient.AttachDevice(devs[0].BusID) + require.NoError(t, err) + defer imp.Conn.Close() + + productString, err := controlIn(imp.Conn, controlSetup(0x0302, 0, 64)) + require.NoError(t, err) + assert.Equal(t, usb.EncodeStringDescriptor("Switch 2 Pro Controller"), productString) + + serialString, err := controlIn(imp.Conn, controlSetup(0x0303, 0, 64)) + require.NoError(t, err) + assert.Equal(t, usb.EncodeStringDescriptor(DefaultSerialEnding), serialString) + + msOSString, err := controlIn(imp.Conn, controlSetup(0x03EE, 0, 18)) + require.NoError(t, err) + assert.Equal(t, []byte{ + 0x12, 0x03, + 'M', 0x00, + 'S', 0x00, + 'F', 0x00, + 'T', 0x00, + '1', 0x00, + '0', 0x00, + '0', 0x00, + microsoftOS10VendorCode, 0x00, + }, msOSString) + + config, err := controlIn(imp.Conn, controlSetup(0x0200, 0, 512)) + require.NoError(t, err) + require.Len(t, config, 64) + assert.Equal(t, []byte{0x09, 0x02, 0x40, 0x00, 0x02, 0x01, 0x04, 0xC0, 0xFA}, config[:9]) + assert.Equal(t, []byte{0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x05}, config[9:18]) + assert.Equal(t, []byte{0x09, 0x04, 0x01, 0x00, 0x02, 0xFF, 0x00, 0x00, 0x06}, config[41:50]) + + require.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 2, selectReportCommand(ReportIDPro), nil)) + require.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 2, enableReportsCommand(), nil)) + + state := InputState{ + Buttons: ButtonA | ButtonHome | ButtonRight, + LX: 0x0123, + LY: 0x0456, + RX: 0x0789, + RY: 0x0ABC, + } + require.NoError(t, stream.WriteBinary(&state)) + + expected := state.buildProReport(0, FeatureButtons|FeatureSticks, *defaultMetaState()) + got := pollInputIgnoringCounter(t, usbipClient, imp.Conn, expected, viiperTesting.IntegrationTimeout) + require.Len(t, got, InputReportSize) + got[1] = 0 + assert.Equal(t, expected, got) + + rumble := make([]byte, OutputReportSize) + rumble[0] = ReportIDOutput + for i := 0; i < 16; i++ { + rumble[1+i] = byte(0x10 + i) + rumble[17+i] = byte(0x90 + i) + } + require.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 1, rumble, nil)) + + var outBuf [OutputWireSize]byte + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) + _, err = io.ReadFull(stream, outBuf[:]) + require.NoError(t, err) + var out OutputState + require.NoError(t, out.UnmarshalBinary(outBuf[:])) + assert.Equal(t, byte(OutputFlagRumble), out.Flags) + assert.Equal(t, byte(0x10), out.LeftRumble[0]) + assert.Equal(t, byte(0x90), out.RightRumble[0]) +} + +func featureCommand(sub, flags uint8) []byte { + return []byte{0x0C, 0x91, 0x00, sub, 0x00, 0x04, 0x00, 0x00, flags, 0x00, 0x00, 0x00} +} + +func selectReportCommand(reportID uint8) []byte { + return []byte{cmdUSB, 0x91, 0x00, subUSBSelectReport, 0x00, 0x04, 0x00, 0x00, reportID, 0x00, 0x00, 0x00} +} + +func enableReportsCommand() []byte { + return []byte{cmdUSB, 0x91, 0x00, subUSBStartReports, 0x00, 0x08, 0x00, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} +} + +func flashReadCommand(address uint32) []byte { + cmd := []byte{0x02, 0x91, 0x01, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + binary.LittleEndian.PutUint32(cmd[12:16], address) + return cmd +} + +func mustHexBytes(t *testing.T, s string) []byte { + t.Helper() + out, err := hex.DecodeString(s) + require.NoError(t, err) + return out +} + +func utf16leToString(b []byte) string { + units := make([]uint16, 0, len(b)/2) + for i := 0; i+1 < len(b); i += 2 { + u := binary.LittleEndian.Uint16(b[i : i+2]) + if u == 0 { + break + } + units = append(units, u) + } + return string(utf16.Decode(units)) +} + +func controlSetup(wValue, wIndex, wLength uint16) [8]byte { // nolint:unparam + var setup [8]byte + setup[0] = 0x80 // bmRequestType + setup[1] = 0x06 // bRequest + binary.LittleEndian.PutUint16(setup[2:4], wValue) + binary.LittleEndian.PutUint16(setup[4:6], wIndex) + binary.LittleEndian.PutUint16(setup[6:8], wLength) + return setup +} + +func controlIn(conn net.Conn, setup [8]byte) ([]byte, error) { + cmd := usbip.CmdSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.CmdSubmitCode, Seqnum: 0xC001, Devid: 0, Dir: usbip.DirIn, Ep: 0}, + TransferBufferLen: uint32(binary.LittleEndian.Uint16(setup[6:8])), + Setup: setup, + } + _ = conn.SetDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) + defer conn.SetDeadline(time.Time{}) + if err := cmd.Write(conn); err != nil { + return nil, err + } + + var retHdr [48]byte + if err := usbip.ReadExactly(conn, retHdr[:]); err != nil { + return nil, err + } + if gotCmd := binary.BigEndian.Uint32(retHdr[0:4]); gotCmd != usbip.RetSubmitCode { + return nil, fmt.Errorf("unexpected ret cmd %x", gotCmd) + } + if status := int32(binary.BigEndian.Uint32(retHdr[20:24])); status != 0 { + return nil, fmt.Errorf("ret status %d", status) + } + actual := binary.BigEndian.Uint32(retHdr[24:28]) + data := make([]byte, int(actual)) + if actual > 0 { + if err := usbip.ReadExactly(conn, data); err != nil { + return nil, err + } + } + return data, nil +} + +func pollInputIgnoringCounter(t *testing.T, client *viiperTesting.TestUsbIpClient, conn net.Conn, want []byte, timeout time.Duration) []byte { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + got, err := client.ReadInputReport(conn) + require.NoError(t, err) + if len(got) == len(want) { + g := append([]byte(nil), got...) + w := append([]byte(nil), want...) + g[1] = 0 + w[1] = 0 + if assert.ObjectsAreEqual(w, g) { + return got + } + } + if time.Now().After(deadline) { + return got + } + time.Sleep(1 * time.Millisecond) + } +} diff --git a/device/options.go b/device/options.go new file mode 100644 index 00000000..519e5466 --- /dev/null +++ b/device/options.go @@ -0,0 +1,7 @@ +package device + +type CreateOptions struct { + IDVendor *uint16 + IDProduct *uint16 + DeviceSpecific string +} diff --git a/viiper/pkg/device/report.go b/device/report.go similarity index 97% rename from viiper/pkg/device/report.go rename to device/report.go index 23a74881..123dd83c 100644 --- a/viiper/pkg/device/report.go +++ b/device/report.go @@ -1,7 +1,7 @@ -package device - -// ReportBuilder is an interface for device input states that can build USB reports. -type ReportBuilder interface { - // BuildReport encodes the input state into a byte slice for USB transfer. - BuildReport() []byte -} +package device + +// ReportBuilder is an interface for device input states that can build USB reports. +type ReportBuilder interface { + // BuildReport encodes the input state into a byte slice for USB transfer. + BuildReport() []byte +} diff --git a/viiper/pkg/device/xbox360/const.go b/device/xbox360/const.go similarity index 96% rename from viiper/pkg/device/xbox360/const.go rename to device/xbox360/const.go index ff493c04..ef77adbe 100644 --- a/viiper/pkg/device/xbox360/const.go +++ b/device/xbox360/const.go @@ -1,20 +1,20 @@ -package xbox360 - -// Button bitmasks for Xbox 360 controller (XInput compatible) -const ( - ButtonDPadUp = 0x0001 - ButtonDPadDown = 0x0002 - ButtonDPadLeft = 0x0004 - ButtonDPadRight = 0x0008 - ButtonStart = 0x0010 - ButtonBack = 0x0020 - ButtonLThumb = 0x0040 // Left stick button - ButtonRThumb = 0x0080 // Right stick button - ButtonLShoulder = 0x0100 // Left bumper (LB) - ButtonRShoulder = 0x0200 // Right bumper (RB) - ButtonGuide = 0x0400 // Xbox/Guide button (center logo) - ButtonA = 0x1000 - ButtonB = 0x2000 - ButtonX = 0x4000 - ButtonY = 0x8000 -) +package xbox360 + +// Button bitmasks for Xbox 360 controller (XInput compatible) +const ( + ButtonDPadUp = 0x0001 + ButtonDPadDown = 0x0002 + ButtonDPadLeft = 0x0004 + ButtonDPadRight = 0x0008 + ButtonStart = 0x0010 + ButtonBack = 0x0020 + ButtonLThumb = 0x0040 // Left stick button + ButtonRThumb = 0x0080 // Right stick button + ButtonLShoulder = 0x0100 // Left bumper (LB) + ButtonRShoulder = 0x0200 // Right bumper (RB) + ButtonGuide = 0x0400 // Xbox/Guide button (center logo) + ButtonA = 0x1000 + ButtonB = 0x2000 + ButtonX = 0x4000 + ButtonY = 0x8000 +) diff --git a/device/xbox360/device.go b/device/xbox360/device.go new file mode 100644 index 00000000..d014726f --- /dev/null +++ b/device/xbox360/device.go @@ -0,0 +1,277 @@ +// Package xbox360 provides an Xbox 360 controller device implementation. +package xbox360 + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type Xbox360 struct { + tick uint64 + inputCh chan InputState + rumbleDispatchMu sync.Mutex + rumbleMu sync.Mutex + rumbleFunc func(XRumbleState) + rumbleState XRumbleState + rumbleSeen bool + descriptor usb.Descriptor +} + +type Xbox360CreateOptions struct { + SubType *uint8 `json:"subType"` +} + +// New returns a new Xbox360 device. +func New(o *device.CreateOptions) (*Xbox360, error) { + d := &Xbox360{ + descriptor: MakeDescriptor(), + } + if o != nil { + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor + } + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct + } + if o.DeviceSpecific != "" { + var args Xbox360CreateOptions + err := json.Unmarshal([]byte(o.DeviceSpecific), &args) + if err != nil { + return nil, fmt.Errorf("invalid JSON payload: %w", err) + } + if args.SubType != nil { + d.descriptor.Interfaces[0].ClassDescriptors[0].Payload[2] = *args.SubType + } + } + } + d.inputCh = make(chan InputState, 1) + d.inputCh <- *NewInputState() + return d, nil +} + +// SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. +func (x *Xbox360) SetRumbleCallback(f func(XRumbleState)) { + x.rumbleDispatchMu.Lock() + defer x.rumbleDispatchMu.Unlock() + + x.rumbleMu.Lock() + x.rumbleFunc = f + latest := x.rumbleState + replay := f != nil && x.rumbleSeen + x.rumbleMu.Unlock() + + if replay { + f(latest) + } +} + +// UpdateInputState updates the device's current input state (thread-safe). +func (x *Xbox360) UpdateInputState(state InputState) { + select { + case <-x.inputCh: + default: + } + x.inputCh <- state +} + +// HandleTransfer implements interrupt IN/OUT for Xbox360. +func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + if dir == usbip.DirIn { + switch ep { + case 1: // 0x81 - main input reports + atomic.AddUint64(&x.tick, 1) + select { + case <-ctx.Done(): + return nil + case st := <-x.inputCh: + return st.BuildReport() + } + default: + return nil + } + } + if dir == usbip.DirOut && ep == 1 { + // Host->Device output reports used by the wired Xbox 360 controller include + // an 8-byte rumble packet: [0]=ReportID(0x00), [1]=Len(0x08), [2]=Reserved/Status(0x00), + // [3]=Left (low-frequency/large) motor 0-255, [4]=Right (high-frequency/small) motor 0-255, + // [5..7]=Reserved (often 0x00). + // Some other outbound reports (e.g. LED control) use different IDs/lengths; we ignore those here. + if len(out) >= 8 && out[0] == 0x00 && out[1] == 0x08 { + x.emitRumble(XRumbleState{ + LeftMotor: out[3], // big / low-frequency motor + RightMotor: out[4], // small / high-frequency motor + }) + } + } + return nil +} + +func (x *Xbox360) emitRumble(rumble XRumbleState) { + x.rumbleDispatchMu.Lock() + defer x.rumbleDispatchMu.Unlock() + + x.rumbleMu.Lock() + x.rumbleState = rumble + x.rumbleSeen = true + rumbleFunc := x.rumbleFunc + x.rumbleMu.Unlock() + + if rumbleFunc != nil { + rumbleFunc(rumble) + } +} + +func MakeDescriptor() usb.Descriptor { + return usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0xff, + BDeviceSubClass: 0xff, + BDeviceProtocol: 0xff, + BMaxPacketSize0: 0x08, + IDVendor: 0x045e, + IDProduct: 0x028e, + BcdDevice: 0x0114, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x03, + BNumConfigurations: 0x01, + Speed: 2, // Full speed + }, + Interfaces: []usb.InterfaceConfig{ + // Interface 0: ff/5d/01 with 2 interrupt endpoints + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0xff, + BInterfaceSubClass: 0x5d, + BInterfaceProtocol: 0x01, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x21, + Payload: usb.Data{0x00, 0x01, 0x01, 0x25, 0x81, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 0x08, 0x00, 0x00}, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: 0x81, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x04}, + {BEndpointAddress: 0x01, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x08}, + }, + }, + // Interface 1: ff/5d/03 with 4 interrupt endpoints + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x01, + BAlternateSetting: 0x00, + BNumEndpoints: 0x04, + BInterfaceClass: 0xff, + BInterfaceSubClass: 0x5d, + BInterfaceProtocol: 0x03, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x21, + Payload: usb.Data{0x00, 0x01, 0x01, 0x01, 0x82, 0x40, 0x01, 0x02, 0x20, 0x16, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + {BEndpointAddress: 0x82, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x02}, + {BEndpointAddress: 0x02, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x04}, + {BEndpointAddress: 0x83, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x40}, + {BEndpointAddress: 0x03, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x10}, + }, + }, + // Interface 2: ff/5d/02 with 1 interrupt endpoint + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x00, + BNumEndpoints: 0x01, + BInterfaceClass: 0xff, + BInterfaceSubClass: 0x5d, + BInterfaceProtocol: 0x02, + IInterface: 0x00, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + { + DescriptorType: 0x21, + Payload: usb.Data{0x00, 0x01, 0x01, 0x22, 0x84, 0x07, 0x00}, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: 0x84, + BMAttributes: 0x03, + WMaxPacketSize: 0x0020, + BInterval: 0x10, + }, + }, + }, + // Interface 3: ff/fd/13 with vendor-specific descriptor + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x03, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0xff, + BInterfaceSubClass: 0xfd, + BInterfaceProtocol: 0x13, + IInterface: 0x04, + }, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x41, Payload: usb.Data{0x00, 0x01, 0x01, 0x03}}, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\u0409", // LangID: en-US (0x0409) + 1: "©Microsoft Corporation", + 2: "VIIPER Controller", //"Controller", + 3: "296013F", + }, + } +} + +func (x *Xbox360) GetDescriptor() *usb.Descriptor { + return &x.descriptor +} + +func (x *Xbox360) GetDeviceSpecificArgs() map[string]any { + return map[string]any{"subType": x.descriptor.Interfaces[0].ClassDescriptors[0].Payload[2]} +} + +func (x *Xbox360) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, _ []byte) ([]byte, bool) { + if bmRequestType == 0xC1 && bRequest == 0x01 && wValue == 0x0100 { + subType := x.descriptor.Interfaces[0].ClassDescriptors[0].Payload[2] + var extra [6]byte + switch subType { + case 0x01: // standard gamepad: vibration motor capabilities + extra = [6]byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00} + default: // drums, guitars, etc.: all extended bytes declared capable + extra = [6]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} + } + return []byte{ + 0x00, 0x14, // report ID, size + 0xFF, 0xFF, // all buttons supported + 0xFF, // LT + 0xFF, // RT + 0xFF, 0x7F, // LX + 0xFF, 0x7F, // LY + 0xFF, 0x7F, // RX + 0xFF, 0x7F, // RY + extra[0], extra[1], extra[2], extra[3], extra[4], extra[5], + }, true + } + return nil, false +} diff --git a/viiper/pkg/device/xbox360/handler.go b/device/xbox360/handler.go similarity index 74% rename from viiper/pkg/device/xbox360/handler.go rename to device/xbox360/handler.go index ba1138a9..04ae1068 100644 --- a/viiper/pkg/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -1,58 +1,64 @@ -package xbox360 - -import ( - "fmt" - "io" - "log/slog" - "net" - "viiper/internal/server/api" - "viiper/pkg/usb" -) - -func init() { - api.RegisterDevice("xbox360", &handler{}) -} - -type handler struct{} - -func (r *handler) CreateDevice() usb.Device { return New() } - -func (r *handler) StreamHandler() api.StreamHandlerFunc { - return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { - if devPtr == nil || *devPtr == nil { - return fmt.Errorf("nil device") - } - xdev, ok := (*devPtr).(*Xbox360) - if !ok { - return fmt.Errorf("device is not xbox360") - } - - xdev.SetRumbleCallback(func(rumble XRumbleState) { - data, err := rumble.MarshalBinary() - if err != nil { - logger.Error("failed to marshal rumble", "error", err) - return - } - if _, err := conn.Write(data); err != nil { - logger.Error("failed to send rumble", "error", err) - } - }) - - buf := make([]byte, 14) - for { - if _, err := io.ReadFull(conn, buf); err != nil { - if err == io.EOF { - logger.Info("client disconnected") - return nil - } - return fmt.Errorf("read input state: %w", err) - } - - var state InputState - if err := state.UnmarshalBinary(buf); err != nil { - return fmt.Errorf("unmarshal input state: %w", err) - } - xdev.UpdateInputState(state) - } - } -} +package xbox360 + +import ( + "fmt" + "io" + "log/slog" + "net" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +func init() { + api.RegisterDevice("xbox360", &handler{}) +} + +type handler struct{} + +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } + +func (h *handler) StreamHandler() api.StreamHandlerFunc { + return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { + if devPtr == nil || *devPtr == nil { + return fmt.Errorf("nil device") + } + xdev, ok := (*devPtr).(*Xbox360) + if !ok { + return fmt.Errorf("device is not xbox360") + } + + xdev.SetRumbleCallback(func(rumble XRumbleState) { + data, err := rumble.MarshalBinary() + if err != nil { + logger.Error("failed to marshal rumble", "error", err) + return + } + if _, err := conn.Write(data); err != nil { + logger.Error("failed to send rumble", "error", err) + } + }) + + buf := make([]byte, 20) + for { + if _, err := io.ReadFull(conn, buf); err != nil { + if err == io.EOF { + logger.Info("client disconnected") + return nil + } + return fmt.Errorf("read input state: %w", err) + } + + var state InputState + if err := state.UnmarshalBinary(buf); err != nil { + return fmt.Errorf("unmarshal input state: %w", err) + } + xdev.UpdateInputState(state) + } + } +} + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} diff --git a/viiper/pkg/device/xbox360/inputstate.go b/device/xbox360/inputstate.go similarity index 50% rename from viiper/pkg/device/xbox360/inputstate.go rename to device/xbox360/inputstate.go index cc61627e..9d78c0ee 100644 --- a/viiper/pkg/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -1,105 +1,128 @@ -package xbox360 - -import ( - "encoding/binary" - "io" -) - -// InputState represents the controller state used to build a report. -// Values are more or less XInput's C API -// viiper:wire xbox360 c2s buttons:u32 lt:u8 rt:u8 lx:i16 ly:i16 rx:i16 ry:i16 -type InputState struct { - // Button bitfield (lower 16 bits used typically), higher bits reserved - Buttons uint32 - // Triggers: 0-255 - LT, RT uint8 - // Sticks: signed 16-bit little endian values - LX, LY int16 - RX, RY int16 -} - -// BuildReport encodes an InputState into the 20-byte Xbox 360 USB report -// layout used by the wired controller. -// -// Bytes: -// -// 0: 0x00 (report id/message) -// 1: 0x14 (payload size 20) -// 2-7: buttons/reserved (we use first two bytes for button bitfield) -// 8: LT (0-255) -// 9: RT (0-255) -// -// 10-11: LX (LE int16) -// 12-13: LY (LE int16) -// 14-15: RX (LE int16) -// 16-17: RY (LE int16) -// 18-19: reserved 0x00 -func (st InputState) BuildReport() []byte { - b := make([]byte, 20) - b[0] = 0x00 - b[1] = 0x14 - binary.LittleEndian.PutUint16(b[2:4], uint16(st.Buttons&0xffff)) - b[8] = st.LT - b[9] = st.RT - binary.LittleEndian.PutUint16(b[10:12], uint16(st.LX)) - binary.LittleEndian.PutUint16(b[12:14], uint16(st.LY)) - binary.LittleEndian.PutUint16(b[14:16], uint16(st.RX)) - binary.LittleEndian.PutUint16(b[16:18], uint16(st.RY)) - return b -} - -// MarshalBinary encodes InputState to 14 bytes. -func (x *InputState) MarshalBinary() ([]byte, error) { - b := make([]byte, 14) - binary.LittleEndian.PutUint32(b[0:4], x.Buttons) - b[4] = x.LT - b[5] = x.RT - binary.LittleEndian.PutUint16(b[6:8], uint16(x.LX)) - binary.LittleEndian.PutUint16(b[8:10], uint16(x.LY)) - binary.LittleEndian.PutUint16(b[10:12], uint16(x.RX)) - binary.LittleEndian.PutUint16(b[12:14], uint16(x.RY)) - return b, nil -} - -// UnmarshalBinary decodes 14 bytes into InputState. -func (x *InputState) UnmarshalBinary(data []byte) error { - if len(data) < 14 { - return io.ErrUnexpectedEOF - } - x.Buttons = binary.LittleEndian.Uint32(data[0:4]) - x.LT = data[4] - x.RT = data[5] - x.LX = int16(binary.LittleEndian.Uint16(data[6:8])) - x.LY = int16(binary.LittleEndian.Uint16(data[8:10])) - x.RX = int16(binary.LittleEndian.Uint16(data[10:12])) - x.RY = int16(binary.LittleEndian.Uint16(data[12:14])) - return nil -} - -// XRumbleState is the wire format for rumble/motor commands sent from device to client. -// Total size: 2 bytes (fixed). -// Layout: -// -// LeftMotor: 1 byte (0-255) -// RightMotor: 1 byte (0-255) -// -// viiper:wire xbox360 s2c left:u8 right:u8 -type XRumbleState struct { - LeftMotor uint8 - RightMotor uint8 -} - -// MarshalBinary encodes XRumbleState to 2 bytes. -func (r *XRumbleState) MarshalBinary() ([]byte, error) { - return []byte{r.LeftMotor, r.RightMotor}, nil -} - -// UnmarshalBinary decodes 2 bytes into XRumbleState. -func (r *XRumbleState) UnmarshalBinary(data []byte) error { - if len(data) < 2 { - return io.ErrUnexpectedEOF - } - r.LeftMotor = data[0] - r.RightMotor = data[1] - return nil -} +package xbox360 + +import ( + "encoding/binary" + "io" +) + +// InputState represents the controller state used to build a report. +// Values are more or less XInput's C API +// viiper:wire xbox360 c2s buttons:u32 lt:u8 rt:u8 lx:i16 ly:i16 rx:i16 ry:i16 reserved:u8*6 +type InputState struct { + // Button bitfield (lower 16 bits used typically), higher bits reserved + Buttons uint32 + // Triggers: 0-255 + LT, RT uint8 + // Sticks: signed 16-bit little endian values + LX, LY int16 + RX, RY int16 + Reserved [6]byte +} + +// NewInputState returns an Xbox 360 input state in its neutral/resting state. +func NewInputState() *InputState { return &InputState{} } + +// nolint +// viiper:wire xbox360guitarherodrums c2s buttons:u32 _:u8 _:u8 greenVelocity:u8 redVelocity:u8 yellowVelocity:u8 blueVelocity:u8 orangeVelocity:u8 kickVelocity:u8 midiPacket:u8*6 +type GuitarHeroDrumsInputState struct { + // Button bitfield (lower 16 bits used typically), higher bits reserved + Buttons uint32 + _, _ uint8 + + // Drum pad velocities, unsigned 7 bit, based on MIDI + GreenVelocity uint8 + RedVelocity uint8 + YellowVelocity uint8 + BlueVelocity uint8 + OrangeVelocity uint8 + KickVelocity uint8 + // MIDI packet, used for unrecognised midi notes received by the drums + MidiPacket [6]byte +} + +// BuildReport encodes an InputState into the 20-byte Xbox 360 wired USB input report. +// Layout (indices in the returned slice): +// +// 0: 0x00 - Report ID +// 1: 0x14 - Payload size (20 bytes) +// 2: Buttons (low byte) +// 3: Buttons (high byte) +// 4: LT (0-255) +// 5: RT (0-255) +// 6-7: LX (little-endian int16) +// 8-9: LY (little-endian int16) +// 10-11: RX (little-endian int16) +// 12-13: RY (little-endian int16) +// 14-19: Reserved / zero +func (x *InputState) BuildReport() []byte { + b := make([]byte, 20) + b[0] = 0x00 + b[1] = 0x14 + binary.LittleEndian.PutUint16(b[2:4], uint16(x.Buttons&0xffff)) + b[4] = x.LT + b[5] = x.RT + binary.LittleEndian.PutUint16(b[6:8], uint16(x.LX)) + binary.LittleEndian.PutUint16(b[8:10], uint16(x.LY)) + binary.LittleEndian.PutUint16(b[10:12], uint16(x.RX)) + binary.LittleEndian.PutUint16(b[12:14], uint16(x.RY)) + copy(b[14:19], x.Reserved[:]) + return b +} + +// MarshalBinary encodes InputState to 20 bytes. +func (x *InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, 20) + binary.LittleEndian.PutUint32(b[0:4], x.Buttons) + b[4] = x.LT + b[5] = x.RT + binary.LittleEndian.PutUint16(b[6:8], uint16(x.LX)) + binary.LittleEndian.PutUint16(b[8:10], uint16(x.LY)) + binary.LittleEndian.PutUint16(b[10:12], uint16(x.RX)) + binary.LittleEndian.PutUint16(b[12:14], uint16(x.RY)) + copy(b[14:19], x.Reserved[:]) + return b, nil +} + +// UnmarshalBinary decodes 20 bytes into InputState. +func (x *InputState) UnmarshalBinary(data []byte) error { + if len(data) < 20 { + return io.ErrUnexpectedEOF + } + x.Buttons = binary.LittleEndian.Uint32(data[0:4]) + x.LT = data[4] + x.RT = data[5] + x.LX = int16(binary.LittleEndian.Uint16(data[6:8])) + x.LY = int16(binary.LittleEndian.Uint16(data[8:10])) + x.RX = int16(binary.LittleEndian.Uint16(data[10:12])) + x.RY = int16(binary.LittleEndian.Uint16(data[12:14])) + copy(x.Reserved[:], data[14:19]) + return nil +} + +// XRumbleState is the wire format for rumble/motor commands sent from device to client. +// Total size: 2 bytes (fixed). +// Layout: +// +// LeftMotor: 1 byte (0-255) +// RightMotor: 1 byte (0-255) +// +// viiper:wire xbox360 s2c left:u8 right:u8 +type XRumbleState struct { + LeftMotor uint8 + RightMotor uint8 +} + +// MarshalBinary encodes XRumbleState to 2 bytes. +func (r *XRumbleState) MarshalBinary() ([]byte, error) { + return []byte{r.LeftMotor, r.RightMotor}, nil +} + +// UnmarshalBinary decodes 2 bytes into XRumbleState. +func (r *XRumbleState) UnmarshalBinary(data []byte) error { + if len(data) < 2 { + return io.ErrUnexpectedEOF + } + r.LeftMotor = data[0] + r.RightMotor = data[1] + return nil +} diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go new file mode 100644 index 00000000..82fb55b3 --- /dev/null +++ b/device/xbox360/xbox360_test.go @@ -0,0 +1,518 @@ +package xbox360_test + +import ( + "context" + "io" + "testing" + "time" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register devices +) + +func TestInputReports(t *testing.T) { + + type testCase struct { + name string + inputState xbox360.InputState + expectedReport []byte + } + + cases := []testCase{ + { + name: "button dpad up", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadUp, + }, + expectedReport: []byte{0x00, 0x14, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "button dpad down", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadDown, + }, + expectedReport: []byte{0x00, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button dpad left", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadLeft, + }, + expectedReport: []byte{0x00, 0x14, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button dpad right", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadRight, + }, + expectedReport: []byte{0x00, 0x14, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button start", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonStart, + }, + expectedReport: []byte{0x00, 0x14, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button back", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonBack, + }, + expectedReport: []byte{0x00, 0x14, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button lthumb", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonLThumb, + }, + expectedReport: []byte{0x00, 0x14, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button rthumb", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonRThumb, + }, + expectedReport: []byte{0x00, 0x14, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button lshoulder", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonLShoulder, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button rshoulder", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonRShoulder, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button guide", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonGuide, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button a", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonA, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button b", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonB, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button x", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonX, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "button y", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonY, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lt min", + inputState: xbox360.InputState{ + LT: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lt mid", + inputState: xbox360.InputState{ + LT: 128, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lt max", + inputState: xbox360.InputState{ + LT: 255, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rt min", + inputState: xbox360.InputState{ + RT: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rt mid", + inputState: xbox360.InputState{ + RT: 128, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rt max", + inputState: xbox360.InputState{ + RT: 255, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lx min", + inputState: xbox360.InputState{ + LX: -32768, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lx mid", + inputState: xbox360.InputState{ + LX: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "lx max", + inputState: xbox360.InputState{ + LX: 32767, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ly min", + inputState: xbox360.InputState{ + LY: -32768, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ly mid", + inputState: xbox360.InputState{ + LY: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ly max", + inputState: xbox360.InputState{ + LY: 32767, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rx min", + inputState: xbox360.InputState{ + RX: -32768, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rx mid", + inputState: xbox360.InputState{ + RX: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "rx max", + inputState: xbox360.InputState{ + RX: 32767, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ry min", + inputState: xbox360.InputState{ + RY: -32768, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ry mid", + inputState: xbox360.InputState{ + RY: 0, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "ry max", + inputState: xbox360.InputState{ + RY: 32767, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "buttons combo", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonA | xbox360.ButtonB | xbox360.ButtonStart | xbox360.ButtonDPadUp, + }, + expectedReport: []byte{0x00, 0x14, 0x11, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "axes combo", + inputState: xbox360.InputState{ + LT: 255, + RT: 128, + LX: -32768, + LY: 32767, + RX: 1234, + RY: -4321, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0x00, 0xff, 0x80, 0x00, 0x80, 0xff, 0x7f, 0xd2, 0x04, 0x1f, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + + { + name: "buttons axes combo", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonX | xbox360.ButtonY | xbox360.ButtonLShoulder | xbox360.ButtonRShoulder, + LT: 1, + RT: 254, + LX: 111, + LY: -222, + RX: 333, + RY: -444, + }, + expectedReport: []byte{0x00, 0x14, 0x00, 0xc3, 0x01, 0xfe, 0x6f, 0x00, 0x22, 0xff, 0x4d, 0x01, 0x44, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() //nolint:errcheck + defer s.ApiServer.Close() //nolint:errcheck + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() //nolint:errcheck + _ = s.UsbServer.AddBus(b) + + client := viiperclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "xbox360", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() //nolint:errcheck + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, devs, 1) { + return + } + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if imp != nil && imp.Conn != nil { + defer imp.Conn.Close() //nolint:errcheck + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedReport, tc.inputState.BuildReport()) + if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { + return + } + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.expectedReport, got) + }) + } + +} + +func TestRumble(t *testing.T) { + + type testCase struct { + name string + rumbleState xbox360.XRumbleState + outPacket []byte + } + cases := []testCase{ + { + name: "off", + rumbleState: xbox360.XRumbleState{ + LeftMotor: 0, + RightMotor: 0, + }, + outPacket: []byte{0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "mid", + rumbleState: xbox360.XRumbleState{ + LeftMotor: 128, + RightMotor: 128, + }, + outPacket: []byte{0x00, 0x08, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00}, + }, + { + name: "full", + rumbleState: xbox360.XRumbleState{ + LeftMotor: 255, + RightMotor: 255, + }, + outPacket: []byte{0x00, 0x08, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00}, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() //nolint:errcheck + defer s.ApiServer.Close() //nolint:errcheck + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() //nolint:errcheck + _ = s.UsbServer.AddBus(b) + + client := viiperclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "xbox360", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() //nolint:errcheck + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, devs, 1) { + return + } + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if imp != nil && imp.Conn != nil { + defer imp.Conn.Close() //nolint:errcheck + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !assert.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 1, tc.outPacket, nil)) { + return + } + var buf [2]byte + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) + _, err := io.ReadFull(stream, buf[:]) + if !assert.NoError(t, err) { + return + } + got := xbox360.XRumbleState{LeftMotor: buf[0], RightMotor: buf[1]} + assert.Equal(t, tc.rumbleState, got) + }) + } + +} + +func TestRumbleCallbackReplaysLatestHostState(t *testing.T) { + dev, err := xbox360.New(nil) + require.NoError(t, err) + + // The all-zero state is the first packet Windows normally sends. Keep an + // explicit seen bit so it is replayed even though it equals the Go zero value. + dev.HandleTransfer(context.Background(), 1, usbip.DirOut, + []byte{0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) + + gotCh := make(chan xbox360.XRumbleState, 1) + dev.SetRumbleCallback(func(rumble xbox360.XRumbleState) { + gotCh <- rumble + }) + + select { + case got := <-gotCh: + assert.Equal(t, xbox360.XRumbleState{}, got) + case <-time.After(viiperTesting.IntegrationTimeout): + t.Fatal("expected late callback to receive latest host rumble state") + } +} + +func TestRumbleCallbackDoesNotInventInitialFeedback(t *testing.T) { + dev, err := xbox360.New(nil) + require.NoError(t, err) + + gotCh := make(chan xbox360.XRumbleState, 1) + dev.SetRumbleCallback(func(rumble xbox360.XRumbleState) { + gotCh <- rumble + }) + + select { + case got := <-gotCh: + t.Fatalf("unexpected feedback before host output: %+v", got) + default: + } +} diff --git a/docs/api/overview.md b/docs/api/overview.md index 505ed03d..586960ce 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -1,184 +1,406 @@ # API Reference -VIIPER ships a lightweight TCP API for managing virtual buses/devices and for device-specific streaming. It's designed to be trivial to drive from any language that can open a TCP socket and send newline-terminated commands. + + + + +VIIPER provides a lightweight TCP API for managing and controlling virtual buses/devices. +It's designed to be trivial to drive from any language that can open a TCP socket and send null-byte-terminated payloads. + +!!! tip "Client Libraries Available" + Client libraries are available that abstract away any protocol details described below. + For most use cases, you should use one of the provided client libraries rather than implementing the raw protocol yourself: -!!! tip "Client SDKs Available" - Generated client libraries are available that abstract away the protocol details described below. For most use cases, you should use one of the provided SDKs rather than implementing the raw protocol yourself: - - - [C SDK](../clients/c.md): Generated C library with type-safe device streams - [Go Client](../clients/go.md): Reference implementation included in the repository - - [Generator Documentation](../clients/generator.md): Information about code generation for additional languages + - [Generator Documentation](../clients/generator.md): Information about code generation + - [C++ Client Library](../clients/cpp.md): Header-only C++20 library (requires external JSON parser) + - [C# Client Library](../clients/csharp.md): Generated .NET library with async/await support + - [TypeScript Client Library](../clients/typescript.md): Generated Node.js library with EventEmitter streams + - [Rust Client Library](../clients/rust.md): Generated Rust library with sync/async support + - The documentation below is provided for reference and for implementing clients in languages not yet supported by the generator. + The documentation below is provided for reference and for implementing clients in languages not supported officially. ## Protocol overview -- Transport: TCP -- Default listen address: `:3242` (configurable via `--api.addr`) -- Request format: a single ASCII/UTF‑8 line terminated by `\n` -- Routing: first token is the path (e.g. `bus/list`), remaining tokens are arguments (space-separated) -- Success response: a single line containing a JSON payload (or an empty line for commands that have no payload) -- Error response: a single line JSON object `{ "error": "message" }` - -Tip: You can experiment with `nc`/`ncat` or PowerShell’s `tcpclient` to send lines and read JSON back. +The TCP API is inspired by the ubiquitous HTTP REST style, but is more lightweight. +If you ever worked with HTTP APIs before, you'll feel right at home. +The exception to this are the device-control and feedback streams, which are raw binary streams specific to each device type. + +- **Transport**: TCP with optional encryption (ChaCha20-Poly1305) +- **Default listen address**: `:3242` (configurable via `--api.addr`) +- **Authentication**: Required for remote connections, optional for localhost (password-based with HMAC validation) +- **Encryption**: Automatic for authenticated connections (ChaCha20-Poly1305 with unique session keys) +- **Request format**: a single ASCII/UTF‑8 line terminated by `\0` +- **Routing**: path followed by optional payload separated by whitespace + (e.g., `bus/list\0` or `bus/create 5\0`) +- **Payload**: optional string that can be a JSON object, numeric value, or plain string depending on the endpoint. + The payload may contain newlines (e.g., pretty-printed JSON) as only the null byte terminates the request. +- **Success response**: a single line containing a JSON payload (or an empty line for commands that have no payload), terminated by connection close +- **Error response**: a single line JSON object following RFC 7807 Problem Details format with a `status` field (HTTP-style status code) and other error details, terminated by connection close + +!!! tip "Testing the API" + For quick testing, you can use tools like `netcat` (Linux/macOS) or PowerShell scripts (Windows) to send requests and read responses. !!! warning "Connection timing and auto‑cleanup" After you add a device with `bus/{id}/add`, you must connect to its streaming endpoint within the configured `DeviceHandlerConnectTimeout` (default: 5s). If no stream connection is established in time, the device is automatically removed. Likewise, when a stream disconnects, a reconnection timer with the same timeout starts; if the client doesn’t reconnect before it expires, the device is removed. -## Commands +!!! warning "Authentication Required for Remote Connections" + **VIIPER requires authentication for all non-localhost connections.** -The server registers the following commands and streams: + - **Localhost clients** (`127.0.0.1`, `::1`, `localhost`): Authentication is **optional** (but supported) by default + - **Remote clients**: Authentication is **required** and enforced + + On first start, VIIPER generates a random password + and saves it to `/viiper.key.txt`. + Windows: `%APPDATA%\VIIPER\viiper.key.txt` + Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` + Linux (root/systemd): `/etc/viiper/viiper.key.txt` -- `bus/list` - - List all virtual bus IDs. - - Response: `{ "buses": [1, 2, ...] }` + Remote clients must provide this password to establish a connection. -- `bus/create [busId]` - - Create a new bus. If `busId` is provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. - - Response: `{ "busId": }` + See the [Configuration](../cli/configuration.md) documentation for details on password management and the `--api.require-localhost-auth` option. -- `bus/remove ` - - Remove a bus and all devices on it. - - Response: `{ "busId": }` +## Endpoints -- `bus/{id}/list` - - List devices on a bus. - - Response: `{ "devices": [{ "busId": 1, "devId": "1", "vid": "0x045e", "pid": "0x028e", "type": "xbox360" }, ...] }` +!!! info "null byte excluded" + The `\0` (null byte) terminator is excluded from all examples below for readability. + All requests must be terminated with a null byte (unless otherwise noted). -- `bus/{id}/add ` - - Add a device to a bus. `deviceType` is a registered device name (e.g., `xbox360`). - - Response: `{ "id": "-" }` where the id is the USBIP busid string you will attach to. - - Important: After add, the server starts a connect timer (default `5s`). You must open a device stream (see below) before the timeout expires, otherwise the device is auto-removed. +
-- `bus/{id}/remove ` - - Remove a device by its device number on that bus (the part after the dash in the busid string). - - Response: `{ "busId": , "devId": "" }` +- **Bus Management** + + --- -### Streaming endpoint + Create, list, and remove virtual buses -- Path: `bus/{busId}/{deviceid}` -- Type: long-lived TCP connection (no line protocol once established) -- Purpose: device-specific, bidirectional stream. The API server hands the socket to the device’s registered stream handler. -- Timeout behavior: When a stream ends, a reconnect timer is started (same `DeviceHandlerConnectTimeout`). If the client doesn’t reconnect in time, the device is removed. + [Jump to section](#bus-management) -#### Xbox 360 controller stream (device type: `xbox360`) +- **Device Management** + + --- -Direction: client ➜ server (input state) + Add, list, and remove devices on a bus -- Fixed 14-byte packets, little-endian layout: - - `Buttons` uint32 (4 bytes) - - `LT` uint8, `RT` uint8 (2 bytes) - - `LX, LY, RX, RY` int16 each (8 bytes) + [Jump to section](#device-management) -Direction: server ➜ client (rumble) +- **Device Control / Feedback** + + --- -- Fixed 2-byte packets: - - `LeftMotor` uint8, `RightMotor` uint8 + Real-time input and feedback streams for devices -See `pkg/device/xbox360/protocol.go` for full details. + [Jump to section](#device-control--feedback) -#### HID keyboard stream (device type: `keyboard`) +- **Error Handling** + + --- -Direction: client ➜ server (keys pressed) + Error response format and common error codes -- Variable-length packets per frame: - - Header: Modifiers uint8, KeyCount uint8 - - Body: KeyCount bytes of HID Usage IDs for currently pressed (non-modifier) keys + [Jump to section](#error-handling) -Direction: server ➜ client (LED state) +
-- 1-byte packets whenever host LED state changes: - - Bit 0 NumLock, Bit 1 CapsLock, Bit 2 ScrollLock +### Bus Management {#bus-management} -Host-facing HID input report is 34 bytes: [Modifiers (1), Reserved (1), 256-bit key bitmap (32)]. +#### `ping` {.toc-anchor} -See `pkg/device/keyboard/` for helpers and constants. +??? info "ping - Simple identity and version check" + **Request:** `ping` -#### HID mouse stream (device type: `mouse`) + **Response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` -Direction: client ➜ server (motion/buttons) +#### `bus/list` {.toc-anchor} -- Fixed 5-byte packets per frame: - - Buttons uint8 (bits 0..4) - - dX int8, dY int8 - - Wheel int8, Pan int8 +??? info "bus/list - List all virtual bus IDs" + **Request:** `bus/list` -Direction: server ➜ client + **Response:** `{ "buses": [1, 2, ...] }` -- None (mouse is input-only) +#### `bus/create [busId]` {.toc-anchor} -Note: Motion and wheel deltas are consumed after each IN report so movement is relative. +??? info "bus/create - Create a new bus" + **Request:** `bus/create` or `bus/create 5` -Note on protocol compatibility: + **Payload:** Optional numeric bus ID (e.g., `5`) + If provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. + + **Response:** `{ "busId": }` -- The wire format is modeled after the XInput gamepad state (XINPUT_GAMEPAD) but is not byte‑for‑byte identical. Key differences: - - Buttons are encoded as a 32‑bit little‑endian field (XInput uses a 16‑bit bitmask), making the packet 14 bytes instead of 12. - - No header or framing: packets are fixed‑length and back‑to‑back on the TCP stream. - - Endianness is little‑endian for all multi‑byte fields. +#### `bus/remove ` {.toc-anchor} -## Example sessions +??? info "bus/remove - Remove a bus and all devices on it" + **Request:** `bus/remove 1` -### Using netcat (Linux/macOS) + **Payload:** Numeric bus ID (e.g., `1`) + + **Response:** `{ "busId": }` + +### Device Management {#device-management} + +#### `bus/{id}/list` {.toc-anchor} + +??? info "bus/{id}/list - List devices on a bus" + **Request:** `bus/1/list` + + **Response:** + ```json + { + "devices": [ + { + "busId": 1, + "devId": "1", + "vid": "0x045e", + "pid": "0x028e", + "type": "xbox360" + "deviceSpecific": { + "subType": 1 + } + } + ] + } + ``` + +#### `bus/{id}/add ` {.toc-anchor} + +??? info "bus/{id}/add - Add a device to a bus" + **Request:** `bus/1/add {"type":"xbox360"}` + + **Payload:** JSON object with device creation parameters + ```json + { + "type": "", + "idVendor": , + "idProduct": , + "deviceSpecific": + } + ``` + + **Examples:** + - `{"type":"xbox360"}` + - `{"type":"keyboard","idVendor":1234,"idProduct":5678}` + - `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` + + **Response:** + ```json + { + "busId": 1, + "devId": "1", + "vid": "0x045e", + "pid": "0x028e", + "type": "xbox360", + "deviceSpecific": { + "subType":7 + } + } + ``` + + !!! warning "Connection timeout" + After add, the server starts a connect timer (default `5s`). You must open a device stream before the timeout expires, otherwise the device is auto-removed. + + !!! info "Auto-attach" + If [auto-attach](../cli/server.md#api.auto-attach-local-client) is enabled (default), the server automatically attaches the new device to a local USBIP client on the same host (localhost only). Failures are logged but do not affect the API response. -```bash -# List buses -printf "bus/list\n" | nc localhost 3242 +#### `bus/{id}/remove ` {.toc-anchor} -# Create a bus -printf "bus/create\n" | nc localhost 3242 -# → {"busId":1} +??? info "bus/{id}/remove - Remove a device from a bus" + **Request:** `bus/1/remove 1` -# Add a virtual Xbox 360 controller to bus 1 -printf "bus/1/add xbox360\n" | nc localhost 3242 -# → {"id":"1-1"} + **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) + + **Response:** `{ "busId": , "devId": "" }` -# List devices on bus 1 -printf "bus/1/list\n" | nc localhost 3242 -``` +### Device Control / Feedback {#device-control--feedback} + +Device Control and Feedback requires an initial "handshake" request, afterwards the connection is used as a long-lived (device-specific, binary) bidirectional stream. + +!!! info "Establish control/feedback connection/stream" + **Path:** `bus/{busId}/{deviceId}` -Then, open a second TCP connection for streaming to `bus/1/1` (the API port, not the USBIP port). You’ll write 14‑byte input packets and read 2‑byte rumble packets. Any language with raw TCP support works. + **Handshake:** Send the path followed by `\0` (null byte) + Example: `bus/1/1\0` + + **Type:** Long-lived TCP connection + + **Purpose:** Device-specific, bidirectional stream. + + !!! warning "Timeout behavior" + When a stream ends, a reconnect timer is started. + If the client doesn't reconnect in time, the device is removed. -### WIndows (PowerShell) +Device control and feedback is **device-specific**. +Each device type defines it's own packet formats. -VIIPER includes convenience scripts for quick testing and automation: +**In general** the client (your code) sends sends binary input state packets to the VIIPER server and possibly receives binary feedback packets (rumble, keyboard leds, etc.) back. -```powershell -# Source the script to load helper functions -. .\scripts\viiper-api.ps1 +Refer to the individual [device documentation](../devices/overview.md) for details on packet formats and behavior. -# Use the Invoke-ViiperApi function (or 'viiper' alias) -viiper "bus/list" -viiper "bus/create" -viiper "bus/1/add xbox360" -Port 3242 -Hostname localhost +### Error Handling {#error-handling} + +All errors are inspired by HTTP REST APIs and are returned as single-line JSON objects in the style of [RFC 7807 Problem Details](https://tools.ietf.org/html/rfc7807). +The connection closes immediately after the error response. + +If you have ever worked with HTTP APIs, the errors and status codes will feel familiar. + +#### Error Response Format + +```json +{ + "status": 400, + "title": "Bad Request", + "detail": "missing payload" +} ``` -The script provides `Invoke-ViiperApi` (alias: `viiper`) for sending commands and `Connect-ViiperDevice` for testing persistent device connections. +**Fields:** -### Go snippet (raw) +- `status` (number): HTTP-style status code indicating the error type +- `title` (string): Short, human-readable summary of the problem +- `detail` (string): Explanation specific to this occurrence + +#### Common Error Codes + +Error codes are basically HTTP carbon copies: + +| Status | Title | Cause | Example | +|--------|-------|-------|---------| +| 400 | Bad Request | Invalid request format, missing payload, or invalid JSON | Missing device type in `bus/{id}/add`, invalid busId format | +| 404 | Not Found | Resource does not exist | Bus ID not found, device ID not found | +| 409 | Conflict | Resource already exists or cannot be modified | Bus ID already exists, auto-attach failure | +| 500 | Internal Server Error | (Unhandled) Server-side error during operation | Failed to marshal response, device add failure, unknown error | + +## Example sessions + +=== "PowerShell (Windows)" + + VIIPER includes convenience scripts for quick testing and automation: + + ```powershell + # Source the script to load helper functions + . .\scripts\viiper-api.ps1 + + # Use the Invoke-ViiperApi function (or 'viiper' alias) + viiper "bus/list" + viiper "bus/create" + viiper "bus/1/add {\"type\":\"xbox360\"}" -Port 3242 -Hostname localhost + ``` + + The script provides `Invoke-ViiperApi` (alias: `viiper`) for sending commands and `Connect-ViiperDevice` for testing persistent device connections. + +=== "netcat (Linux/macOS)"" + + ```bash + # List buses + printf "bus/list\0" | nc localhost 3242 + + # Create a bus + printf "bus/create\0" | nc localhost 3242 + # → {"busId":1} + + # Create a bus with specific ID + printf "bus/create 5\0" | nc localhost 3242 + # → {"busId":5} + + # Add a virtual Xbox 360 controller to bus 1 + printf 'bus/1/add {"type":"xbox360"}\0' | nc localhost 3242 + # → {"busId":1,"devId":"1","vid":"0x045e","pid":"0x028e","type":"xbox360"} + + # List devices on bus 1 + printf "bus/1/list\0" | nc localhost 3242 + ``` + +Then, open a second TCP connection for device control to `bus/1/1` (the API port, not the USBIP port). +First send the "handshake" `bus/1/1\0`, +then you'll write device-specific input packets and read device-specific feedback packets. +Any language with raw TCP support works. + +### Go snippet (raw TCP Socket) ```go package main import ( - "bufio" "fmt" + "io" "net" ) func main() { conn, _ := net.Dial("tcp", "localhost:3242") defer conn.Close() - fmt.Fprintln(conn, "bus/create") - r := bufio.NewReader(conn) - line, _ := r.ReadString('\n') - fmt.Println(line) // {"busId":1} + + // Send request with null terminator + fmt.Fprint(conn, "bus/create\x00") + + // Read entire response until connection closes + resp, _ := io.ReadAll(conn) + fmt.Println(string(resp)) // {"busId":1}\n } ``` -For a higher-level experience, see the Go client in `pkg/apiclient/`. +For a higher-level experience, see the Go client in `/apiclient/`. ## How this relates to USBIP -The API controls which virtual devices exist and exposes a device stream for live input/feedback. Separately, the USBIP server (default `:3241`) makes these devices attachable from clients. Typical flow: +The VIIPER API controls which virtual devices exist and exposes a device stream for live input/feedback. +Separately, the USBIP server (default `:3241`) makes these devices attachable from USBIP clients. + +Typical flow: + +1. Create a bus +2. Add a device +3. Connect the device stream +4. Attach a USBIP client to the device -1) Create a bus ➜ 2) Add a device ➜ 3) Connect the device stream ➜ 4) From a client, attach using USBIP by `busid` (see the Server command page for exact `usbip` syntax). +If auto-attach is enabled step 4 is attempted automatically for the local host; you still must perform step 3 to keep the device alive. diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index abef8dbf..01b271ab 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -1,130 +1,75 @@ # Code Generation Command -The `codegen` command generates type-safe client SDKs from Go source code annotations. +The `codegen` command generates type-safe client libraries from Go source code annotations. +The command can only be run from the VIIPER repository root with the source code available. -## Usage - -```bash -viiper codegen [flags] -``` +It scans the VIIPER server codebase to extract: -## Description - -Scans the VIIPER server codebase to extract: - API routes and response DTOs - Device wire formats from `viiper:wire` comment tags - Device constants (keycodes, modifiers, button masks) -Then generates client SDKs for C, C#, and TypeScript with: +Then generates client libraries with: + - Management API clients - Device-agnostic stream wrappers - Per-device encode/decode functions - Typed constants and enums +!!! note "Sourcecode access is required" + The codegen command requires access to VIIPER source code. Run it from the repository root. + +## Usage + +```bash +viiper codegen [flags] +``` + +## Description + ## Flags ### `--output` -Output directory for generated SDKs (relative to repository root). +Output directory for generated client libraries (relative to repository root). -**Default:** `../clients` +**Default:** `clients` **Environment Variable:** `VIIPER_CODEGEN_OUTPUT` -**Example:** - -```bash -viiper codegen --output=../sdk-output -``` - ### `--lang` Target language to generate. -**Values:** `c`, `csharp`, `typescript`, `all` +**Values:** `csharp`, `typescript`, `all` **Default:** `all` **Environment Variable:** `VIIPER_CODEGEN_LANG` -**Examples:** - -```bash -# Generate all SDKs -viiper codegen --lang=all - -# Generate C SDK only -viiper codegen --lang=c - -# Generate C# SDK only -viiper codegen --lang=csharp - -# Generate TypeScript SDK only -viiper codegen --lang=typescript -``` - -## Output Structure - -Generated files are organized by language: - -``` -clients/ -├── c/ -│ ├── include/viiper/ -│ │ ├── viiper.h -│ │ ├── viiper_keyboard.h -│ │ ├── viiper_mouse.h -│ │ └── viiper_xbox360.h -│ ├── src/ -│ │ ├── viiper.c -│ │ ├── viiper_keyboard.c -│ │ ├── viiper_mouse.c -│ │ └── viiper_xbox360.c -│ └── CMakeLists.txt -├── csharp/ -│ └── Viiper.Client/ -│ └── (generated C# files) -└── ts/ - └── viiperclient/ - └── (generated TypeScript files) -``` - ## Examples -### Generate All SDKs +### Generate All Client Libraries ```bash -cd viiper go run ./cmd/viiper codegen ``` -### Generate C SDK and Rebuild Examples +### Generate a Single Client Library ```bash -cd viiper -go run ./cmd/viiper codegen --lang=c -cd ../examples/c -cmake --build build --config Release -``` - -### CI/CD Integration - -```yaml -- name: Generate Client SDKs - run: | - cd viiper - go run ./cmd/viiper codegen --lang=all +go run ./cmd/viiper codegen --lang=csharp ``` ## When to Regenerate Run codegen when any of these change: -- `pkg/apitypes/*.go`: API response structures -- `pkg/device/*/inputstate.go`: Wire format annotations -- `pkg/device/*/const.go`: Exported constants +- `/viipertypes/*.go`: API response structures +- `/device/*/inputstate.go`: Wire format annotations +- `/device/*/const.go`: Exported constants - `internal/server/api/*.go`: Route registrations - Generator templates in `internal/codegen/generator/` ## See Also - [Generator Documentation](../clients/generator.md): Detailed explanation of tagging system and code generation flow -- [C SDK Documentation](../clients/c.md): C-specific usage and build instructions +- [API and Client Reference](../../api/overview.md): API endpoints and data structures - [Configuration](configuration.md): Global configuration options diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0d3d8cc2..0dd0791a 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -1,10 +1,8 @@ # Configuration -VIIPER can be configured via: +Aside from passing CLI flags, VIIPER can also be configured via environment variables and configuration files. -- Command-line flags -- Environment variables -- Configuration files (JSON/YAML/TOML) +For configuration files, VIIPER supports `JSON`, `YAML`, and `TOML` formats. ## Environment Variables @@ -23,8 +21,10 @@ All command-line flags have corresponding environment variables for easier deplo | Environment Variable | CLI Flag | Default | Description | |---------------------|----------|---------|-------------| | `VIIPER_USB_ADDR` | `--usb.addr` | `:3241` | USBIP server listen address | -| `VIIPER_API_ADDR` | `--api.addr` | `:3242` | API server listen address (empty = disabled) | +| `VIIPER_API_ADDR` | `--api.addr` | `:3242` | API server listen address | | `VIIPER_API_DEVICE_HANDLER_TIMEOUT` | `--api.device-handler-timeout` | `5s` | Device handler auto-cleanup timeout | +| `VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT` | `--api.auto-attach-local-client` | `true` | Auto-attach exported devices to local usbip client | +| `VIIPER_API_REQUIRE_LOCALHOST_AUTH` | `--api.require-localhost-auth` | `false` | Require authentication even for localhost connections | | `VIIPER_CONNECTION_TIMEOUT` | `--connection-timeout` | `30s` | Connection operation timeout | ### Proxy Configuration @@ -37,13 +37,14 @@ All command-line flags have corresponding environment variables for easier deplo ## Configuration Files -VIIPER supports JSON, YAML, and TOML configuration files. Generate a starter file with: +VIIPER supports JSON, YAML, and TOML configuration files. +Generate a starter file (default configuration) with: ```bash -viiper config init server --format=json # or yaml|toml +viiper config init server --format=json # or yaml/toml ``` -If no output path is provided, the file is written to the current working directory (e.g., server.json, proxy.yaml). +If no output path is provided, the file is written to the current working directory (e.g., `server.json`, `proxy.yaml`). You can also specify a custom location: @@ -51,7 +52,7 @@ You can also specify a custom location: viiper config init server --format=json --output ./server.json ``` -To use a specific configuration file when starting VIIPER, pass the --config flag (or set VIIPER_CONFIG): +To use a specific configuration file when starting VIIPER, pass the --config flag (or set the `VIIPER_CONFIG` environment variable): ```bash viiper --config ./server.json server @@ -63,55 +64,79 @@ If --config is not provided, VIIPER will search for configuration in this order 2. Platform config directory (see above): server.(json|yaml|yml|toml), proxy.(json|yaml|yml|toml), config.(json|yaml|yml|toml) 3. Linux system-wide: /etc/viiper/server.(json|yaml|yml|toml), /etc/viiper/proxy.(json|yaml|yml|toml), /etc/viiper/config.(json|yaml|yml|toml) -Example JSON configurations: - -Server: - -```json -{ - "api": { - "addr": ":3242", - "device-handler-connect-timeout": "5s" - }, - "usb": { - "addr": ":3241" - }, - "connection-timeout": "30s" -} -``` +## Authentication and Security -Proxy: +VIIPER requires authentication for remote (non-localhost) connections +to prevent unauthorized device creation. -```json -{ - "listen-addr": ":3241", - "upstream-addr": "127.0.0.1:3242", - "connection-timeout": "30s" -} -``` +The password file is _intentionally_ separated from the main configuration -Notes: +**Password File:** `viiper.key.txt` -- The configuration file never contains secrets; environment variables or external secret stores are recommended for sensitive values. +- **Location:** + - **Windows:** `%APPDATA%\VIIPER\` + - **Linux/macOS (user):** `~/.config/github.com/Alia5/viiper/` + - **Linux (root/systemd):** `/etc/viiper/` +- **Auto-generation:** If the file doesn't exist, +VIIPER generates a random 16-character password on first start and displays it in the console +- **Custom passwords:** You can edit `viiper.key.txt` and replace it with any password of any length +- **Encryption:** All authenticated connections use fast ChaCha20-Poly1305 encryption with unique session keys -## Configuration Examples +### Localhost Exemption -### Using Environment Variables +By default, clients connecting from `localhost`, `127.0.0.1`, or `::1` do NOT require authentication (they can optionally provide it). +To require authentication even for localhost connections, use `--api.require-localhost-auth=true`. -Create a `.env` file or export variables: +### Remote Connections -```bash -export VIIPER_LOG_LEVEL=debug -export VIIPER_USB_ADDR=:3241 -export VIIPER_API_ADDR=:3242 -export VIIPER_LOG_FILE=/var/log/viiper.log -``` +All remote clients MUST authenticate using the password from `viiper.key.txt`. -Then run: +## Configuration Examples -```bash -viiper server -``` +=== "Config files" + + Server: + + ```json + { + "api": { + "addr": ":3242", + "device-handler-connect-timeout": "5s", + "auto-attach-local-client": true + }, + "usb": { + "addr": ":3241" + }, + "connection-timeout": "30s" + } + ``` + + Proxy: + + ```json + { + "listen-addr": ":3241", + "upstream-addr": "127.0.0.1:3242", + "connection-timeout": "30s" + } + ``` + +=== "Environment Variables" + + Create a `.env` file or export variables: + + ```bash + export VIIPER_LOG_LEVEL=debug + export VIIPER_USB_ADDR=:3241 + export VIIPER_API_ADDR=:3242 + export VIIPER_LOG_FILE=/var/log/viiper.log + ``` + + Then run: + + ```bash + viiper server + ``` ### Systemd Service diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 73f46159..c73e8780 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -6,7 +6,9 @@ VIIPER provides a command-line interface for running the USBIP server and proxy. - [`server`](server.md) - Start the VIIPER USBIP server - [`proxy`](proxy.md) - Start the VIIPER USBIP proxy -- [`codegen`](codegen.md) - Generate client SDKs from source code annotations +- `install` - Configure VIIPER to start automatically on system boot (see [Installation](../getting-started/installation.md#system-startup-configuration)) +- `uninstall` - Remove VIIPER from system startup configuration +- [`codegen`](codegen.md) - Generate client libraries from source code annotations ## Global Options diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index 9d766a83..ce637a8f 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -1,6 +1,15 @@ # Proxy Command -Start the VIIPER USBIP proxy for traffic inspection and logging. +The `proxy` command starts VIIPER in proxy mode, sitting between a USBIP client and a USBIP server. +VIIPER intercepts and logs all USB traffic passing through, without handling the devices directly. + +This is useful for reverse engineering USB protocols and understanding how devices communicate. + +``` +USBIP Client → VIIPER Proxy → USBIP Server (real devices or VIIPER) + ↓ + Logs/Captures Traffic +``` ## Usage @@ -8,13 +17,6 @@ Start the VIIPER USBIP proxy for traffic inspection and logging. viiper proxy --upstream=
[OPTIONS] ``` -## Description - -The `proxy` command starts VIIPER in proxy mode, sitting between a USBIP client and a USBIP server. -VIIPER intercepts and logs all USB traffic passing through, without handling the devices directly. - -This is useful for reverse engineering USB protocols and understanding how devices communicate. - ## Options ### `--listen-addr` @@ -24,24 +26,12 @@ Proxy listen address (where clients connect). **Default:** `:3241` **Environment Variable:** `VIIPER_PROXY_ADDR` -**Example:** - -```bash -viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3241 -``` - ### `--upstream` **Required.** Upstream USBIP server address (where real devices are). **Environment Variable:** `VIIPER_PROXY_UPSTREAM` -**Example:** - -```bash -viiper proxy --upstream=192.168.1.100:3241 -``` - ### `--connection-timeout` Connection timeout for proxy operations. @@ -49,12 +39,6 @@ Connection timeout for proxy operations. **Default:** `30s` **Environment Variable:** `VIIPER_PROXY_TIMEOUT` -**Example:** - -```bash -viiper proxy --upstream=192.168.1.100:3241 --connection-timeout=60s -``` - ## Examples ### Basic Proxy @@ -62,27 +46,27 @@ viiper proxy --upstream=192.168.1.100:3241 --connection-timeout=60s Start proxy between local clients and remote USBIP server: ```bash -viiper proxy --upstream=192.168.1.100:3241 +viiper proxy --upstream=192.168.1.100:3240 ``` -Clients connect to `localhost:3241`, traffic is proxied to `192.168.1.100:3241`. +Clients connect to `localhost:3241`, traffic is proxied to `192.168.1.100:3240`. ### Custom Listen Address Start proxy on a different port: ```bash -viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3241 +viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3240 ``` -Clients connect to `localhost:9000`, traffic is proxied to `192.168.1.100:3241`. +Clients connect to `localhost:9000`, traffic is proxied to `192.168.1.100:3240`. ### With Raw Packet Logging Capture all USB traffic for reverse engineering: ```bash -viiper proxy --upstream=192.168.1.100:3241 --log.raw-file=usb-capture.log +viiper proxy --upstream=192.168.1.100:3240 --log.raw-file=usb-capture.log ``` All USB packets will be logged to `usb-capture.log`. @@ -92,7 +76,7 @@ All USB packets will be logged to `usb-capture.log`. Enable debug logging to see proxy operations: ```bash -viiper proxy --upstream=192.168.1.100:3241 --log.level=debug +viiper proxy --upstream=192.168.1.100:3240 --log.level=debug ``` ## Use Cases @@ -102,7 +86,7 @@ viiper proxy --upstream=192.168.1.100:3241 --log.level=debug Intercept USB traffic between a client and server to understand device protocols: ```bash -viiper proxy --upstream=real-server:3241 --log.raw-file=device-capture.log +viiper proxy --upstream=real-server:3240 --log.raw-file=device-capture.log ``` ### Traffic Analysis @@ -110,7 +94,7 @@ viiper proxy --upstream=real-server:3241 --log.raw-file=device-capture.log Monitor USB communication for debugging: ```bash -viiper proxy --upstream=real-server:3241 --log.level=trace +viiper proxy --upstream=real-server:3240 --log.level=trace ``` ### Network Inspection @@ -118,18 +102,8 @@ viiper proxy --upstream=real-server:3241 --log.level=trace Route USB traffic through VIIPER to inspect and log all operations: ```bash -viiper proxy --upstream=real-server:3241 --log.level=debug --log.raw-file=traffic.log -``` - -## Proxy Architecture - +viiper proxy --upstream=real-server:3240 --log.level=debug --log.raw-file=traffic.log ``` -USBIP Client → VIIPER Proxy → USBIP Server (real devices) - ↓ - Logs/Captures Traffic -``` - -VIIPER sits in the middle, forwarding all traffic while logging packets for inspection. ## See Also diff --git a/docs/cli/server.md b/docs/cli/server.md index f97173c7..bf8f4a99 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -1,6 +1,7 @@ # Server Command -Start the VIIPER USBIP server to expose virtual devices. +Start the VIIPER daemon/server to expose virtual devices. +This is the default command you should run when you want to create virtual USB devices using VIIPER. ## Usage @@ -15,7 +16,27 @@ The `server` command starts the VIIPER USBIP server, which allows you to create The server exposes two interfaces: 1. **USBIP Server** - Standard USBIP protocol for device attachment -2. **API Server** (optional) - Management API for device/bus control +2. **VIIPER API Server** - Management API for device/bus control + +!!! warning "Authentication Required for Remote Connections" + VIIPER requires **authentication for all remote (non-localhost) connections** to prevent unauthorized device creation. + + On first start, VIIPER generates a random password + and saves it to `/viiper.key.txt`. + Windows: `%APPDATA%\VIIPER\viiper.key.txt` + Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` + Linux (root/systemd): `/etc/viiper/viiper.key.txt` + + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is optional by default + - **Remote clients**: Authentication is required and enforced + - All authenticated connections use **ChaCha20-Poly1305 encryption** + + See the `--api.require-localhost-auth` option below to require authentication for localhost connections. + +!!! info "Automatic Local Attachment" + By default, VIIPER automatically attaches newly created devices to the local USBIP client (localhost only). + This means when you create a device via the API, it will be immediately available on the same machine without manual `usbip attach` commands. + This behavior can be disabled with `--api.auto-attach-local-client=false` if you prefer manual control or are running on a remote server. ## Options @@ -26,29 +47,13 @@ USBIP server listen address. **Default:** `:3241` **Environment Variable:** `VIIPER_USB_ADDR` -**Example:** - -```bash -viiper server --usb.addr=0.0.0.0:3241 -``` - ### `--api.addr` -API server listen address. If empty, the API server is disabled. +API server listen address. **Default:** `:3242` **Environment Variable:** `VIIPER_API_ADDR` -**Example:** - -```bash -# Enable API on custom port -viiper server --api.addr=:8080 - -# Disable API server -viiper server --api.addr= -``` - ### `--api.device-handler-timeout` Time before auto-cleanup occurs when a device handler has no active connection. @@ -56,25 +61,44 @@ Time before auto-cleanup occurs when a device handler has no active connection. **Default:** `5s` **Environment Variable:** `VIIPER_API_DEVICE_HANDLER_TIMEOUT` -**Example:** +### `--api.auto-attach-local-client` + +Automatically attach newly added devices to a local USBIP client on the same host (localhost only). This is a convenience feature; attachment failures (tool not found, error exit) are logged but do not abort device creation. + +VIIPER expects the USBIP command-line tool to be in the PATH (should be by default) (`usbip` on Linux, `usbip.exe` on Windows). If it is missing, auto-attach will simply log an error. + +**Default:** `true` +**Environment Variable:** `VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT` + +Disable example: ```bash -viiper server --api.device-handler-timeout=10s +viiper server --api.auto-attach-local-client=false ``` -### `--connection-timeout` +### `--api.require-localhost-auth` -Connection operation timeout for both USBIP and API servers. +Require authentication even for clients connecting from localhost (`127.0.0.1`, `::1`, `localhost`). -**Default:** `30s` -**Environment Variable:** `VIIPER_CONNECTION_TIMEOUT` +By default, localhost clients are exempt from authentication for convenience during local development. +Enable this option if you want to enforce authentication for all connections regardless of origin. -**Example:** +**Default:** `false` +**Environment Variable:** `VIIPER_API_REQUIRE_LOCALHOST_AUTH` + +Enable example: ```bash -viiper server --connection-timeout=60s +viiper server --api.require-localhost-auth=true ``` +### `--connection-timeout` + +Connection operation timeout for both USBIP and API servers. + +**Default:** `30s` +**Environment Variable:** `VIIPER_CONNECTION_TIMEOUT` + ## Examples ### Basic Server @@ -85,14 +109,6 @@ Start server with default settings (USBIP on :3241, API on :3242): viiper server ``` -### Server Without API - -Start only the USBIP server (no API): - -```bash -viiper server --api.addr= -``` - ### Custom Addresses Start server on custom ports: @@ -122,38 +138,39 @@ viiper server --log.raw-file=/var/log/viiper-raw.log After the server is running and a virtual device has been added to a bus (via the API), attach it from a client using USBIP. Notes: + - VIIPER's USBIP server listens on `:3241` by default (configurable via `--usb.addr`). - The BUSID-DEVICEID you need (e.g. `1-1`) is returned by the API on device add and also visible via `usbip list`. -### Linux +=== "Windows" -```bash -# Load the virtual host controller (only needed once per boot) -sudo modprobe vhci-hcd + On Windows, use [usbip-win2](https://github.com/vadimgrn/usbip-win2): -# List exportable devices on the VIIPER host -usbip list --remote=VIIPER_HOST --tcp-port=3241 + - GUI: use the client to add a remote host and attach by busid. + - CLI (similar flags): -# Attach a device by busid (long flags) -sudo usbip attach --remote=VIIPER_HOST --tcp-port=3241 --busid=BUSID-DEVICEID + ```powershell + usbip.exe list --remote VIIPER_HOST --tcp-port 3241 + usbip.exe attach --remote VIIPER_HOST --tcp-port 3241 --busid BUSID-DEVICEID + ``` -# Equivalent short-form flags -sudo usbip --tcp-port 3241 -r VIIPER_HOST -b BUSID-DEVICEID -``` +=== "Linux" -Replace `VIIPER_HOST` with the server's hostname/IP. If you changed the USBIP port, use that port instead of `3241`. + ```bash + # Load the virtual host controller (only needed once per boot) + sudo modprobe vhci-hcd -### Windows + # List exportable devices on the VIIPER host + usbip list --remote=VIIPER_HOST --tcp-port=3241 -On Windows, use [usbip-win2](https://github.com/vadimgrn/usbip-win2): + # Attach a device by busid (long flags) + sudo usbip attach --remote=VIIPER_HOST --tcp-port=3241 --busid=BUSID-DEVICEID -- GUI: use the client to add a remote host and attach by busid. -- CLI (similar flags): + # Equivalent short-form flags + sudo usbip --tcp-port 3241 -r VIIPER_HOST -b BUSID-DEVICEID + ``` -```powershell -usbip.exe list --remote VIIPER_HOST --tcp-port 3241 -usbip.exe attach --remote VIIPER_HOST --tcp-port 3241 --busid BUSID-DEVICEID -``` + Replace `VIIPER_HOST` with the server's hostname/IP. If you changed the USBIP port, use that port instead of `3241`. Once attached, the device will appear to the OS/applications as a local USB device. diff --git a/docs/clients/c.md b/docs/clients/c.md deleted file mode 100644 index 20d227c5..00000000 --- a/docs/clients/c.md +++ /dev/null @@ -1,246 +0,0 @@ -# C SDK Documentation - -The VIIPER C SDK provides a lightweight, dependency-free client library for interacting with VIIPER servers and controlling virtual devices. - -## Overview - -The C SDK features: - -- **Device-agnostic streaming API**: Uniform interface for all device types -- **Zero dependencies**: Pure C99, no external libraries required -- **Cross-platform**: Windows (MSVC) and POSIX (GCC/Clang) -- **Type-safe**: Generated headers with packed structs and constants -- **Thread-safe**: Recommended: one `viiper_client_t` per thread - -!!! note "License" - The C SDK is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. - The core VIIPER server remains under its original license. - -## Installation - -### Building from Source - -The C SDK is generated from the VIIPER server codebase: - -```bash -cd viiper -go run ./cmd/viiper codegen --lang=c -``` - -Build the SDK: - -```bash -cd ../clients/c -cmake -B build -G "Visual Studio 17 2022" # Windows -cmake -B build # POSIX -cmake --build build --config Release -``` - -### Linking to Your Project - -**CMake:** - -```cmake -# Add viiper SDK -add_subdirectory(path/to/clients/c) -target_link_libraries(your_target PRIVATE viiper) - -# Copy DLL on Windows (post-build) -if(WIN32) - add_custom_command(TARGET your_target POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $ - ) -endif() -``` - -**Manual:** - -- Include: `clients/c/include/viiper/viiper.h` -- Link: `clients/c/build/Release/viiper.lib` (Windows) or `libviiper.a` (POSIX) -- Runtime: Copy `viiper.dll` next to your executable (Windows) - -## Quick Start - -```c -#include -#include -#include - -int main(void) { - // Connect to management API - viiper_client_t* client = NULL; - int err = viiper_client_create("127.0.0.1", 3242, &client); - if (err != 0) { - fprintf(stderr, "Failed to connect: %s\n", viiper_strerror(err)); - return 1; - } - - // Create or find a bus - viiper_bus_list_t buses = {0}; - err = viiper_bus_list(client, &buses); - uint32_t bus_id = (buses.count > 0) ? buses.buses[0] : 0; - - if (bus_id == 0) { - viiper_bus_create_response_t resp = {0}; - err = viiper_bus_create(client, 1, &resp); - bus_id = resp.bus_id; - } - - // Add device - viiper_device_add_response_t dev_resp = {0}; - err = viiper_device_add(client, bus_id, "keyboard", &dev_resp); - - // Open device stream - viiper_device_t* device = NULL; - err = viiper_device_create(client, bus_id, dev_resp.id, &device); - - // Send keyboard input - viiper_keyboard_input_t input = { - .modifiers = 0, - .count = 1 - }; - uint8_t keys[] = {VIIPER_KEYBOARD_KEYA}; - input.keys = keys; - input.keys_count = 1; - - err = viiper_device_send(device, &input, sizeof(input.modifiers) + sizeof(input.count) + input.keys_count); - - // Cleanup - viiper_device_close(device); - viiper_device_remove(client, bus_id, dev_resp.id); - viiper_client_destroy(client); - return 0; -} -``` - -## Device Stream API - -### Creating a Device Stream - -```c -viiper_device_t* device = NULL; -int err = viiper_device_create(client, bus_id, device_id, &device); -if (err != 0) { - fprintf(stderr, "Failed to open device stream: %s\n", viiper_strerror(err)); -} -``` - -### Sending Input - -```c -viiper_mouse_input_t input = { - .buttons = VIIPER_MOUSE_BTN_LEFT, - .dx = 10, - .dy = -5, - .wheel = 0, - .pan = 0 -}; - -int err = viiper_device_send(device, &input, sizeof(input)); -``` - -### Receiving Output (Callbacks) - -```c -void on_led_update(void* user_data, const void* data, size_t len) { - if (len < 1) return; - uint8_t leds = ((uint8_t*)data)[0]; - printf("LEDs: NumLock=%d CapsLock=%d ScrollLock=%d\n", - !!(leds & VIIPER_KEYBOARD_LEDNUMLOCK), - !!(leds & VIIPER_KEYBOARD_LEDCAPSLOCK), - !!(leds & VIIPER_KEYBOARD_LEDSCROLLLOCK)); -} - -viiper_device_on_output(device, on_led_update, NULL); -``` - -### Closing a Stream - -```c -viiper_device_close(device); -``` - -## Device-Specific Notes - -Each device type has specific packet formats, constants, and wire protocols. For wire format details and usage patterns, see the [Devices](../devices/) section of the documentation. - -The C SDK provides generated structs and constants in device-specific headers (e.g., `viiper_keyboard.h`, `viiper_mouse.h`, `viiper_xbox360.h`). - -## Struct Packing - -All device I/O structs use `#pragma pack(1)` to ensure wire compatibility (no padding). - -```c -#pragma pack(push, 1) -typedef struct { - uint8_t buttons; - int8_t dx; - // ... -} viiper_mouse_input_t; -#pragma pack(pop) -``` - -**Important:** Always ensure your compiler respects packing directives. MSVC and GCC/Clang handle this correctly by default. - -## Troubleshooting - -### Missing DLL on Windows - -**Symptom:** Application crashes immediately with "viiper.dll not found" - -**Solution:** Copy `viiper.dll` to the same directory as your executable: - -```cmake -add_custom_command(TARGET your_target POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $ -) -``` - -### Repeated Keys Not Working - -**Symptom:** Typing "Hello" outputs "Helo" (missing duplicate letter) - -**Solution:** Add sufficient delays between key press, release, and next action: - -```c -press_and_release(dev, VIIPER_KEYBOARD_KEYL, 0); -Sleep(100); -press_and_release(dev, VIIPER_KEYBOARD_KEYL, 0); -``` - -### Struct Padding Issues - -**Symptom:** Device input is corrupted or "spazzing" - -**Solution:** Verify `#pragma pack(1)` is applied to device structs. All generated headers include this by default. - -## Examples - -Full working examples are available in the repository: - -- **Virtual Keyboard**: `examples/c/virtual_keyboard/main.c` - - Types "Hello!" every 5 seconds - - Reads LED state (NumLock, CapsLock, ScrollLock) - -- **Virtual Xbox360 Controller**: `examples/c/virtual_x360_pad/main.c` - - Simulates button presses and stick movements - - Receives rumble feedback - -Build and run: - -```bash -cd examples/c -cmake -B build -G "Visual Studio 17 2022" -cmake --build build --config Release -./build/Release/virtual_keyboard.exe 127.0.0.1:3242 -``` - -## See Also - -- [Generator Documentation](generator.md): How the C SDK is generated -- [Go Client Documentation](go.md): Reference implementation -- [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/cpp.md b/docs/clients/cpp.md new file mode 100644 index 00000000..15ab4a0f --- /dev/null +++ b/docs/clients/cpp.md @@ -0,0 +1,448 @@ +# C++ Client Library Documentation + +The VIIPER C++ client library provides a modern, header-only C++20 client library for interacting with VIIPER servers and controlling virtual devices. + +The C++ client library features: + +- **Header-only**: No separate compilation required, just include and use +- **C++20**: Uses concepts, designated initializers, std::optional, smart pointers +- **Type-safe**: Generated structs with constants and helper maps +- **Callback-based output**: Register lambdas for device feedback (LEDs, rumble) +- **Thread-safe**: Separate mutexes for send/recv operations +- **Cross-platform**: Windows (MSVC) and POSIX (GCC/Clang) + +!!! warning "JSON Parser Required" + The C++ client library requires a JSON library to be provided by the user. You **must** define `VIIPER_JSON_INCLUDE`, `VIIPER_JSON_NAMESPACE`, and `VIIPER_JSON_TYPE` before including the client library headers. + + **Recommended**: [nlohmann/json](https://github.com/nlohmann/json) - a header-only JSON library that can be easily integrated. + +!!! warning "OpenSSL Required" + The C++ client library requires OpenSSL for encrypted connections to VIIPER servers with authentication enabled. + Ensure OpenSSL is installed and linked in your project. + +!!! note "License" + The C++ client library is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + The core VIIPER server remains under its original license. + +## Installation + +### 1. Header-Only Integration + +Copy the `clients/cpp/include/viiper` directory to your project's include path: + +```bash +cp -r clients/cpp/include/viiper /path/to/your/project/include/ +``` + +Or add it as an include directory in your build system. + +### 2. CMake Integration + +```cmake +# Add viiper include directory +target_include_directories(your_target PRIVATE path/to/clients/cpp/include) + +# Also ensure nlohmann/json is available +# Option A: FetchContent +include(FetchContent) +FetchContent_Declare(json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) +FetchContent_MakeAvailable(json) +target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json) + +# Option B: Find package (if installed system-wide) +find_package(nlohmann_json REQUIRED) +target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json) +``` + +The client library will be generated in `clients/cpp/include/viiper/`. + +## JSON Parser Configuration + +Before including the VIIPER client library, you must configure a JSON parser. The client library is designed to work with any JSON library that provides a compatible interface. + +### Using nlohmann/json (Recommended) + +```cpp +// Define these BEFORE including viiper headers +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include +``` + +### Using a Custom JSON Library + +Your JSON type must support: + +- `parse(const std::string&)` → JsonType +- `dump()` → std::string +- `operator[](const std::string&)` → JsonType +- `contains(const std::string&)` → bool +- `is_number()`, `is_string()`, `is_array()`, `is_object()` → bool +- `get()` → T +- `size()` → std::size_t (for arrays) + +Example with a custom library: + +```cpp +#define VIIPER_JSON_INCLUDE "my_json_lib.hpp" +#define VIIPER_JSON_NAMESPACE myjson +#define VIIPER_JSON_TYPE JsonValue + +#include +``` + +## Example + +```cpp +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include +#include + +int main() { + // Create new Viiper client + viiper::ViiperClient client("localhost", 3242); + + // Find or create a bus + auto buses_result = client.buslist(); + if (buses_result.is_error()) { + std::cerr << "BusList error: " << buses_result.error().to_string() << "\n"; + return 1; + } + + std::uint32_t bus_id; + if (buses_result.value().buses.empty()) { + auto create_result = client.buscreate(std::nullopt); // Auto-assign ID + if (create_result.is_error()) { + std::cerr << "BusCreate error: " << create_result.error().to_string() << "\n"; + return 1; + } + bus_id = create_result.value().busid; + } else { + bus_id = buses_result.value().buses[0]; + } + + // Add device + auto device_result = client.busdeviceadd(bus_id, {.type = "keyboard"}); + if (device_result.is_error()) { + std::cerr << "AddDevice error: " << device_result.error().to_string() << "\n"; + return 1; + } + auto device_info = std::move(device_result.value()); + + // Connect to device stream + auto stream_result = client.connectDevice(device_info.busid, device_info.devid); + if (stream_result.is_error()) { + std::cerr << "Connect error: " << stream_result.error().to_string() << "\n"; + return 1; + } + auto stream = std::move(stream_result.value()); + + std::cout << "Connected to device " << device_info.devid + << " on bus " << device_info.busid << "\n"; + + // Send keyboard input + viiper::keyboard::Input input = { + .modifiers = viiper::keyboard::ModLeftShift, + .keys = {viiper::keyboard::KeyH}, + }; + stream->send(input); + + // Cleanup + client.busdeviceremove(device_info.busid, device_info.devid); + + return 0; +} +``` + +## Device Control/Feedback + +### Creating a Device + Control/Feedback Stream + +The simplest way to add a device and connect: + +```cpp +auto [device_info, stream] = client.addDeviceAndConnect(bus_id, {.type = "xbox360"}).value(); +``` + +Or manually add and connect: + +```cpp +auto device_result = client.busdeviceadd(bus_id, {.type = "keyboard"}); +auto device_info = device_result.value(); + +auto stream_result = client.connectDevice(device_info.busid, device_info.devid); +auto stream = std::move(stream_result.value()); +``` + +### Sending Input + +Device input is sent using generated structs: + +**Keyboard:** + +```cpp +viiper::keyboard::Input input = { + .modifiers = viiper::keyboard::ModLeftShift, + .keys = {viiper::keyboard::KeyH, viiper::keyboard::KeyE}, +}; +stream->send(input); +``` + +**Mouse:** + +```cpp +viiper::mouse::Input input = { + .buttons = viiper::mouse::ButtonLeft, + .x = 10, + .y = -5, + .wheel = 0, +}; +stream->send(input); +``` + +**Xbox360 Controller:** + +```cpp +viiper::xbox360::Input input = { + .buttons = viiper::xbox360::ButtonA, + .lt = 255, // Left trigger (0-255) + .rt = 0, // Right trigger (0-255) + .lx = -32768, // Left stick X (-32768 to 32767) + .ly = 32767, // Left stick Y + .rx = 0, // Right stick X + .ry = 0, // Right stick Y +}; +stream->send(input); +``` + +### Receiving Feedback + +For devices that send feedback (rumble, LEDs), register a callback: + +**Keyboard LEDs:** + +```cpp +stream->on_output(viiper::keyboard::OUTPUT_SIZE, [](const std::uint8_t* data, std::size_t len) { + if (len < viiper::keyboard::OUTPUT_SIZE) return; + auto result = viiper::keyboard::Output::from_bytes(data, len); + if (result.is_error()) return; + + auto& leds = result.value(); + bool num_lock = (leds.leds & viiper::keyboard::LEDNumLock) != 0; + bool caps_lock = (leds.leds & viiper::keyboard::LEDCapsLock) != 0; + std::cout << "LEDs: Num=" << num_lock << " Caps=" << caps_lock << "\n"; +}); +``` + +**Xbox360 Rumble:** + +```cpp +stream->on_output(viiper::xbox360::OUTPUT_SIZE, [](const std::uint8_t* data, std::size_t len) { + if (len < viiper::xbox360::OUTPUT_SIZE) return; + auto result = viiper::xbox360::Output::from_bytes(data, len); + if (result.is_error()) return; + + auto& rumble = result.value(); + std::cout << "Rumble: Left=" << static_cast(rumble.left) + << ", Right=" << static_cast(rumble.right) << "\n"; +}); +``` + +### Event Handlers + +```cpp +// Called when the server disconnects the device +stream->on_disconnect([]() { + std::cerr << "Device disconnected by server\n"; +}); + +// Called on stream errors +stream->on_error([](const viiper::Error& err) { + std::cerr << "Stream error: " << err.to_string() << "\n"; +}); +``` + +### Stopping a Device + +```cpp +stream->stop(); // Stops the output thread and closes the connection +``` + +The device is also automatically stopped when the `ViiperDevice` is destroyed. +The VIIPER server automatically removes the device when the stream is closed after a short timeout. + +## Generated Constants and Maps + +The C++ client library automatically generates constants and helper maps for each device type. + +### Keyboard Constants + +**Key Codes:** + +```cpp +auto key = viiper::keyboard::KeyA; // 0x04 +auto f1 = viiper::keyboard::KeyF1; // 0x3A +auto enter = viiper::keyboard::KeyEnter; // 0x28 +``` + +**Modifier Flags:** + +```cpp +std::uint8_t mods = viiper::keyboard::ModLeftShift | viiper::keyboard::ModLeftCtrl; +``` + +**LED Flags:** + +```cpp +bool num_lock = (leds & viiper::keyboard::LEDNumLock) != 0; +bool caps_lock = (leds & viiper::keyboard::LEDCapsLock) != 0; +``` + +### Helper Maps + +The client library generates useful lookup maps for working with keyboard input: + +**CHAR_TO_KEY Map** - Convert ASCII characters to key codes: + +```cpp +auto it = viiper::keyboard::CHAR_TO_KEY.find(static_cast('a')); +if (it != viiper::keyboard::CHAR_TO_KEY.end()) { + std::uint8_t key = it->second; // KeyA +} +``` + +**KEY_NAME Array** - Get human-readable key names: + +```cpp +for (const auto& [key, name] : viiper::keyboard::KEY_NAME) { + if (key == viiper::keyboard::KeyF1) { + std::cout << "Key name: " << name << "\n"; // "F1" + break; + } +} +``` + +**SHIFT_CHARS Set** - Check if a character requires shift: + +```cpp +bool needs_shift = viiper::keyboard::SHIFT_CHARS.contains(static_cast('A')); +``` + +### Xbox360 Constants + +**Button Flags:** + +```cpp +std::uint16_t buttons = viiper::xbox360::ButtonA | viiper::xbox360::ButtonB; +``` + +## Error Handling + +All API methods return `Result`, which is either a value or an error: + +```cpp +auto result = client.buslist(); +if (result.is_error()) { + std::cerr << "Error: " << result.error().to_string() << "\n"; + return 1; +} +auto buses = result.value(); +``` + +Using the value directly (throws on error): + +```cpp +// Only use if you're certain the operation succeeded +auto buses = client.buslist().value(); +``` + +### Resource Management + +`ViiperDevice` is managed via `std::unique_ptr` and automatically cleans up: + +```cpp +{ + auto stream = client.connectDevice(bus_id, device_id).value(); + // ... use stream ... +} // stream->stop() called automatically +``` + +## Examples + +Full working examples are available in the repository: + +- **Virtual Keyboard**: `examples/cpp/virtual_keyboard.cpp` + - Types "Hello!" every 5 seconds using generated maps + - Displays LED feedback in console + +- **Virtual Mouse**: `examples/cpp/virtual_mouse.cpp` + - Moves cursor in a circle pattern + - Demonstrates button clicks + +- **Virtual Xbox360 Controller**: `examples/cpp/virtual_x360_pad.cpp` + - Cycles through buttons A, B, X, Y + - Handles rumble feedback + +### Building Examples + +```bash +cd examples/cpp +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Release +cmake --build . --config Release +``` + +### Running Examples + +```bash +./virtual_keyboard localhost:3242 +./virtual_mouse localhost:3242 +./virtual_x360_pad localhost:3242 +``` + +## Troubleshooting + +**Error: VIIPER_JSON_INCLUDE must be defined** + +You must define the JSON macros before including any VIIPER headers: + +```cpp +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include // Include AFTER the defines +``` + +**Linker errors on Windows** + +Ensure Winsock2 is linked. If not using the auto-link pragma, add: + +```cmake +target_link_libraries(your_target PRIVATE Ws2_32) +``` + +**Connection refused** + +Verify the VIIPER server is running: + +```bash +viiper server --api-addr localhost:3242 +``` + +## See Also + +- [Generator Documentation](generator.md): How generated client libraries work +- [Rust Client Library Documentation](rust.md): Rust client library with sync/async support +- [C# Client Library Documentation](csharp.md): .NET client library +- [TypeScript Client Library Documentation](typescript.md): Node.js client library +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md new file mode 100644 index 00000000..574d5573 --- /dev/null +++ b/docs/clients/csharp.md @@ -0,0 +1,362 @@ +# C# Client Library Documentation + +The VIIPER C# client library provides a modern, type-safe .NET client library for interacting with VIIPER servers and controlling virtual devices. + +The C# client library features: + +- **Async/await support**: Full async API with cancellation token support +- **Type-safe**: Generated classes with enums, structs, and helper maps +- **Event-driven**: `OnOutput` event for device feedback (LEDs, rumble) +- **Modern .NET**: Targets .NET 10.0 with nullable reference types +- **Zero external dependencies**: Uses only built-in .NET libraries + +!!! note "License" + The C# client library is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + The core VIIPER server remains under its original license. + +## Installation + +### 1. Using the Published NuGet Package (Recommended) + +Install the stable package: + +```bash +dotnet add package Viiper.Client +``` + +Package page: [Viiper.Client on NuGet](https://www.nuget.org/packages/Viiper.Client/) + +!!! info "Pre-Releases" + Pre-release / snapshot builds are **not** published to NuGet. They are only available as GitHub Release artifacts (e.g. `dev-latest`) or by building from source. + +To use a snapshot `.nupkg` from a GitHub Release: + +```bash +# 1. Download viiper-csharp-sdk-nupkg-Release.nupkg (or Snapshot) to ./packages +mkdir -p packages +cp /path/to/downloaded/viiper-csharp-sdk-nupkg.nupkg packages/ + +# 2. Add a temporary local source and install +dotnet nuget add source ./packages --name viiper-local || true +dotnet add package Viiper.Client --source viiper-local +``` + +Or add directly in your `.csproj` (stable only): + +```xml + + + +``` + +### 2. Project Reference (For Local Development Against Source) + +Use this when modifying the generator or contributing new device types: + +```xml + + + +``` + +## Example + +```csharp +using Viiper.Client; +using Viiper.Client.Devices.Keyboard; + +// Create new Viiper client +var client = new ViiperClient("localhost", 3242); + +// Find or create a bus +var buses = await client.BusListAsync(); +uint busId; +if (buses.Buses.Length == 0) +{ + var resp = await client.BusCreateAsync(null); // null = auto-assign ID + // Or specify ID: await client.BusCreateAsync(5); + busId = resp.BusID; +} +else +{ + busId = buses.Buses[0]; +} + +// Add device and connect +var deviceReq = new DeviceCreateRequest { Type = "keyboard" }; +var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); +var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); + +Console.WriteLine($"Connected to device {deviceResp.BusID}-{deviceResp.DevId}"); + +// Send keyboard input +var input = new KeyboardInput +{ + Modifiers = (byte)Mod.LeftShift, + Count = 1, + Keys = new[] { (byte)Key.H } +}; +await device.SendAsync(input); + +// Cleanup +await client.BusDeviceRemoveAsync(busId, deviceResp.DevId); +``` + +## Device Control/Feedback + +### Creating a Device + Control/Feedback Stream + +The simplest way to add a device and connect: + +```csharp +var deviceReq = new DeviceCreateRequest { Type = "xbox360" }; +var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); +var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); +``` + +Or connect to an existing device: + +```csharp +var device = await client.ConnectDeviceAsync(busId, deviceId); +``` + +### Sending Input + +Device input is sent using generated structs with async methods: + +```csharp +using Viiper.Client.Devices.Xbox360; + +var input = new Xbox360Input +{ + Buttons = (uint)Button.A, + LeftTrigger = 255, + RightTrigger = 0, + ThumbLX = -32768, // Left stick left + ThumbLY = 32767, // Left stick up + ThumbRX = 0, + ThumbRY = 0 +}; +await device.SendAsync(input); +``` + +### Receiving Feedback + +For devices that send feedback (rumble, LEDs), subscribe to the `OnOutput` event: + +```csharp +using Viiper.Client.Devices.Keyboard; + +device.OnOutput += data => +{ + if (data.Length < 1) return; + byte leds = data[0]; + + Console.WriteLine($"LEDs: " + + $"Num={(leds & (byte)LED.NumLock) != 0} " + + $"Caps={(leds & (byte)LED.CapsLock) != 0} " + + $"Scroll={(leds & (byte)LED.ScrollLock) != 0}"); +}; +``` + +For Xbox360 rumble: + +```csharp +using Viiper.Client.Devices.Xbox360; + +device.OnOutput += data => +{ + if (data.Length < 2) return; + byte leftMotor = data[0]; + byte rightMotor = data[1]; + Console.WriteLine($"Rumble: Left={leftMotor} Right={rightMotor}"); +}; +``` + +### Closing a Device + +```csharp +device.Dispose(); +// or +await using var device = await client.ConnectDeviceAsync(busId, deviceId); +``` + +The VIIPER server automatically removes the device when the stream is closed after a short timeout. + +## Generated Constants and Maps + +The C# client library automatically generates enums and helper maps for each device type. + +### Keyboard Constants + +**Key Enum:** + +```csharp +using Viiper.Client.Devices.Keyboard; + +var key = Key.A; // 0x04 +var f1 = Key.F1; // 0x3A +var enter = Key.Enter; // 0x28 +``` + +**Modifier Flags:** + +```csharp +var mods = (byte)(Mod.LeftShift | Mod.LeftCtrl); // 0x03 +``` + +**LED Flags:** + +```csharp +bool numLock = (leds & (byte)LED.NumLock) != 0; +bool capsLock = (leds & (byte)LED.CapsLock) != 0; +``` + +### Helper Maps + +The client library generates useful lookup maps for working with keyboard input: + +**CharToKey Map** - Convert ASCII characters to key codes: + +```csharp +if (CharToKey.TryGetValue((byte)'A', out var key)) +{ + Console.WriteLine($"'A' maps to {key}"); // Key.A +} +``` + +**KeyName Map** - Get human-readable key names: + +```csharp +if (KeyName.TryGetValue((byte)Key.F1, out var name)) +{ + Console.WriteLine($"Key name: {name}"); // "F1" +} +``` + +**ShiftChars Map** - Check if a character requires shift: + +```csharp +bool needsShift = ShiftChars.ContainsKey((byte)'A'); // true for uppercase +``` + +## Configuration and Advanced Usage + +### Custom Timeouts + +```csharp +var client = new ViiperClient("localhost", 3242) +{ + Timeout = TimeSpan.FromSeconds(10) +}; +``` + +Default timeout is 5 seconds. + +### Cancellation Tokens + +All async methods support cancellation: + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + +try +{ + var buses = await client.BusListAsync(cts.Token); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Request timed out"); +} +``` + +### Error Handling + +The server returns errors as JSON. The client throws exceptions: + +```csharp +try +{ + await client.BusCreateAsync("invalid-bus-id"); +} +catch (Exception ex) +{ + Console.WriteLine($"Request failed: {ex.Message}"); +} +``` + +### Resource Management + +`ViiperDevice` implements `IDisposable`: + +```csharp +await using var device = await client.ConnectDeviceAsync(busId, deviceId); +// Device automatically closed when scope exits +``` + +Or manual cleanup: + +```csharp +try +{ + var device = await client.ConnectDeviceAsync(busId, deviceId); + // ... use device ... +} +finally +{ + device.Dispose(); +} +``` + +## Examples + +Full working examples are available in the repository: + +- **Virtual Keyboard**: `examples/csharp/virtual_keyboard/Program.cs` + - Types "Hello!" every 5 seconds using generated maps + - Displays LED feedback in console + +- **Virtual Mouse**: `examples/csharp/virtual_mouse/Program.cs` + - Moves cursor in a circle pattern + - Demonstrates button clicks and scroll wheel + +- **Virtual Xbox360 Controller**: `examples/csharp/virtual_x360_pad/Program.cs` + - Presses buttons and moves sticks + - Handles rumble feedback + +### Running Examples + +```bash +cd examples/csharp/virtual_keyboard +dotnet run -- localhost +``` + +## Troubleshooting + +**Build Errors:** + +Ensure you have .NET 8.0 SDK installed: + +```bash +dotnet --version # Should be 8.0 or higher +``` + +**Nullable Reference Warnings:** + +The generated code uses nullable annotations. You may see warnings like CS8601/CS8625. These are safe to ignore or suppress in your project file: + +```xml + + $(NoWarn);CS8601;CS8625 + +``` + +## See Also + +- [Generator Documentation](generator.md): How generated client libraries work +- [Go Client Documentation](go.md): Reference implementation patterns +- [Rust Client Library Documentation](rust.md): Rust client library with sync/async support +- [TypeScript Client Library Documentation](typescript.md): Node.js client library +- [C++ Client Library Documentation](cpp.md): Header-only C++ client library +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/clients/generator.md b/docs/clients/generator.md index aecc1031..6e2ba295 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -2,7 +2,7 @@ ## Overview -The VIIPER client generator scans Go source code to extract API routes, device wire formats, and constants; then emits type-safe client SDKs for multiple languages. +The VIIPER client generator scans Go source code to extract API routes, device wire formats, and constants; then emits type-safe client libraries for multiple languages. **What it extracts:** @@ -10,19 +10,17 @@ The VIIPER client generator scans Go source code to extract API routes, device w - Device wire formats from `viiper:wire` comment tags - All exported constants from device packages (automatic) -**Output:** Type-safe client SDKs for multiple target languages +**Output:** Type-safe client libraries for multiple target languages !!! note "License" - All generated client SDKs are licensed under the **MIT License**, providing maximum flexibility for integration into your projects. The core VIIPER server remains under its original license. + All generated client libraries are licensed under the **MIT License**, providing maximum flexibility for integration into your projects. The core VIIPER server remains under its original license. ## Running the Generator ```bash -cd viiper -go run ./cmd/viiper codegen --lang=all # Generate all SDKs -go run ./cmd/viiper codegen --lang=c # Generate C SDK only -go run ./cmd/viiper codegen --lang=csharp # Generate C# SDK only -go run ./cmd/viiper codegen --lang=typescript # Generate TypeScript SDK only +go run ./cmd/viiper codegen --lang=all # Generate all client libraries +go run ./cmd/viiper codegen --lang=csharp # Generate C# client library only +go run ./cmd/viiper codegen --lang=typescript # Generate TypeScript client library only ``` **Output directory**: `clients/` (relative to repository root) @@ -56,20 +54,23 @@ The generator uses lightweight comment tags placed next to device types and cons type InputState struct { ... } ``` -### Constant Export +### Constant and Map Export -The generator automatically exports all constants from `pkg/device/*/const.go` for each device type. -No special tags are required. Exported Go constants are emitted with language-appropriate representations and prefixes. +The generator automatically exports all constants and map literals from `/device/*/const.go` for each device type. +No special tags are required. Exported Go constants and maps are emitted with language-appropriate representations: + +- **Constants**: Grouped into enums or language-appropriate constants based on common prefixes +- **Maps**: Converted to Dictionary/Map/lookup functions with helper methods ## Code Generation Flow **Scan Phase:** 1. Parse API routes from `internal/server/api/*.go` -2. Reflect response DTOs from `pkg/apitypes/*.go` +2. Reflect response DTOs from `/viipertypes/*.go` 3. Find device types via `RegisterDevice()` calls 4. Parse `viiper:wire` comments for packet layouts -5. Extract all exported constants from `pkg/device/*/const.go` (automatic) +5. Extract all exported constants and map literals from `/device/*/const.go` (automatic) **Emit Phase:** For each language, generate management client, DTO types, device streams, constants, and build configs. @@ -103,7 +104,6 @@ Each target language emits appropriate types for dynamic arrays (pointers with c For wire compatibility, all device I/O structs are tightly packed (no padding). -- **C:** `#pragma pack(push, 1)` / `#pragma pack(pop)` - **C#:** `[StructLayout(LayoutKind.Sequential, Pack = 1)]` - **TypeScript:** Manual byte-level encoding/decoding @@ -119,22 +119,9 @@ type InputState struct { } ``` -**Emitted C struct:** - -```c -#pragma pack(push, 1) -typedef struct { - uint8_t modifiers; - uint8_t count; - uint8_t* keys; - size_t keys_count; -} viiper_keyboard_input_t; -#pragma pack(pop) -``` - -## Example: Constant Export +## Example: Constant and Map Export -**Go source (`pkg/device/keyboard/const.go`):** +**Go source (`/device/keyboard/const.go`):** ```go const ( @@ -144,46 +131,67 @@ const ( KeyB = 0x05 // ... ) + +var CharToKey = map[byte]byte{ + 'a': KeyA, + 'b': KeyB, + '\n': KeyEnter, + // ... +} ``` -**Emitted C header (`viiper_keyboard.h`):** +**Emitted C# (`KeyboardConstants.cs`):** + +```csharp +public enum Mod : uint +{ + LeftCtrl = 0x01, + LeftShift = 0x02, + // ... +} -```c -#define VIIPER_KEYBOARD_MODLEFTCTRL 0x1 -#define VIIPER_KEYBOARD_MODLEFTSHIFT 0x2 -#define VIIPER_KEYBOARD_KEYA 0x4 -#define VIIPER_KEYBOARD_KEYB 0x5 -// ... +public enum Key : uint +{ + A = 0x04, + B = 0x05, + // ... +} + +public static class CharToKey +{ + private static readonly Dictionary _map = new() + { + { (byte)'a', Key.A }, + { (byte)'b', Key.B }, + { (byte)'\n', Key.Enter }, + // ... + }; + + public static bool TryGetValue(byte key, out Key value) + { + return _map.TryGetValue(key, out value); + } +} ``` ## Regeneration Triggers Run codegen when any of these change: -- `pkg/apitypes/*.go`: API response structures -- `pkg/device/*/inputstate.go`: Wire tag annotations -- `pkg/device/*/const.go`: Exported constants +- `/viipertypes/*.go`: API response structures +- `/device/*/inputstate.go`: Wire tag annotations +- `/device/*/const.go`: Exported constants and map literals - `internal/server/api/*.go`: Route registrations - `internal/codegen/generator/**/*.go`: Generator templates +- `internal/codegen/scanner/**/*.go`: Scanner logic (constants, maps, wire tags) ## Language-Specific Notes -- **C**: manual memory management for variable-length fields; builds with CMake. -- **C#**: `ViiperDevice` class with `OnOutput` event; async/await for management API; struct packing via attributes. -- **TypeScript**: manual byte encoding; ESM/CJS compatible. - -## Current SDK Status - -- **C**: ✅ Complete -- **C#**: 🚧 Planned -- **TypeScript**: 🚧 Planned +- **C#**: Enums for constant groups; `Dictionary` with static helper methods for maps; `ViiperDevice` class with `OnOutput` event; async/await for management API; struct packing via attributes. +- **TypeScript**: Enums for constant groups; `Record` objects with `Get`/`Has` helper functions for maps; manual byte encoding via `BinaryWriter`/`BinaryReader`; `ViiperDevice` class with EventEmitter for output; `addDeviceAndConnect` convenience method; builds with `tsc`. ## Further Reading -- [Design Document](../design.md): Architectural rationale and detailed generation strategy -- [C SDK Documentation](c.md): C-specific usage, build, and examples - [Go Client Documentation](go.md): Go reference client usage - ---- - -For questions or contributions, see the main VIIPER repository. +- [C# Client Library Documentation](csharp.md): C#-specific usage, async patterns, and map helpers +- [TypeScript Client Library Documentation](typescript.md): TypeScript-specific usage, EventEmitter patterns, and examples diff --git a/docs/clients/go.md b/docs/clients/go.md index f4c4521c..082dddd9 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -1,17 +1,16 @@ # Go Client Documentation -The Go client is the reference implementation for interacting with VIIPER servers. It's included in the repository under `pkg/apiclient` and `pkg/device`. - -## Overview +The Go client is the reference implementation for interacting with the VIIPER TCP API. +It's included in the repository under `/apiclient` (and `/device`). The Go client features: - **Type-safe API**: Structured request/response types with context support -- **Device streams**: Bidirectional communication using `encoding.BinaryMarshaler`/`BinaryUnmarshaler` +- **Device Control/Feedback**: Bidirectional binary communication - **Built-in**: No code generation needed; part of the main repository - **Flexible timeouts**: Configurable connection and I/O timeouts -## Quick Start +## Example ```go package main @@ -20,14 +19,15 @@ import ( "context" "log" "time" - - apiclient "viiper/pkg/apiclient" - "viiper/pkg/device/keyboard" + + apiclient "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/keyboard" ) func main() { - // Connect to management API - client := apiclient.New("127.0.0.1:3242") + // Create new Viiper client + client := viiperclient.New("127.0.0.1:3242") ctx := context.Background() // Create or find a bus @@ -47,8 +47,9 @@ func main() { busID = resp.BusID } - // Add device and connect - stream, resp, err := client.AddDeviceAndConnect(ctx, busID, "keyboard") + // Add device and connect (optional CreateOptions parameter for VID/PID) + // Pass nil to use default VID/PID for the device type. + stream, resp, err := client.AddDeviceAndConnect(ctx, busID, "keyboard", nil) if err != nil { log.Fatal(err) } @@ -74,14 +75,15 @@ func main() { } ``` -## Device Stream API +## Device Control/Feedback API ### Creating and Connecting -The simplest way to add a device and open its stream: +The simplest way to add a device and open its control stream (nil opts): ```go -stream, resp, err := client.AddDeviceAndConnect(ctx, busID, "xbox360") +// Use default VID/PID for the device type +stream, resp, err := client.AddDeviceAndConnect(ctx, busID, "xbox360", nil) if err != nil { log.Fatal(err) } @@ -90,22 +92,13 @@ defer stream.Close() log.Printf("Connected to device %s", resp.ID) ``` -Or connect to an existing device: - -```go -stream, err := client.OpenStream(ctx, busID, deviceID) -if err != nil { - log.Fatal(err) -} -defer stream.Close() -``` - ### Sending Input -Device input is sent using structs that implement `encoding.BinaryMarshaler`: +Device input is sent using structs that implement `encoding.BinaryMarshaler`. +Every device package (e.g. `device/xbox360`) provides type-safe input state structs. ```go -import "viiper/pkg/device/xbox360" +import "github.com/Alia5/VIIPER/device/xbox360" input := &xbox360.InputState{ Buttons: xbox360.ButtonA, @@ -117,7 +110,7 @@ if err := stream.WriteBinary(input); err != nil { } ``` -### Receiving Output (Callbacks) +### Receiving Feedback For devices that send feedback (rumble, LEDs), use `StartReading` with a decode function: @@ -126,7 +119,7 @@ import ( "bufio" "encoding" "io" - "viiper/pkg/device/xbox360" + "github.com/Alia5/VIIPER/device/xbox360" ) // Start async reading for rumble commands @@ -152,29 +145,32 @@ go func() { }() ``` -### Closing a Stream +### Closing a Stream / Removing a Device ```go stream.Close() ``` +The VIIPER server automatically removes the device when the stream is closed after a short timeout. + ## Device-Specific Notes -Each device type has specific wire formats and helper methods. For wire format details and usage patterns, see the [Devices](../devices/) section of the documentation. +Each device type has specific wire formats and helper methods. +For wire format details and usage patterns, see the [Devices](../devices/) section of the documentation. -The Go client provides device packages under `pkg/device/` with type-safe structs and constants (e.g., `keyboard.InputState`, `keyboard.KeyA`, `mouse.Btn_Left`). +The Go client provides device packages under `/device/` with type-safe structs and constants (e.g., `keyboard.InputState`, `keyboard.KeyA`, `mouse.Btn_Left`). ## Configuration and Advanced Usage ### Custom Timeouts ```go -cfg := &apiclient.Config{ +cfg := &viiperclient.Config{ DialTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, } -client := apiclient.NewWithConfig("127.0.0.1:3242", cfg) +client := viiperclient.NewWithConfig("127.0.0.1:3242", cfg) ``` Default timeouts are: Dial 3s, Read/Write 5s. @@ -204,12 +200,18 @@ if err != nil { Full working examples are available in the repository: -- **Virtual Mouse**: `examples/virtual_mouse/main.go` -- **Virtual Keyboard**: `examples/virtual_keyboard/main.go` -- **Virtual Xbox360 Controller**: `examples/virtual_x360_pad/main.go` +- **Virtual Mouse**: `examples/go/virtual_mouse/main.go` +- **Virtual Keyboard**: `examples/go/virtual_keyboard/main.go` +- **Virtual Xbox360 Controller**: `examples/go/virtual_x360_pad/main.go` +- **Virtual DualSense (and Edge) Controller**: `examples/go/virtual_ds_and_edge_cli/main.go` +- **Virtual Switch 2 Pro Controller**: `examples/go/virtual_ns2pro/main.go` +- More examples are always being added! ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [C SDK Documentation](c.md): Generated C SDK usage +- [Generator Documentation](generator.md): How generated client libraries work +- [C++ Client Library Documentation](cpp.md): Header-only C++ client library +- [C# Client Library Documentation](csharp.md): .NET client library +- [Rust Client Library Documentation](rust.md): Rust client library +- [TypeScript Client Library Documentation](typescript.md): Node.js client library - [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/rust.md b/docs/clients/rust.md new file mode 100644 index 00000000..fc1e2aab --- /dev/null +++ b/docs/clients/rust.md @@ -0,0 +1,420 @@ +# Rust Client Library Documentation + +The VIIPER Rust client library provides a type-safe, zero-cost abstraction client library for interacting with VIIPER servers and controlling virtual devices. + +The Rust client library features: + +- **Sync and Async APIs**: Choose between blocking `ViiperClient` or async `AsyncViiperClient` (with `async` feature) +- **Type-safe**: Generated structs with constants, helper maps, and `DeviceInput` trait implementations +- **Callback-based output**: Register closures for device feedback (LEDs, rumble) +- **Zero external dependencies** (sync): Uses only `std` for the synchronous client +- **Tokio-based async**: Optional `async` feature for async/await support with Tokio runtime + +!!! note "License" + The Rust client library is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + The core VIIPER server remains under its original license. + +## Installation + +### 1. Using the Published Crate (Recommended) + +Install the client library using Cargo: + +```bash +cargo add viiper-client +``` + +For async support: + +```bash +cargo add viiper-client --features async +cargo add tokio --features full +``` + +Package page: [viiper-client on crates.io](https://crates.io/crates/viiper-client) + +> Pre-release / snapshot builds are **not** published to crates.io. They are only available as GitHub Release artifacts (e.g. `dev-latest`) or by building from source. + +### 2. Path Dependency (For Local Development Against Source) + +Use this when modifying the generator or contributing new device types: + +```toml +[dependencies] +viiper-client = { path = "../../clients/rust" } +``` + +## Example + +=== "Sync" + + ```rust + use viiper_client::{ViiperClient, devices::keyboard::*}; + use std::net::ToSocketAddrs; + + fn main() { + // Create new Viiper client + let addr = "localhost:3242" + .to_socket_addrs() + .expect("Invalid address") + .next() + .expect("No address resolved"); + let client = ViiperClient::new(addr); + + // Find or create a bus + let bus_id = match client.bus_list() { + Ok(resp) if resp.buses.is_empty() => { + client.bus_create(None).expect("Failed to create bus").bus_id + } + Ok(resp) => *resp.buses.first().unwrap(), + Err(e) => panic!("BusList error: {}", e), + }; + + // Add device + let device_info = client.bus_device_add( + bus_id, + &viiper_client::types::DeviceCreateRequest { + r#type: Some("keyboard".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }, + ).expect("Failed to add device"); + + // Connect to device stream + let mut stream = client + .connect_device(device_info.bus_id, &device_info.dev_id) + .expect("Failed to connect"); + + println!("Connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + + // Send keyboard input + let input = KeyboardInput { + modifiers: MOD_LEFT_SHIFT, + count: 1, + keys: vec![KEY_H], + }; + stream.send(&input).expect("Failed to send input"); + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + } + ``` + +=== "Async" + + ```rust + use tokio::time::{sleep, Duration}; + use viiper_client::{AsyncViiperClient, devices::keyboard::*}; + use std::net::ToSocketAddrs; + + #[tokio::main] + async fn main() { + // Create new Viiper client + let addr = "localhost:3242" + .to_socket_addrs() + .expect("Invalid address") + .next() + .expect("No address resolved"); + let client = AsyncViiperClient::new(addr); + + // Find or create a bus + let bus_id = match client.bus_list().await { + Ok(resp) if resp.buses.is_empty() => { + client.bus_create(None).await.expect("Failed to create bus").bus_id + } + Ok(resp) => *resp.buses.first().unwrap(), + Err(e) => panic!("BusList error: {}", e), + }; + + // Add device + let device_info = client.bus_device_add( + bus_id, + &viiper_client::types::DeviceCreateRequest { + r#type: Some("keyboard".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }, + ).await.expect("Failed to add device"); + + // Connect to device stream + let mut stream = client + .connect_device(device_info.bus_id, &device_info.dev_id) + .await + .expect("Failed to connect"); + + println!("Connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + + // Send keyboard input + let input = KeyboardInput { + modifiers: MOD_LEFT_SHIFT, + count: 1, + keys: vec![KEY_H], + }; + stream.send(&input).await.expect("Failed to send input"); + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + } + ``` + +## Device Control/Feedback + +### Creating a Device + Control/Feedback Stream + +=== "Sync" + + ```rust + use viiper_client::{ViiperClient, types::DeviceCreateRequest}; + use std::net::ToSocketAddrs; + + let addr = "localhost:3242" + .to_socket_addrs() + .expect("Invalid address") + .next() + .expect("No address resolved"); + let client = ViiperClient::new(addr); + + // Add device first + let device_info = client.bus_device_add( + bus_id, + &DeviceCreateRequest { + r#type: Some("xbox360".to_string()), + id_vendor: None, + id_product: None, + }, + ).expect("Failed to add device"); + + // Then connect to its stream + let mut stream = client + .connect_device(device_info.bus_id, &device_info.dev_id) + .expect("Failed to connect"); + ``` + +### Sending Input + +Device input is sent using generated structs that implement the `DeviceInput` trait: + +```rust +use viiper_client::devices::xbox360::*; + +let input = Xbox360Input { + buttons: BUTTON_A as u32, + lt: 255, + rt: 0, + lx: -32768, // Left stick left + ly: 32767, // Left stick up + rx: 0, + ry: 0, +}; +stream.send(&input).expect("Failed to send"); +``` + +### Receiving Feedback + +For devices that send feedback (rumble, LEDs), register a callback with `on_output`: + +=== "Sync" + + ```rust + use viiper_client::devices::keyboard::OUTPUT_SIZE; + + stream.on_output(|reader| { + let mut buf = [0u8; OUTPUT_SIZE]; + reader.read_exact(&mut buf)?; + let leds = buf[0]; + + let num_lock = (leds & 0x01) != 0; + let caps_lock = (leds & 0x02) != 0; + let scroll_lock = (leds & 0x04) != 0; + + println!("LEDs: Num={} Caps={} Scroll={}", num_lock, caps_lock, scroll_lock); + Ok(()) + }).expect("Failed to register callback"); + ``` + + For Xbox360 rumble: + + ```rust + stream.on_output(|reader| { + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + let left_motor = buf[0]; + let right_motor = buf[1]; + println!("Rumble: Left={} Right={}", left_motor, right_motor); + Ok(()) + }).expect("Failed to register callback"); + ``` + +=== "Async" + + ```rust + use tokio::io::AsyncReadExt; + use viiper_client::devices::keyboard::OUTPUT_SIZE; + + stream.on_output(|stream| async move { + let mut buf = [0u8; OUTPUT_SIZE]; + let mut guard = stream.lock().await; + guard.read_exact(&mut buf).await?; + drop(guard); + + let leds = buf[0]; + let num_lock = (leds & 0x01) != 0; + let caps_lock = (leds & 0x02) != 0; + + println!("LEDs: Num={} Caps={}", num_lock, caps_lock); + Ok(()) + }).expect("Failed to register callback"); + ``` + + For Xbox360 rumble: + + ```rust + stream.on_output(|reader| async move { + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + let left_motor = buf[0]; + let right_motor = buf[1]; + println!("Rumble: Left={} Right={}", left_motor, right_motor); + Ok(()) + }).expect("Failed to register callback"); + ``` + +## Generated Constants and Maps + +The Rust client library generates constants and lazy-static maps for each device type. + +### Keyboard Constants + +**Key Constants:** + +```rust +use viiper_client::devices::keyboard::*; + +let key = KEY_A; // 0x04 +let f1 = KEY_F1; // 0x3A +let enter = KEY_ENTER; // 0x28 +``` + +**Modifier Flags:** + +```rust +use viiper_client::devices::keyboard::*; + +let mods = MOD_LEFT_SHIFT | MOD_LEFT_CTRL; // 0x03 +``` + +**LED Flags:** + +```rust +use viiper_client::devices::keyboard::*; + +let num_lock = (leds & LED_NUM_LOCK) != 0; +let caps_lock = (leds & LED_CAPS_LOCK) != 0; +``` + +### Helper Maps + +The client library generates useful lookup maps for working with keyboard input: + +**CHAR_TO_KEY** - Convert ASCII characters to key codes: + +```rust +use viiper_client::devices::keyboard::CHAR_TO_KEY; + +if let Some(&key) = CHAR_TO_KEY.get(&b'a') { + println!("'a' maps to key code {}", key); // KEY_A +} +``` + +**KEY_NAME** - Get human-readable key names: + +```rust +use viiper_client::devices::keyboard::KEY_NAME; + +if let Some(name) = KEY_NAME.get(&KEY_F1) { + println!("Key name: {}", name); // "F1" +} +``` + +**SHIFT_CHARS** - Check if a character requires shift: + +```rust +use viiper_client::devices::keyboard::SHIFT_CHARS; + +let needs_shift = SHIFT_CHARS.contains(&b'A'); // true for uppercase +``` + +## Error Handling + +The client library uses a custom `ViiperError` type for all errors: + +```rust +use viiper_client::ViiperError; + +match client.bus_list() { + Ok(buses) => println!("Found {} buses", buses.buses.len()), + Err(ViiperError::Io(e)) => eprintln!("I/O error: {}", e), + Err(ViiperError::Protocol(problem)) => eprintln!("API error: {}", problem), + Err(e) => eprintln!("Other error: {}", e), +} +``` + +The server returns errors as RFC 7807 Problem inspired JSON. +The client parses these into `ProblemJson`: + +```rust +use viiper_client::ProblemJson; + +if let Err(ViiperError::Protocol(problem)) = result { + println!("Status: {}", problem.status); + println!("Title: {}", problem.title); + println!("Detail: {}", problem.detail); +} +``` + +## Features + +The Rust client library supports optional features: + +| Feature | Description | Dependencies | +|---------|-------------|--------------| +| (default) | Synchronous blocking client | None | +| `async` | Async client with Tokio runtime | `tokio`, `tokio-util` | + +Enable async support: + +```toml +[dependencies] +viiper-client = { version = "0.1", features = ["async"] } +``` + +## Examples + +Full working examples are available in the repository: + +- **Virtual Keyboard (sync)**: `examples/rust/sync/virtual_keyboard/` + - Types "Hello!" every 5 seconds using generated maps + - Displays LED feedback in console + +- **Virtual Keyboard (async)**: `examples/rust/async/virtual_keyboard/` + - Async version using Tokio runtime + +- **Virtual Mouse (sync/async)**: `examples/rust/sync/virtual_mouse/`, `examples/rust/async/virtual_mouse/` + - Moves cursor diagonally + - Demonstrates button clicks and scroll wheel + +- **Virtual Xbox360 Controller (sync/async)**: `examples/rust/sync/virtual_x360_pad/`, `examples/rust/async/virtual_x360_pad/` + - Cycles through buttons + - Handles rumble feedback + +## See Also + +- [Generator Documentation](generator.md): How generated client libraries work +- [Go Client Documentation](go.md): Reference implementation patterns +- [C# Client Library Documentation](csharp.md): Alternative managed language client library +- [TypeScript Client Library Documentation](typescript.md): Node.js client library +- [C++ Client Library Documentation](cpp.md): Header-only C++ client library +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md new file mode 100644 index 00000000..dec3d7c4 --- /dev/null +++ b/docs/clients/typescript.md @@ -0,0 +1,372 @@ +# TypeScript Client Library Documentation + +The VIIPER TypeScript client library provides a modern, type-safe Node.js client library for interacting with VIIPER servers and controlling virtual devices. + +The TypeScript client library features: + +- **Type-safe API**: Structured request/response types with proper TypeScript definitions +- **Event-driven**: EventEmitter-based output handling for device feedback (LEDs, rumble) +- **Zero external dependencies**: Uses only built-in Node.js libraries + +!!! note "License" + The TypeScript client library is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + The core VIIPER server remains under its original license. + +## Installation + +### 1. Using the Published Package + +Install the client library from the public npm registry: + +```bash +npm install viiperclient +``` + +The latest stable version is tagged as `latest`. + +!!! "Pre-Releases" + Pre-release / snapshot builds are **not** published to npm. They are only available as GitHub Release artifacts (e.g. `dev-latest`) or by building from source. + +To use a snapshot artifact from GitHub: + +1. Download `viiperclient-typescript-sdk-Snapshot.tgz` (or a versioned tarball) from the appropriate Release. +2. Install it directly: + +```bash +npm install ./viiperclient-typescript-sdk-Snapshot.tgz +``` + +Package page: [npm: viiperclient](https://www.npmjs.com/package/viiperclient) + +### 2. Local Project Reference (For Development Against Source) + +If you are actively modifying VIIPER or the code generator, link directly: + +```json +{ + "dependencies": { + "viiperclient": "file:../../clients/typescript" + } +} +``` + +Then build locally after regeneration: + +```bash +cd clients/typescript +npm install +npm run build +``` + +## Example + +```typescript +import { ViiperClient, Keyboard } from "viiperclient"; + +const { KeyboardInput, Key, Mod } = Keyboard; + +// Create new Viiper client +const client = new ViiperClient("localhost", 3242); + +// Find or create a bus +const busesResp = await client.buslist(); +let busID: number; +if (busesResp.buses.length === 0) { + const resp = await client.buscreate(); // Auto-assign ID + // Or specify ID: await client.buscreate(5); + busID = resp.busId; +} else { + busID = busesResp.buses[0]; +} + +// Add device and connect +const deviceReq = { type: "keyboard" }; +const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); + +console.log(`Connected to device ${response.busId}-${response.devId}`); + +// Send keyboard input +const input = new KeyboardInput({ + Modifiers: Mod.LeftShift, + Count: 1, + Keys: [Key.H] +}); +await device.send(input); + +// Cleanup +await client.busdeviceremove(busID, response.devId); +``` + +## Device Control/Feedback + +### Creating a Device + Control/Feedback Stream + +The simplest way to add a device and connect: + +```typescript +const deviceReq = { type: "xbox360" }; +const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); +``` + +Or manually add and connect: + +```typescript +const deviceResp = await client.busdeviceadd(busId, { type: "keyboard" }); +const device = await client.connectDevice(busId, deviceResp.devId); +``` + +Or connect to an existing device: + +```typescript +const device = await client.connectDevice(busId, deviceId); +``` + +### Sending Input + +Device input is sent using generated classes: + +```typescript +import { Xbox360 } from "viiperclient"; + +const { Xbox360Input, Button } = Xbox360; + +const input = new Xbox360Input({ + Buttons: Button.A, + Lt: 255, + Rt: 0, + Lx: -32768, // Left stick left + Ly: 32767, // Left stick up + Rx: 0, + Ry: 0 +}); +await device.send(input); +``` + +### Receiving Feedback + +For devices that send feedback (rumble, LEDs), subscribe to the `output` event: + +```typescript +import { Keyboard } from "viiperclient"; + +const { LED } = Keyboard; + +device.on("output", (data: Buffer) => { + if (data.length < 1) return; + const leds = data.readUInt8(0); + + console.log(`LEDs: ` + + `Num=${(leds & LED.NumLock) !== 0} ` + + `Caps=${(leds & LED.CapsLock) !== 0} ` + + `Scroll=${(leds & LED.ScrollLock) !== 0}`); +}); +``` + +For Xbox360 rumble: + +```typescript +device.on("output", (data: Buffer) => { + if (data.length < 2) return; + const leftMotor = data.readUInt8(0); + const rightMotor = data.readUInt8(1); + console.log(`Rumble: Left=${leftMotor} Right=${rightMotor}`); +}); +``` + +### Closing a Device + +```typescript +device.close(); +``` + +The VIIPER server automatically removes the device when the stream is closed after a short timeout. + +### Error Handling and Events + +Device streams emit `error` and `end` events that should be handled: + +```typescript +device.on("error", async (err: Error) => { + console.error(`Stream error: ${err}`); + // Handle error and cleanup +}); + +device.on("end", async () => { + console.log("Stream ended by server"); + // Handle disconnection and cleanup +}); +``` + +For long-running applications with intervals or timers, stop them before cleanup: + +```typescript +let running = true; +const interval = setInterval(async () => { + if (!running) return; + + try { + await device.send(input); + } catch (err) { + console.error(`Send error: ${err}`); + running = false; + clearInterval(interval); + // Cleanup... + } +}, 16); + +// Handle Ctrl+C gracefully +process.on("SIGINT", async () => { + console.log("Stopping..."); + running = false; + clearInterval(interval); + device.close(); + await client.busdeviceremove(busId, deviceId); + process.exit(0); +}); +``` + +## Generated Constants and Maps + +The TypeScript SDK automatically generates enums and helper maps for each device type. + +### Keyboard Constants + +**Key Enum:** + +```typescript +import { Keyboard } from "viiperclient"; + +const { Key } = Keyboard; + +const key = Key.A; // 0x04 +const f1 = Key.F1; // 0x3A +const enter = Key.Enter; // 0x28 +``` + +**Modifier Flags:** + +```typescript +import { Keyboard } from "viiperclient"; + +const { Mod } = Keyboard; + +const mods = Mod.LeftShift | Mod.LeftCtrl; // 0x03 +``` + +**LED Flags:** + +```typescript +import { Keyboard } from "viiperclient"; + +const numLock = (leds & LED.NumLock) !== 0; +const capsLock = (leds & LED.CapsLock) !== 0; +``` + +### Helper Maps + +The client library generates useful lookup maps for working with keyboard input: + +**CharToKey Map** - Convert ASCII characters to key codes: + +```typescript +import { Keyboard } from "viiperclient"; + +const { CharToKeyGet } = Keyboard; + +const key = CharToKeyGet('A'.codePointAt(0)!); +if (key !== undefined) { + console.log(`'A' maps to ${key}`); // Key.A +} +``` + +**KeyName Map** - Get human-readable key names: + +```typescript +import { Keyboard } from "viiperclient"; + +const { KeyNameGet } = Keyboard; + +const name = KeyNameGet(Key.F1); +if (name !== undefined) { + console.log(`Key name: ${name}`); // "F1" +} +``` + +**ShiftChars Map** - Check if a character requires shift: + +```typescript +import { Keyboard } from "viiperclient"; + +const { ShiftCharsHas } = Keyboard; + +const needsShift = ShiftCharsHas('A'.codePointAt(0)!); // true for uppercase +``` + +### Error Handling + +The server returns errors as JSON. The client throws exceptions: + +```typescript +try { + await client.buscreate("invalid-bus-id"); +} catch (err) { + console.error(`Request failed: ${err}`); +} +``` + +Device Control/Feedback stream errors are surfaced through the EventEmitter error event: + +```typescript +device.on('error', (err) => { + console.error(`Stream error: ${err}`); +}); +``` + +### Resource Management + +Always close devices when done: + +```typescript +try { + const device = await client.connectDevice(busId, deviceId); + // ... use device ... +} finally { + device.close(); +} +``` + +## Examples + +Full working examples are available in the repository: + +- **Virtual Keyboard**: `examples/typescript/virtual_keyboard.ts` + - Types "Hello!" every 5 seconds using generated maps + - Displays LED feedback in console + +- **Virtual Mouse**: `examples/typescript/virtual_mouse.ts` + - Moves cursor diagonally + - Demonstrates button clicks and scroll wheel + +- **Virtual Xbox360 Controller**: `examples/typescript/virtual_x360_pad.ts` + - Runs at 60fps with cycling buttons and animated triggers + - Handles rumble feedback + +### Running Examples + +```bash +cd examples/typescript +npm install +npm run build + +node dist/virtual_keyboard.js localhost:3242 +``` + +## See Also + +- [Generator Documentation](generator.md): How generated client libraries work +- [Go Client Documentation](go.md): Reference implementation patterns +- [C# Client Library Documentation](csharp.md): Alternative managed language client library +- [Rust Client Library Documentation](rust.md): Rust client library with sync/async support +- [C++ Client Library Documentation](cpp.md): Header-only C++ client library +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md new file mode 100644 index 00000000..69f62906 --- /dev/null +++ b/docs/devices/dualsense.md @@ -0,0 +1,281 @@ +# DualSense Controller + +The DualSense virtual gamepad emulates a complete PlayStation 5 DualSense +controller connected via USB. +The DualSense Edge variant is also supported and uses the same wire/state model. + +It supports sticks, triggers, D-pad, face/shoulder buttons, PS button, +touchpad click, back paddles/function buttons (Edge variant), mic mute button, +IMU (gyro + accelerometer), and touchpad finger coordinates. + +=== "TCP API" + + Use `dualsense` as the default device type when adding a device via the API + or client libraries. + + Use `dualsenseedge` when you want the Edge variant. + + ## Client Library Support + + The wire protocol is abstracted by client libraries. + The **Go client** includes built-in types (`/device/dualsense`), + and **generated client libraries** provide equivalent structures + with proper packing. + + You don't need to manually construct packets, just use the provided types + and send/receive them via the device control and feedback stream. + + See: [API Reference](../api/overview.md) + + ## (RAW) Streaming protocol + + The device stream is a bidirectional, raw TCP connection with fixed-size + packets. + + ### Input State + + - 33-byte packets, little-endian layout: + - Sticks: StickLX, StickLY, StickRX, StickRY: int8 each (4 bytes) + -128 to 127 per axis (-128=min, 0=center, 127=max) + - Buttons: uint32 (4 bytes, bitfield) + - DPad: uint8 (1 byte, bitfield) + - Triggers: TriggerL2, TriggerR2: uint8, uint8 (2 bytes) + 0-255 (0=not pressed, 255=fully pressed) + - Touch1: Touch1X, Touch1Y: uint16 each, Touch1Active: status byte (5 bytes) + - Touch2: Touch2X, Touch2Y: uint16 each, Touch2Active: status byte (5 bytes) + - Gyroscope: GyroX, GyroY, GyroZ: int16 each (6 bytes, raw report + values) + - Accelerometer: AccelX, AccelY, AccelZ: int16 each + (6 bytes, raw report values) + + See `/device/dualsense/state.go` for details. + + ### Feedback (Rumble & LED) + + - Base `dualsense` / `dualsenseedge` streams send 6-byte packets: + - RumbleSmall: uint8, RumbleLarge: uint8 (2 bytes), 0-255 intensity + values + - LED Color: LedRed, LedGreen, LedBlue: uint8 each (3 bytes), 0-255 per + channel + - PlayerLeds: uint8 (1 byte), host-controlled player indicator LED mask + - Extended `dualsenseext` / `dualsenseedgeext` streams send 217-byte + VIIPER feedback packets. Bytes 0..27 preserve the legacy compact + feedback layout: base rumble/LED bytes plus native-spaced trigger + blocks. Bytes 28..75 contain the native USB HID output report `0x02` + exactly as sent by the host, allowing clients to forward DualSense + haptics/control flags instead of reducing them to generic rumble. Bytes + 76..216 contain one optional Bluetooth HID haptics report `0x32` built + from the experimental 3 kHz stereo haptics/audio OUT endpoint. A zero + report ID means no haptics frame is present for that feedback packet. + Bytes 0..5 preserve the original rumble/LED layout above. Bytes 6..16 + contain the R2 adaptive-trigger effect block copied from USB output + report 0x02 with the same reserved gaps used by the native report. + Bytes 17..27 contain the L2 adaptive-trigger effect block with the same + layout. Each trigger block is 11 bytes: + - Mode + - StartResistance + - EffectForce + - RangeForce + - NearReleaseStrength + - NearMiddleStrength + - PressedStrength + - Reserved + - Reserved + - Frequency + - Reserved + + Native USB HID output report `0x02` is advertised as 47 payload bytes by + the captured DualSense USB descriptor, so hosts see 48 bytes including the + report ID. The extended VIIPER feedback stream includes that native report + so DS4Windows can pass through host haptics/control semantics to physical + DualSense hardware when available. + + See `/device/dualsense/state.go` for the `OutputState` wire definition. + + ### Full-duplex audio stream (V3) + + `dualsensecombinedaudioduplexv3` and + `dualsenseedgecombinedaudioduplexv3` are opt-in variants for clients that + transport microphone input and native speaker output on the controller + stream. The older device names and their wire formats remain unchanged, so + a client can fall back to `dualsensecombinedmicv2` or the legacy raw stream. + + Every V3 packet has a 16-byte header followed by its payload: + + | Offset | Size | Field | + | --- | --- | --- | + | 0 | 4 | ASCII `VPCM` | + | 4 | 1 | Version `0x03` | + | 5 | 1 | Frame type | + | 6 | 2 | Payload length, little endian | + | 8 | 4 | Sequence, little endian | + | 12 | 4 | IEEE CRC32, little endian | + + The CRC covers header bytes 4..11 followed by the payload. Sequence + numbers increase independently in each direction and are shared by all + frame types in that direction. + + | Direction | Type | Payload | + | --- | --- | --- | + | Client to VIIPER | `0x01` | 33-byte controller input state | + | Client to VIIPER | `0x02` | 1,920-byte microphone block: signed 16-bit little-endian, 48 kHz, stereo, 10 ms | + | VIIPER to client | `0x81` | 474-byte combined extended feedback state | + | VIIPER to client | `0x82` | Native speaker PCM: signed 16-bit little-endian, 48 kHz, stereo | + + Windows exposes the virtual playback endpoint as four channels. V3 speaker + frames contain only channels 1 and 2 (front left/right); channels 3 and 4 + remain reserved for advanced haptics. A normal ten-packet USB/IP audio URB + therefore produces a 1,920-byte, 10 ms speaker frame, although clients must + honor the payload length rather than assume a fixed block size. + + ## Reference + + ### Button Constants + + | Button | Hex Value | + | -------- | ----------- | + | Square button | 0x00000010 | + | Cross (X) button | 0x00000020 | + | Circle button | 0x00000040 | + | Triangle button | 0x00000080 | + | L1 (Left bumper) | 0x00000100 | + | R1 (Right bumper) | 0x00000200 | + | L2 button | 0x00000400 | + | R2 button | 0x00000800 | + | Create button | 0x00001000 | + | Options button | 0x00002000 | + | L3 (Left stick button) | 0x00004000 | + | R3 (Right stick button) | 0x00008000 | + | PS button | 0x00010000 | + | Touchpad click | 0x00020000 | + | Mic mute button | 0x00040000 | + | Edge Variant only | --- | + | RFn button | 0x00200000 | + | LFn button | 0x00100000 | + | R4 back paddle | 0x00800000 | + | L4 back paddle | 0x00400000 | + + ### D-Pad Constants + + | D-Pad Direction | Hex Value | + | --------------- | ----------- | + | Up | 0x01 | + | Down | 0x02 | + | Left | 0x04 | + | Right | 0x08 | + + ### Touchpad Coordinates + + Touch coordinates are sent as `Touch{1,2}X: uint16` and `Touch{1,2}Y: uint16` + plus a touch status byte. Legacy clients may send `0` for inactive and `1` + for active. New clients should send the raw DualSense tracking byte instead: + bit 7 set means inactive, and the low 7 bits are the contact tracking ID. + If a client marks a touch active without a tracking ID, VIIPER emits `1` as + a safe active fallback rather than `0`. + + VIIPER clamps touch coordinates to the DualSense range: + + - X: **0..1920** + - Y: **0..1080** + + See `/device/dualsense/const.go`. + + ### IMU (Gyro + Accelerometer) + + VIIPER exposes DualSense IMU values as raw report-space `int16` values, + while helper conversions use fixed scale factors. + + Constants (see `/device/dualsense/const.go`): + + - `GyroCountsPerDps = 16.384` + - `AccelCountsPerMS2 = 835.07` + + Gyro (degrees/second): + + raw_gyro = round(gyro_dps * GyroCountsPerDps) + gyro_dps = raw_gyro / GyroCountsPerDps + + Accelerometer (m/s2): + + raw_accel = round(accel_ms2 * AccelCountsPerMS2) + accel_ms2 = raw_accel / AccelCountsPerMS2 + + On device creation, VIIPER initializes the accelerometer to a controller + lying flat with gravity downwards (`AccelZ = -8192`, i.e. roughly -1g). + + Helpers are in `/device/dualsense/helpers.go`. + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateDualSenseDevice(...)` | Create a virtual DualSense device | + | `CreateDualSenseEdgeDevice(...)` | Create a virtual DualSense Edge | + | `SetDualSenseDeviceState(handle, state)` | Push input state | + | `SetDualSenseOutputCallback(handle, cb)` | Register output callback | + | `RemoveDualSenseDevice(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + int8_t LX; + int8_t LY; + int8_t RX; + int8_t RY; + uint32_t Buttons; + uint8_t DPad; + uint8_t L2; + uint8_t R2; + uint16_t Touch1X; + uint16_t Touch1Y; + uint8_t Touch1Active; + uint16_t Touch2X; + uint16_t Touch2Y; + uint8_t Touch2Active; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; + } DSDeviceState; + ``` + + ## Meta state + + Optional metadata can be provided during `CreateDualSenseDevice` + and `CreateDualSenseEdgeDevice`. + + ```c + typedef struct { + const char* SerialNumber; // NULL = use default + const char* MACAddress; // NULL = use default + const char* Board; // NULL = use default + uint8_t BatteryStatus; // 0 = use default + double TemperatureCelsius; // 0 = use default + double BatteryVoltage; // 0 = use default + const char* ShellColor; // NULL = use default (e.g. "00", "Z1") + } DSMetaState; + ``` + + ## Output callback + + Called when the host sends rumble or LED commands to the device. + + ```c + typedef void (*DSOutputCallback)( + DSDeviceHandle handle, + uint8_t rumbleSmall, + uint8_t rumbleLarge, + uint8_t ledRed, + uint8_t ledGreen, + uint8_t ledBlue, + uint8_t playerLeds + ); + ``` + + Pass `NULL` to `SetDualSenseOutputCallback` to clear + a previously registered callback. diff --git a/docs/devices/dualshock4.md b/docs/devices/dualshock4.md new file mode 100644 index 00000000..512bea7e --- /dev/null +++ b/docs/devices/dualshock4.md @@ -0,0 +1,186 @@ +# DualShock 4 Controller + +The DualShock 4 virtual gamepad emulates a complete PlayStation 4 Controller (V1) +connected via USB. +It supports sticks, triggers, D-pad, face/shoulder buttons, PS button, +touchpad click, IMU (gyro + accelerometer), touchpad finger coordinates, and +the native DualShock 4 USB speaker and microphone interfaces. + +=== "TCP API" + + Use `dualshock4` as the device type when adding a device via the API or client libraries. + + Use `dualshock4micv2` when the feeder also supplies microphone input. This + variant keeps the 31-byte controller state and 320-byte, 16 kHz mono PCM + microphone frames in separate CRC-protected framed packets. The legacy + `dualshock4` stream remains unchanged for existing clients. + + ## Client Library Support + + The wire protocol is abstracted by client libraries. + The **Go client** includes built-in types (`/device/dualshock4`), + and **generated client libraries** provide equivalent structures + with proper packing. + + You don't need to manually construct packets, just use the provided types + and send/receive them via the device control and feedback stream. + + See: [API Reference](../api/overview.md) + + ## (RAW) Streaming protocol + + The device stream is a bidirectional, raw TCP connection with fixed-size packets. + + ### Input State + + - 31-byte packets, little-endian layout: + - Sticks: StickLX, StickLY, StickRX, StickRY: int8 each (4 bytes) + -128 to 127 per axis (-128=min, 0=center, 127=max) + - Buttons: uint16 (2 bytes, bitfield) + - DPad: uint8 (1 byte, bitfield) + - Triggers: TriggerL2, TriggerR2: uint8, uint8 (2 bytes) + 0-255 (0=not pressed, 255=fully pressed) + - Touch1: Touch1X, Touch1Y: uint16 each, Touch1Active: bool (5 bytes) + - Touch2: Touch2X, Touch2Y: uint16 each, Touch2Active: bool (5 bytes) + - Gyroscope: GyroX, GyroY, GyroZ: int16 each (6 bytes, fixed-point deg/s) + - Accelerometer: AccelX, AccelY, AccelZ: int16 each (6 bytes, fixed-point m/s2) + + See `/device/dualshock4/inputstate.go` for details. + + ### Feedback (Rumble & LED) + + - 7-byte packets: + - RumbleSmall: uint8, RumbleLarge: uint8 (2 bytes), 0-255 intensity values + - LED Color: LedRed, LedGreen, LedBlue: uint8 each (3 bytes), 0-255 per channel + - LED Flash: FlashOn, FlashOff: uint8 each (2 bytes), units of 2.5ms per value + + See `/device/dualshock4/inputstate.go` for the `OutputState` wire definition. + + ## Reference + + ### Button Constants + + | Button | Hex Value | + | -------- | ----------- | + | Square button | 0x0010 | + | Cross (X) button | 0x0020 | + | Circle button | 0x0040 | + | Triangle button | 0x0080 | + | L1 (Left bumper) | 0x0100 | + | R1 (Right bumper) | 0x0200 | + | L2 button | 0x0400 | + | R2 button | 0x0800 | + | Share button | 0x1000 | + | Options button | 0x2000 | + | L3 (Left stick button) | 0x4000 | + | R3 (Right stick button) | 0x8000 | + | PS button | 0x0001 | + | Touchpad click | 0x0002 | + + ### D-Pad Constants + + | D-Pad Direction | Hex Value | + | --------------- | ----------- | + | Up | 0x01 | + | Down | 0x02 | + | Left | 0x04 | + | Right | 0x08 | + + ### Touchpad Coordinates + + Touch coordinates are sent as `Touch{1,2}X: uint16` and `Touch{1,2}Y: uint16` + plus an explicit boolean `Touch{1,2}Active`. + + VIIPER clamps touch coordinates to the DS4 range: + + - X: **0..1920** + - Y: **0..942** + + See `/device/dualshock4/const.go`. + + ### IMU (Gyro + Accelerometer) + + VIIPER uses fixed-point physical units for IMU values on the wire (stored as `int16`) + to avoid float serialization differences across client languages. + + Constants (see `/device/dualshock4/const.go`): + + - `GyroCountsPerDps = 16` + - `AccelCountsPerMS2 = 512` + + Gyro (degrees/second): + + raw_gyro = round(gyro_dps * GyroCountsPerDps) + gyro_dps = raw_gyro / GyroCountsPerDps + + Accelerometer (m/s2): + + raw_accel = round(accel_ms2 * AccelCountsPerMS2) + accel_ms2 = raw_accel / AccelCountsPerMS2 + + With the default scales: + + - Gyro: resolution `0.0625 deg/s`, max approx `2048 deg/s` + - Accelerometer: resolution approx `0.00195 m/s2`, max approx `64 m/s2` + + On device creation, VIIPER initializes the accelerometer to a controller lying + flat with gravity downwards (`AccelZ = -5023`, i.e. `round(-9.81 * 512)`). + + Helpers are in `/device/dualshock4/helpers.go`. + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateDS4Device(serverHandle, &handle, busID, autoAttach, vid, pid)` | Create a virtual DualShock 4 controller | + | `SetDS4DeviceState(handle, state)` | Push an input state to the device | + | `SetDS4OutputCallback(handle, cb)` | Register a callback for rumble and LED output | + | `RemoveDS4Device(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + int8_t LX; + int8_t LY; + int8_t RX; + int8_t RY; + uint16_t Buttons; + uint8_t DPad; + uint8_t L2; + uint8_t R2; + uint16_t Touch1X; + uint16_t Touch1Y; + uint8_t Touch1Active; + uint16_t Touch2X; + uint16_t Touch2Y; + uint8_t Touch2Active; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; + } DS4DeviceState; + ``` + + ## Output callback + + Called when the host sends rumble or LED commands to the device. + + ```c + typedef void (*DS4OutputCallback)( + DS4DeviceHandle handle, + uint8_t rumbleSmall, + uint8_t rumbleLarge, + uint8_t ledRed, + uint8_t ledGreen, + uint8_t ledBlue, + uint8_t flashOn, + uint8_t flashOff + ); + ``` + + Pass `NULL` to `SetDS4OutputCallback` to clear a previously registered callback. diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index cffb4611..7bc814d1 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -1,61 +1,117 @@ -# HID Keyboard (virtual) - -A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, plus LED status feedback (NumLock, CapsLock, ScrollLock) via an OUT report. - -- USB IDs: VID 0x2E8A (Raspberry Pi), PID 0x0010 -- Interfaces/Endpoints: - - IN: 0x81 (keyboard input report) - - OUT: 0x01 (LED output report) -- Device type id (for API add): `keyboard` - -## HID report format (host-facing) - -Input (device → host): 34 bytes - -- Byte 0: Modifiers bitfield (LeftCtrl, LeftShift, LeftAlt, LeftGUI, RightCtrl, RightShift, RightAlt, RightGUI) -- Byte 1: Reserved (0) -- Bytes 2..33: 256-bit key bitmap (least-significant bit = usage ID 0) - -Output (host → device): 1 byte LEDs - -- Bit 0 NumLock, Bit 1 CapsLock, Bit 2 ScrollLock (remaining bits reserved) - -Note: The HID descriptor uses a long-item Report Count (0x96) to encode 256 for the bitmap. - -## Device stream protocol (client-facing) - -Wire format from your client into VIIPER: - -- Variable-length packets -- Header: [Modifiers (1 byte), KeyCount (1 byte)] -- Followed by KeyCount bytes of HID Usage IDs for the currently pressed non-modifier keys - -VIIPER converts this to the bitmap report for the host, so you don’t need to manage the 256-bit array yourself. - -Example wire packet to press “A” with LeftShift: - -- Modifiers = 0x02 (LeftShift) -- Count = 1 -- Keys = [0x04] // HID usage for “A” - -## LEDs feedback - -The device sends the current LED state (1 byte) back on the same stream whenever the host changes it. You can use this to update indicators in your client. - -## Helpers and keycodes - -Convenience helpers and key constants are available in the Go package: - -- `pkg/device/keyboard/helpers.go`: TypeString, TypeChar, PressKey, Release, etc. -- `pkg/device/keyboard/const.go`: Modifiers, LED bits, and HID usage IDs, including media keys (Mute, VolumeUp/Down, PlayPause, Stop, Next, Previous) - -## Adding the device - -```text -bus/create -bus/1/add keyboard -``` - -## Examples - -A runnable example that types “Hello!” followed by Enter every few seconds is provided in `examples/virtual_keyboard/`. +# HID Keyboard + +A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, +plus LED status feedback (NumLock, CapsLock, ScrollLock). + +=== "TCP API" + + Use `keyboard` as the device type when adding a device via the API or client libraries. + + ## Client Library Support + + The wire protocol is abstracted by client libraries. + The **Go client** includes built-in types (`/device/keyboard`), + and **generated client libraries** provide equivalent structures + with proper packing. + + You don't need to manually construct packets, just use the provided types + and send them via the device stream. + + See: [API Reference](../api/overview.md) + + ## (RAW) Streaming protocol + + The device stream is a bidirectional, raw TCP connection with variable-size packets. + + ### Input State + + - Variable-length packets: + - Header: Modifiers (1 byte), KeyCount (1 byte) + - Followed by KeyCount bytes of HID Usage IDs for pressed non-modifier keys + + ### LED Feedback + + - 1-byte packets: LEDs bitfield + - Bit 0: NumLock + - Bit 1: CapsLock + - Bit 2: ScrollLock + + See `/device/keyboard/inputstate.go` for details. + + ## Reference + + ### Modifiers + + | Modifier | Hex Value | + | -------- | ----------- | + | LeftCtrl | 0x01 | + | LeftShift | 0x02 | + | LeftAlt | 0x04 | + | LeftGUI | 0x08 | + | RightCtrl | 0x10 | + | RightShift | 0x20 | + | RightAlt | 0x40 | + | RightGUI | 0x80 | + + ### Keycodes + + HID Usage IDs for keys are available in `/device/keyboard/const.go`, + including standard alphanumeric keys (0x04–0x63) + and media keys (Mute, VolumeUp/Down, PlayPause, Stop, Next, Previous). + + Helper functions are in `/device/keyboard/helpers.go`. + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateKeyboardDevice(serverHandle, &handle, busID, autoAttach, vid, pid)` | Create a virtual HID keyboard | + | `SetKeyboardDeviceState(handle, state)` | Push an input state to the device | + | `SetKeyboardLEDCallback(handle, cb)` | Register a callback for LED state changes | + | `RemoveKeyboardDevice(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + uint8_t Modifiers; + uint8_t KeyBitmap[32]; /* 256-bit bitmap, one bit per HID key code */ + } KeyboardDeviceState; + ``` + + ### Modifier flags + + | Constant | Value | Key | + | --- | --- | --- | + | `KB_MOD_LEFT_CTRL` | `0x01` | Left Control | + | `KB_MOD_LEFT_SHIFT` | `0x02` | Left Shift | + | `KB_MOD_LEFT_ALT` | `0x04` | Left Alt | + | `KB_MOD_LEFT_GUI` | `0x08` | Left GUI (Win/Cmd) | + | `KB_MOD_RIGHT_CTRL` | `0x10` | Right Control | + | `KB_MOD_RIGHT_SHIFT` | `0x20` | Right Shift | + | `KB_MOD_RIGHT_ALT` | `0x40` | Right Alt | + | `KB_MOD_RIGHT_GUI` | `0x80` | Right GUI (Win/Cmd) | + + Key codes in `KeyBitmap` follow the [USB HID Usage Tables](https://usb.org/sites/default/files/hut1_5.pdf) (page 83, Keyboard/Keypad page). + + ## LED callback + + Called when the host changes keyboard LED state. + + ```c + typedef void (*KeyboardLEDCallback)(KeyboardDeviceHandle handle, uint8_t leds); + ``` + + ### LED flags + + | Constant | Value | + | --- | --- | + | `KB_LED_NUM_LOCK` | `0x01` | + | `KB_LED_CAPS_LOCK` | `0x02` | + | `KB_LED_SCROLL_LOCK` | `0x04` | + | `KB_LED_COMPOSE` | `0x08` | + | `KB_LED_KANA` | `0x10` | + + Pass `NULL` to `SetKeyboardLEDCallback` to clear a previously registered callback. diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index bf8ed4d2..a1809c2a 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -1,39 +1,72 @@ -# HID Mouse (virtual) - -A standard 5-button mouse with vertical and horizontal scroll wheels. Reports relative motion deltas and supports up to five buttons. - -- USB IDs: VID 0x2E8A (Raspberry Pi), PID 0x0011 -- Interface/Endpoint: IN 0x81 (mouse input report) -- Device type id (for API add): `mouse` - -## HID report format (host-facing) - -Input (device → host): 5 bytes - -- Byte 0: Buttons bitfield (bits 0..4 for buttons 1..5) -- Byte 1: X delta (int8) -- Byte 2: Y delta (int8) -- Byte 3: Vertical wheel (int8; positive up) -- Byte 4: Horizontal wheel/pan (int8; positive right) - -Deltas are consumed after each IN report so motion is truly relative and not repeated across host polls. - -## Device stream protocol (client-facing) - -Wire format from your client into VIIPER: - -- Fixed 5-byte packets matching the HID report layout: - [Buttons, dX, dY, Wheel, Pan] - -Buttons persist until changed; motion/wheel deltas are applied once and reset. - -## Adding the device - -```text -bus/create -bus/1/add mouse -``` - -## Examples - -A runnable example that periodically moves the mouse a short distance, clicks, and scrolls is provided in `examples/virtual_mouse/`. +# HID Mouse + +A standard 5-button mouse with vertical and horizontal scroll wheels. +Reports relative motion deltas. + +=== "TCP API" + + Use `mouse` as the device type when adding a device via the API or client libraries. + + ## Client Library Support + + The wire protocol is abstracted by client libraries. + The **Go client** includes built-in types (`/device/mouse`), + and **generated client libraries** provide equivalent structures + with proper packing. + + You don't need to manually construct packets, just use the provided types + and send them via the device stream. + + See: [API Reference](../api/overview.md) + + ## (RAW) Streaming protocol + + The device stream is a bidirectional, raw TCP connection with fixed-size packets. + + ### Input State + + - 9-byte packets, little-endian layout: + - Buttons: uint8 (1 byte, bitfield) — bits 0..4 for buttons 1..5 + - X delta: int16 (2 bytes), -32768 to +32767 + - Y delta: int16 (2 bytes), -32768 to +32767 + - Vertical wheel: int16 (2 bytes), positive = up + - Horizontal wheel/pan: int16 (2 bytes), positive = right + + Motion and wheel deltas are consumed after each report and reset; + buttons persist until changed. + + See `/device/mouse/inputstate.go` for details. + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateMouseDevice(serverHandle, &handle, busID, autoAttach, vid, pid)` | Create a virtual HID mouse | + | `SetMouseDeviceState(handle, state)` | Push an input state to the device | + | `RemoveMouseDevice(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + uint8_t Buttons; + int16_t DX; + int16_t DY; + int16_t Wheel; + int16_t Pan; + } MouseDeviceState; + ``` + + `DX`, `DY`, `Wheel` and `Pan` are relative values consumed once per poll cycle. + + ### Button flags + + | Constant | Value | + | --- | --- | + | `MOUSE_BUTTON_LEFT` | `0x01` | + | `MOUSE_BUTTON_RIGHT` | `0x02` | + | `MOUSE_BUTTON_MIDDLE` | `0x04` | + | `MOUSE_BUTTON_4` | `0x08` | + | `MOUSE_BUTTON_5` | `0x10` | diff --git a/docs/devices/ns2pro.md b/docs/devices/ns2pro.md new file mode 100644 index 00000000..c3aee466 --- /dev/null +++ b/docs/devices/ns2pro.md @@ -0,0 +1,165 @@ +# Switch 2 Pro Controller + +The `ns2pro` virtual gamepad emulates a Nintendo Switch 2 Pro Controller over USB. +It exposes the Switch 2 HID reports used by SDL, including buttons, sticks, +gyro/accelerometer data, and HD rumble output. + +=== "TCP API" + + Use `ns2pro` as the device type when adding a device via the API or client libraries. + + ## Client Library Support + + The Go client can use the built-in types from `/device/ns2pro`. + Generated client libraries will pick up the `viiper:wire` tags from this package + the next time codegen is run. + + ## Raw Streaming Protocol + + The device stream is a bidirectional raw TCP connection with fixed-size packets. + + ### Input State + + - 27-byte packets, little-endian layout: + - Buttons: `uint32` bitfield + - Sticks: `LX`, `LY`, `RX`, `RY` as raw `uint16` values, clamped to `0..4095` + - Accelerometer: `AccelX`, `AccelY`, `AccelZ` as raw `int16` report values + - Gyroscope: `GyroX`, `GyroY`, `GyroZ` as raw `int16` report values + - Battery: `BatteryLevel` (`0..9`), `Charging`, `ExternalPower` + + ### Feedback + + - 34-byte packets: + - `LeftRumble`: 16 bytes copied from HID output report `0x02` + - `RightRumble`: 16 bytes copied from HID output report `0x02` + - `Flags`: bit 0 = rumble update, bit 1 = player LED update + - `PlayerLedMask`: SDL/Steam player LED mask from bulk command `0x09/0x07` + + ## Notes + + VIIPER implements the HID and vendor bulk command paths needed by SDL's Switch 2 + driver. The USB identity mirrors a wired Switch 2 Pro Controller closely enough + for host-side drivers to find the HID interface and vendor bulk interface: + product string `Switch 2 Pro Controller`, serial `00`, `bcdDevice=0x0200`, + HID plus vendor bulk interfaces, and Microsoft OS 1.0 compatible ID and + extended properties descriptors that bind the vendor bulk interface to WinUSB + on Windows. + + NFC, Bluetooth GATT, and headset audio streaming are not emulated. + + Gyro and accelerometer values are raw report values. Clients that need physical + units should convert them according to their target host or driver conventions. + + ## Button Constants + + | Button | Constant | + | --- | --- | + | B / A / Y / X | `ButtonB`, `ButtonA`, `ButtonY`, `ButtonX` | + | L / R / ZL / ZR | `ButtonL`, `ButtonR`, `ButtonZL`, `ButtonZR` | + | Plus / Minus | `ButtonPlus`, `ButtonMinus` | + | Stick clicks | `ButtonLeftStick`, `ButtonRightStick` | + | D-pad | `ButtonUp`, `ButtonDown`, `ButtonLeft`, `ButtonRight` | + | System buttons | `ButtonHome`, `ButtonCapture`, `ButtonC` | + | Grip buttons | `ButtonGL`, `ButtonGR` | + | Headset | `ButtonHeadset` | + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateNS2ProDevice(...)` | Create a virtual Switch 2 Pro Controller | + | `SetNS2ProDeviceState(handle, state)` | Push input state | + | `SetNS2ProOutputCallback(handle, cb)` | Register output (rumble/LED) callback | + | `RemoveNS2ProDevice(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + uint32_t Buttons; + uint16_t LX; + uint16_t LY; + uint16_t RX; + uint16_t RY; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; + } NS2ProDeviceState; + ``` + + Stick values are in the range `0..0x0FFF` (`NS2PRO_STICK_MIN` / `NS2PRO_STICK_CENTER` / `NS2PRO_STICK_MAX`). + + ## Meta state + + Optional metadata passed to `CreateNS2ProDevice`. Controls battery reporting and serial number. + + ```c + typedef struct { + const char* SerialNumber; // NULL = use default + uint8_t BatteryLevel; // 0-9; 0 = use default (9 = full) + uint8_t Charging; // 0 = not charging + uint8_t ExternalPower; // 0 = battery only + uint16_t BatteryVolts; // mV; 0 = use default (3800) + } NS2ProMetaState; + ``` + + ## Output callback + + Called when the host sends HD rumble or player LED commands to the device. + + ```c + typedef struct { + uint8_t LeftRumble[16]; + uint8_t RightRumble[16]; + uint8_t Flags; // bit 0 = rumble update, bit 1 = player LED update + uint8_t PlayerLedMask; + } NS2ProOutputState; + + typedef void (*NS2ProOutputCallback)(NS2ProDeviceHandle handle, NS2ProOutputState output); + ``` + + Pass `NULL` to `SetNS2ProOutputCallback` to clear a previously registered callback. + + ## Button constants + + | Constant | Hex Value | + | --- | --- | + | `NS2PRO_BUTTON_B` | 0x00000001 | + | `NS2PRO_BUTTON_A` | 0x00000002 | + | `NS2PRO_BUTTON_Y` | 0x00000004 | + | `NS2PRO_BUTTON_X` | 0x00000008 | + | `NS2PRO_BUTTON_R` | 0x00000010 | + | `NS2PRO_BUTTON_ZR` | 0x00000020 | + | `NS2PRO_BUTTON_PLUS` | 0x00000040 | + | `NS2PRO_BUTTON_RIGHT_STICK` | 0x00000080 | + | `NS2PRO_BUTTON_DOWN` | 0x00000100 | + | `NS2PRO_BUTTON_RIGHT` | 0x00000200 | + | `NS2PRO_BUTTON_LEFT` | 0x00000400 | + | `NS2PRO_BUTTON_UP` | 0x00000800 | + | `NS2PRO_BUTTON_L` | 0x00001000 | + | `NS2PRO_BUTTON_ZL` | 0x00002000 | + | `NS2PRO_BUTTON_MINUS` | 0x00004000 | + | `NS2PRO_BUTTON_LEFT_STICK` | 0x00008000 | + | `NS2PRO_BUTTON_HOME` | 0x00010000 | + | `NS2PRO_BUTTON_CAPTURE` | 0x00020000 | + | `NS2PRO_BUTTON_GR` | 0x00040000 | + | `NS2PRO_BUTTON_GL` | 0x00080000 | + | `NS2PRO_BUTTON_C` | 0x00100000 | + | `NS2PRO_BUTTON_HEADSET` | 0x00200000 | + + ## Feature flags + + Passed to `CreateNS2ProDevice` to declare which features the device exposes. + + | Constant | Hex Value | Description | + | --- | --- | --- | + | `NS2PRO_FEATURE_BUTTONS` | 0x01 | Button input | + | `NS2PRO_FEATURE_STICKS` | 0x02 | Analog sticks | + | `NS2PRO_FEATURE_IMU` | 0x04 | Gyro + accelerometer | + | `NS2PRO_FEATURE_MOUSE` | 0x10 | Mouse mode | + | `NS2PRO_FEATURE_RUMBLE` | 0x20 | HD rumble output | diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 9f421208..8d46a0b2 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -1,40 +1,144 @@ -# Xbox 360 Controller (virtual) +# Xbox 360 Controller -The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most operating systems and games understand out of the box. +The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most +operating systems and games understand out of the box. -- USB IDs: VID 0x045E (Microsoft), PID 0x028E (Xbox 360 Controller) -- Interfaces/Endpoints: single HID interface with one IN interrupt endpoint and one OUT interrupt endpoint for rumble -- Device type id (for API add): `xbox360` +=== "TCP API" -## Adding the device + Use `xbox360` as the device type when adding a device via the API or client libraries. -Use the API to create a bus and add an Xbox 360 controller: + ## Client Library Support -```text -bus/create -bus/1/add xbox360 -``` + The wire protocol is abstracted by client libraries. + The **Go client** includes built-in types (`/device/xbox360`), + and **generated client libraries** provide equivalent structures + with proper packing. -The API returns a `busid` like `1-1`. Attach it from a USB/IP client, then open a stream to drive input and receive rumble. + You don't need to manually construct packets, just use the provided types + and send/receive them via the device control and feedback stream. -## Streaming protocol + You can optionally specify a sub type if you wish to emulate a different type of controller. + This is done by specifying it as part of the device options. -The device stream is a bidirectional, raw TCP connection with fixed-size packets. + For example: -Direction: client → server (input state) + - `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` -- 14-byte packets, little-endian layout: - - Buttons: uint32 (4 bytes) - - LT, RT: uint8, uint8 (2 bytes) - - LX, LY, RX, RY: int16 each (8 bytes) + ### Subtypes -Direction: server → client (rumble feedback) + | Subtype | Value | + | ----------------------------------------- | ----- | + | Gamepad | 1 | + | Wheel | 2 | + | Arcade Stick | 3 | + | Flight Stick | 4 | + | Dance Pad | 5 | + | Guitar | 6 | + | Guitar Alternate | 7 | + | Drums | 8 | + | Rock Band Stage Kit | 9 | + | Guitar Bass | 11 | + | Rock Band Pro Keys | 15 | + | Arcade Pad | 19 | + | Turntable | 23 | + | Rock Band Pro Guitar | 25 | + | Disney Infinity or Lego Dimensions Portal | 33 | + | Skylanders Portal | 36 | -- 2-byte packets: - - LeftMotor: uint8, RightMotor: uint8 + See: [API Reference](../api/overview.md) -See `pkg/device/xbox360/inputstate.go` for details. + ## (RAW) Streaming protocol -## Example + The device stream is a bidirectional, raw TCP connection with fixed-size packets. -A minimal example program that sends input and reads rumble is provided in `examples/`. + ### Input State + + - 20-byte packets, little-endian layout: + - Buttons: uint32 (4 bytes, bitfield) + - Triggers: LT, RT: uint8, uint8 (2 bytes) + 0-255 (0=not pressed, 255=fully pressed) + - Sticks: LX, LY, RX, RY: int16 each (8 bytes) + 0 is center, -32768 is min, 32767 is max + - Reserved: there are 6 reserved bytes at the end of the report. For most subtypes, these will be zeroed, but a few subtypes do put data here. + + ### Rumble Feedback + + - 2-byte packets: + - LeftMotor: uint8, RightMotor: uint8 + 0-255 intensity values + + See `/device/xbox360/inputstate.go` for details. + + ### Button constants + + | Button | Hex Value | + | ------------------ | --------- | + | D-Pad Up | 0x0001 | + | D-Pad Down | 0x0002 | + | D-Pad Left | 0x0004 | + | D-Pad Right | 0x0008 | + | Start button | 0x0010 | + | Back button | 0x0020 | + | Left stick button | 0x0040 | + | Right stick button | 0x0080 | + | Left bumper | 0x0100 | + | Right bumper | 0x0200 | + | Xbox/Guide button | 0x0400 | + | A button | 0x1000 | + | B button | 0x2000 | + | X button | 0x4000 | + | Y button | 0x8000 | + +=== "libVIIPER" + + ## API + + | Function | Description | + | --- | --- | + | `CreateXbox360Device(serverHandle, &handle, busID, autoAttach, vid, pid, subType)` | Create a virtual Xbox 360 controller | + | `SetXbox360DeviceState(handle, state)` | Push an input state to the device | + | `SetXbox360RumbleCallback(handle, cb)` | Register a callback for rumble output | + | `RemoveXbox360Device(handle)` | Remove the device | + + ## Input state + + ```c + typedef struct { + uint32_t Buttons; + uint8_t LT; + uint8_t RT; + int16_t LX; + int16_t LY; + int16_t RX; + int16_t RY; + uint8_t Reserved[6]; + } Xbox360DeviceState; + ``` + + ### Button flags + + | Constant | Value | + | --- | --- | + | `XBOX360_BUTTON_DPAD_UP` | `0x0001` | + | `XBOX360_BUTTON_DPAD_DOWN` | `0x0002` | + | `XBOX360_BUTTON_DPAD_LEFT` | `0x0004` | + | `XBOX360_BUTTON_DPAD_RIGHT` | `0x0008` | + | `XBOX360_BUTTON_START` | `0x0010` | + | `XBOX360_BUTTON_BACK` | `0x0020` | + | `XBOX360_BUTTON_LEFT_THUMB` | `0x0040` | + | `XBOX360_BUTTON_RIGHT_THUMB` | `0x0080` | + | `XBOX360_BUTTON_LEFT_SHOULDER` | `0x0100` | + | `XBOX360_BUTTON_RIGHT_SHOULDER` | `0x0200` | + | `XBOX360_BUTTON_GUIDE` | `0x0400` | + | `XBOX360_BUTTON_A` | `0x1000` | + | `XBOX360_BUTTON_B` | `0x2000` | + | `XBOX360_BUTTON_X` | `0x4000` | + | `XBOX360_BUTTON_Y` | `0x8000` | + + ## Rumble callback + + ```c + typedef void (*Xbox360RumbleCallback)(Xbox360DeviceHandle handle, uint8_t leftMotor, uint8_t rightMotor); + ``` + + Pass `NULL` to `SetXbox360RumbleCallback` to clear a previously registered callback. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 20977e59..ce0cf0f9 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,60 +1,198 @@ -# Installation - -## Requirements - -VIIPER relies on USBIP. You must have USBIP installed on your system. - -### Linux - -#### Ubuntu/Debian - -```bash -sudo apt install linux-tools-generic -``` - -[Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) - -#### Arch Linux - -```bash -sudo pacman -S usbip -``` - -[Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) - -### Windows - -[usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver). - -## Installing VIIPER - -### Pre-built Binaries - -Download the latest release from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page. - -### Building from Source - -#### Prerequisites - -- [Go](https://go.dev/) 1.25 or newer -- USBIP installed -- (Optional) [Make](https://www.gnu.org/software/make/) - - Linux/macOS: Usually pre-installed - - Windows: `winget install ezwinports.make` - -#### Build Steps - -```bash -git clone https://github.com/Alia5/VIIPER.git -cd VIIPER -make build -``` - -The compiled binary will be in `dist/viiper` (or `dist/viiper.exe` on Windows). - -**Additional build targets:** - -```bash -make help # Show all available make targets -make test # Run tests -``` +# Installation + +VIIPER comes in two distinct flavors: + +- **VIIPER Server** +A self-contained, portable, standalone executable providing a lightweight TCP-based API for feeder application development. All client libraries are MIT licensed. +- **libVIIPER** + a single shared library that lets you emulate devices using USBIP directly from within your application. Requires your application to be GPL-3.0 licensed. + +Regardless of the flavour you choose, VIIPER requires USBIP. + +## Requirements + +### USBIP + +VIIPER relies on USBIP. +You must have a USBIP-Client implementation available on your system to use VIIPER's virtual devices. + +=== "Windows" + + [usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver). + + **Install and done 😉** + + !!! warning "USBIP-Win2 security issue" + The releases of usbip-win2 **currently** (at the time of writing) install the publicly available test signing CA as a _trusted root CA_ on your system. + You can safely remove this CA after installation using `certmgr.msc` (run as admin) and removing the "USBIP" from the "Trusted Root Certification Authorities" -> "Certificates" list. + + **Alternatively**, you can download and install the **latest pre-release** driver manually from the + [OSSign repository](https://github.com/OSSign/vadimgrn--usbip-win2/releases), which has this issue fixed already. + _Note_ that the installer does not work, only the driver `.cat,.inf,.sys` files. + +=== "Linux" + + #### Ubuntu/Debian + + ```bash + sudo apt install linux-tools-generic + ``` + + [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) + + #### Arch Linux + + ```bash + sudo pacman -S usbip + ``` + + [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) + + ### Linux Kernel Module Setup + + !!! info "USBIP Client Requirement" + USBIP requires the `vhci-hcd` (Virtual Host Controller Interface) kernel module on Linux for client operations. This includes VIIPER's auto-attach feature and manual device attachment. + + Most Linux distributions include this module but do not load it automatically. + + #### One-Time Setup + + To load the module automatically on boot: + + ```bash + echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf + sudo modprobe vhci-hcd + ``` + + #### Manual Loading + + To load the module for the current session only: + + ```bash + sudo modprobe vhci-hcd + ``` + + #### Verification + + ```bash + lsmod | grep vhci_hcd + ``` + +--- + +=== "VIIPER Server" + + A standalone executable that exposes an API over TCP. + + ## Installing VIIPER + + VIIPER does not require system-wide installation. + The `viiper` executable is completely self-contained (fully portable, no dependencies except USBIP) and can be: + + - Placed in any directory + - Shipped alongside your application + - Run directly without installation + - Bundled with your application's distribution + + !!! warning "Daemon/Service Conflicts" + If VIIPER is already running as a system service or daemon on the target machine, be aware of potential port conflicts. Applications should: + - Check if VIIPER is already running before starting their own instance + - Use the `ping` API endpoint to check for VIIPER presence and version + - Connect to the existing VIIPER instance (if accessible) + - Use a custom port via `--api.addr` flag to run a separate instance + + !!! info "Linux Permissions" + On Linux, attaching devices via USBIP requires root permissions. + You can run VIIPER with `sudo`, or configure appropriate udev rules to allow non-root users to attach devices. + + ### Pre-built Binaries + + Download the latest release from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page. Pre-built binaries are available for: + + - Windows (x64, ARM64) + - Linux (x64, ARM64) + + ### Automated Install Script + + The following scripts will download a VIIPER release, install it to a system location and configure it to start automatically on boot. + + !!! info "For Application Developers" + The installation scripts are intended for **end-users** setting up a permanent VIIPER service on their system. + + If you are developing an application that uses VIIPER, I **strongly** encourage you to **not** install a permanent VIIPER service on your users' machines. + + Instead, bundle the (no dependencies, portable) VIIPER binary with your application and start/stop the server directly from your application as needed. + You may need to check for existing VIIPER instances or use a custom port via `--api.addr` to avoid conflicts. + + !!! info "USBIP installed by scripts" + The install scripts install and configure USBIP for you: + + - **Windows:** installs the usbip-win2 driver (admin prompt) and prompts for a reboot when drivers were added. + - **Linux:** installs USBIP via the distro package manager (when available), loads `vhci_hcd` and configures it to autoload. + + If the automated USBIP setup fails, follow the [USBIP guide](usbip.md) to finish manually. + + === "Windows" + + ```powershell + irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex + ``` + + Installs to: `%LOCALAPPDATA%\VIIPER\viiper.exe` + + The scripts will: + + 1. Download the specified VIIPER binary version + 2. Install it to the system location + 3. Install and configure USBIP (driver on Windows; packages/modules on Linux) + 4. Configure automatic startup (Registry RunKey on Windows, systemd service on Linux) + 5. Start/restart the VIIPER service + + === "Linux" + + ```bash + curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh + ``` + + Installs to: `/usr/local/bin/viiper` + + The scripts will: + + 1. Download the specified VIIPER binary version + 2. Install it to the system location + 3. Attempt to install and configure USBIP + 4. Load the `vhci_hcd` kernel module and configure it to autoload on boot + 5. Configure and run a systemd service + +=== "libVIIPER" + + libVIIPER is a shared library (`libVIIPER.dll` on Windows, `libVIIPER.so` on Linux) that you link against directly from your application, eliminating the need for a separate VIIPER server process. + + !!! warning "License" + Linking against libVIIPER requires your application to be licensed under the **GPL-3.0** (or a compatible license). + If you cannot comply with the GPL-3.0, use the standalone executable and the [TCP API](../api/overview.md) instead. All client libraries are **MIT licensed**. + + ## Pre-built Binaries + + Download the latest `libVIIPER` release artifact from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page. + The archive contains: + + - `libVIIPER.dll` / `libVIIPER.so`: the shared library + - `libVIIPER.h`: the C header + - `libVIIPER.def`: Windows import definition (for generating `.lib`/`.dll.a` import libraries) + + ## Building from Source + + ```bash + git clone https://github.com/Alia5/VIIPER.git + cd VIIPER + just build-libVIIPER + ``` + + The output will be in `dist/libVIIPER/`. + + !!! info "CGO Required" + Building libVIIPER requires CGO (`CGO_ENABLED=1`) and a C compiler (GCC / MSVC / Clang) in `PATH`. + On Windows, [mingw-w64](https://www.mingw-w64.org/) or MSVC is required. + + See [libVIIPER documentation](../libviiper/overview.md) for integration guides and examples. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 790f4b7e..883cea95 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,9 +1,287 @@ # Quick Start -🚧 **Documentation in progress** 🚧 +## 📋 Prerequisites -This section will cover basic usage examples for getting started with VIIPER. +Ensure you have: -## Basic Usage +1. **USBIP installed** on your system (see [Installation](installation.md#requirements)) +2. **VIIPER binary** downloaded from [GitHub Releases](https://github.com/Alia5/VIIPER/releases) or [built from source](installation.md#building-from-source) -Check the [CLI Reference](../cli/overview.md) for detailed command documentation. +## Starting the Server + +Start VIIPER with default settings: + +```bash +viiper server +``` + +This starts two services: + +- **USBIP Server** on port `3241` (standard USBIP protocol) +- **VIIPER API Server** on port `3242` (management and device interactions) + +!!! warning "Authentication for Remote Connections" + On first start, VIIPER generates a random password + and saves it to `/viiper.key.txt`. + Windows: `%APPDATA%\VIIPER\viiper.key.txt` + Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` + Linux (root/systemd): `/etc/viiper/viiper.key.txt` + + - **Localhost clients** (`127.0.0.1`, `::1`): Authentication is **optional** (but supported) + - **Remote clients**: Authentication is **required** - provide the password using your client library + + All authenticated connections use **ChaCha20-Poly1305 encryption** to protect against man-in-the-middle attacks. + + You can change the password at any time by editing `viiper.key.txt`. + +!!! tip "Auto-attach Feature" + By default, VIIPER automatically attaches newly created devices to the local machine. You can disable this with `--api.auto-attach-local-client=false`. + **Linux users:** Auto-attach requires running VIIPER with `sudo` as USBIP attach operations need elevated permissions. + +!!! info "Custom Ports" + To use different ports: + + ```bash + viiper server --usb.addr=:9000 --api.addr=:9001 + ``` + + See [CLI Reference](../cli/overview.md) for all available options. + +## 🎮 Creating Your First Virtual Device + +VIIPER provides multiple ways to interact with the API. +Choose the method that works best for you. + +### Option 1: Using Client Libraries (Recommended) + +Client libraries are (at time of writing) available for C++, C#, Go, Rust, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. + +For complete client library documentation and code examples, see: + +- [C++ Client Library Documentation](../clients/cpp.md) +- [C# Client Library Documentation](../clients/csharp.md) +- [TypeScript Client Library Documentation](../clients/typescript.md) +- [Go Client Documentation](../clients/go.md) +- [Rust Client Documentation](../clients/rust.md) + +Full working examples for all device types are available in the `examples/` directory of the repository. + +### Example + +For a minimal example, we'll be using TypeScript (as there are more Javascript devs than Insects on this planet), but you can checkout any of the examples provided in the [API Reference](../../api/overview.md) + +This minimal example creates a virtual Xbox 360 controller and sends an input state to press the "A" button, left bumper, half-press the left trigger, and push the left analog-stick to the right. + +Error handling is omitted for brevity. + +```typescript +import { ViiperClient, ViiperDevice, Xbox360, Types } from "viiperclient"; +const { Xbox360Input, Button } = Xbox360; + +const client = new ViiperClient("localhost", 3242); +const bus_create_response = await client.buscreate(); + +const { device, response: addResp } = await client.addDeviceAndConnect( + bus_create_response.busId, + { type: "xbox360"} +); + +device.send(new Xbox360Input({ + Buttons: Button.A | Button.LB, + // Left trigger half-pressed + Lt: 128, + Rt: 0, + // Left joystick pushed to the right + Lx: 32768, + Ly: 0, + Rx: 0, + Ry: 0, +})); +``` + +### Option 2: Using Raw TCP + +VIIPER provides a lightweight TCP API for direct interaction. +See: [API Reference](../api/overview.md) for complete documentation. + +For quick testing you can use `netcat` on Linux or the provided PowerShell helper script on Windows. + +=== "Netcat" + + ```bash + # Create a bus + printf "bus/create\0" | nc localhost 3242 + # Response: {"busId":1} + + # Add a keyboard device + printf 'bus/1/add {"type":"keyboard"}\0' | nc localhost 3242 + # Response: {"busId":1,"devId":"1","vid":"0x2e8a","pid":"0x0010","type":"keyboard"} + + # List devices on the bus + printf "bus/1/list\0" | nc localhost 3242 + ``` + + !!! note "Protocol Details" + The API uses TCP with null-byte (`\0`) terminated requests. See [API Reference](../api/overview.md) for complete protocol documentation. + +=== "PowerShell" + + VIIPER includes a PowerShell helper script for Windows users: + + ```powershell + # Load the helper script + . .\scripts\viiper-api.ps1 + + # Create a bus + Invoke-ViiperAPI "bus/create" + + # Add a device + Invoke-ViiperAPI 'bus/1/add {"type":"keyboard"}' + ``` + +## 🔌 Attaching Devices (USBIP) + +After creating a device via the API, attach it using your system's USBIP client. + +!!! success "Automatic Attachment" + If you're running VIIPER on the same machine where you want to use the device, it's likely already attached automatically! Check the Windows device manager or `lsusb` to confirm. + +### Manual Attachment + +If auto-attach is disabled or you're connecting from a remote machine: + +=== "Linux" + + ```bash + # Load kernel module (once per boot) + sudo modprobe vhci-hcd + + # List available devices + usbip list --remote=localhost --tcp-port=3241 + + # Attach device (use busid from API response, e.g., "1-1") + sudo usbip attach --remote=localhost --tcp-port=3241 --busid=1-1 + + # Verify attachment + lsusb | grep "Raspberry Pi" # For keyboard/mouse + lsusb | grep "Microsoft" # For Xbox 360 controller + ``` + +=== "Windows" + + Using [usbip-win2](https://github.com/vadimgrn/usbip-win2): + + ```powershell + # List available devices + usbip.exe list --remote localhost --tcp-port 3241 + + # Attach device + usbip.exe attach --remote localhost --tcp-port 3241 --busid 1-1 + + # Check Device Manager to verify attachment + ``` + +## 🧰 Available Device Types + +VIIPER supports multiple virtual device types including keyboards, mice, and game controllers. Each device type has its own protocol and capabilities. + +For a complete list of supported devices, their specifications, and wire protocols, see the [Devices](../devices/) documentation. + +## ➡️ Next Steps + +Now that you have a working setup: + +1. **Explore Examples**: Check the `examples/` directory for complete working programs in C#, Go, Rust, TypeScript, and C++ +2. **Read API Documentation**: Learn about all available [API commands](../api/overview.md) +3. **Choose a Client Library**: Pick a [client library](../clients/generator.md) for your preferred language +4. **Review Device Specs**: Understand device-specific protocols in [Devices](../devices/keyboard.md) + +## 🆘 Troubleshooting + +### Server Won't Start + +**Port already in use:** + +```bash +# Use custom ports +viiper server --usb.addr=:9000 --api.addr=:9001 +``` + +**Permission denied (Linux):** + +```bash +# Use ports above 1024 or run with sudo +viiper server --usb.addr=:3241 --api.addr=:3242 +``` + +### Auto-Attach Not Working + +VIIPER will check prerequisites at startup when auto-attach is enabled and log warnings if requirements are missing. + +**Linux - USBIP tool not found:** + +```bash +# Ubuntu/Debian +sudo apt install linux-tools-generic + +# Arch Linux +sudo pacman -S usbip +``` + +**Linux - Kernel module not loaded:** + +```bash +# Load for current session +sudo modprobe vhci-hcd + +# Or configure persistent loading (see Installation guide) +``` + +See [Linux Kernel Module Setup](installation.md#linux-kernel-module-setup-for-auto-attach) for detailed setup instructions. + +**Windows - USBIP tool not found:** + +Download and install [usbip-win2](https://github.com/vadimgrn/usbip-win2) and ensure `usbip.exe` is in your PATH. + +### Device Not Attaching + +**USBIP tool not found:** + +Make sure USBIP is installed and in your PATH (see [Installation requirements](installation.md#requirements)). + +**Connection refused:** + +Verify the VIIPER server is running and listening on the expected ports. + +### Device Not Working + +**No input response:** + +Ensure the device is attached via USBIP AND you've opened a device stream via the API to send input data. + +**Multiple VIIPER instances:** + +If you have VIIPER running as a service, your application's instance may conflict. Either connect to the existing instance or use different ports. + +### Linux: Permission Denied When Attaching Devices + +**On Linux, USBIP attach operations require root permissions.** + +Run VIIPER with `sudo`: + +```bash +sudo viiper server +``` + +Or if manually attaching devices, use `sudo` with the `usbip attach` command: + +```bash +sudo usbip attach --remote=localhost --tcp-port=3241 --busid=1-1 +``` + +## 🔗 See Also + +- [CLI Reference](../cli/overview.md) - Complete command documentation +- [API Reference](../api/overview.md) - Management API protocol +- [Client Libraries](../clients/generator.md) - Language-specific client libraries +- [Configuration](../cli/configuration.md) - Environment variables and config files diff --git a/docs/getting-started/usbip.md b/docs/getting-started/usbip.md new file mode 100644 index 00000000..c49a7d3f --- /dev/null +++ b/docs/getting-started/usbip.md @@ -0,0 +1,72 @@ + +# 🔌 USBIP + +=== "🪟 Windows" + + [usbip-win2](https://github.com/vadimgrn/usbip-win2) is by far the most complete implementation of USBIP for Windows (comes with a **SIGNED** kernel mode driver). + + **Install and done 😉** + + !!! warning "USBIP-Win2 security issue" + The releases of usbip-win2 **currently** (at the time of writing) install the publicly available test signing CA as a _trusted root CA_ on your system. + You can safely remove this CA after installation using `certmgr.msc` (run as admin) and removing the "USBIP" from the "Trusted Root Certification Authorities" -> "Certificates" list. + + **Alternativly**, you can download and istall the **latest pre-release** driver manually from the + [OSSign repository](https://github.com/OSSign/vadimgrn--usbip-win2/releases), which has this issue fixed already. + _Note_ that the installer does not work, only the driver `.cat,.inf,.sys` files. + +=== "🐧 Linux" + + ### 🏹 Arch Linux + + ```bash + sudo pacman -S usbip + ``` + + [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) + + ??? tip "Steam OS users" + If you are installing SISR on Steam OS, you have to switch to the desktop mode and enable write access to the root filesystem first: + + ```bash + sudo steamos-readonly disable + ``` + + ### 🟠 Ubuntu/Debian + + ```bash + sudo apt install linux-tools-generic + ``` + + [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) + + ### 🧩 Linux Kernel Module Setup + + !!! info "USBIP Client Requirement" + USBIP requires the `vhci-hcd` (Virtual Host Controller Interface) kernel module on Linux. + Most Linux distributions include this module but don't load it automatically. + + #### 🧷 One-Time Setup + + To load the module automatically on boot: + + ```bash + echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf + sudo modprobe vhci-hcd + ``` + + #### 🔄 Manual Loading + + To load the module for the current session only: + + ```bash + sudo modprobe vhci-hcd + ``` + + #### 🔎 Verification + + Check if the module is loaded: + + ```bash + lsmod | grep vhci_hcd + ``` diff --git a/docs/index.md b/docs/index.md index 74ea2274..9d1b462d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,38 +1,153 @@
-# VIIPER Documentation +# VIIPER 🐍 -Welcome to the VIIPER documentation! +**Virtual** **I**nput over **IP** **E**mulato**R** -VIIPER is a tool to create virtual input devices using USBIP. +A **cross-platform virtual USB input framework** for creating virtual USB input devices (game controllers, keyboards, mice and more) +that are indistinguishable from real hardware to the operating system and applications. ## Quick Links -- [Installation](getting-started/installation.md) -- [CLI Reference](cli/overview.md) -- [API Reference](api/overview.md) +- [Installation (VIIPER Server)](getting-started/installation.md) + - [CLI Reference](cli/overview.md) + - [API Reference](api/overview.md) +- [libVIIPER](libviiper/overview.md) - [GitHub Repository](https://github.com/Alia5/VIIPER) ## What is VIIPER? -VIIPER creates virtual USB input devices using the USBIP protocol. -These virtual devices appear as real hardware to the operating system and applications, allowing you to emulate controllers, keyboards, and other input devices without physical hardware. - -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -All devices can and must be controlled programmatically via an API. - -## Key Features - -- ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation (virtual device); see [Devices › Xbox 360 Controller](devices/xbox360.md) - - ✅ HID Keyboard with N-key rollover and LED feedback; see [Devices › Keyboard](devices/keyboard.md) - - ✅ HID Mouse with 5 buttons and horizontal/vertical wheel; see [Devices › Mouse](devices/mouse.md) - - 🚧 Extensible architecture allows for more device types (other gamepads, specialized HID) -- ✅ USBIP server mode: expose virtual devices to remote clients -- ✅ Proxy mode: forward real USB devices and inspect/record traffic -- ✅ Cross-platform: works on Linux and Windows -- ✅ Flexible logging (including raw USB packet logs) -- ✅ API server for device/bus management and controlling virtual devices programmatically -- ✅ Multiple client SDKs for easy integration; see [Client SDKs](clients/go.md) - MIT Licensed +VIIPER lets developers create and programmatically control virtual USB input devices (using USBIP under the hood), +enabling seamless integration for gaming, automation, testing and remote control scenarios. + +These virtual devices are indistinguishable from real hardware to the operating system and applications. + +- Runs on Linux and Windows. +- _(Optional)_ network support built in: control devices over a network with lower overhead than raw USBIP alone. +- VIIPER abstracts away all USB / USBIP details. +- VIIPER is portable and runs entirely in userspace. + - Utilizes a generic USBIP kernel mode driver + (built into Linux; on Windows [usbip-win2](https://github.com/vadimgrn/usbip-win2) provides a signed kernel mode driver) + New device types never require touching kernel code. +- After installing USBIP once, VIIPER can run without additional dependencies or system-wide installation. + +VIIPER comes in two distinct flavors: + +- **VIIPER server** + a self-contained, (no dependencies, statically linked) portable, standalone executable + - exposing a lightweight TCP-API + - control devices from any language or machine on the network +- **libVIIPER** + a single shared library to embed device emulation directly into your application + See Examples for C and C# [here](./examples/libVIIPER) + or the [libVIIPER documentation](libviiper/overview.md) for details and examples. + +For why you should pick one over the other see the [FAQ](#why-choose-the-the-standalone-executable-and-interfacing-via-tcp-over-and-the-shared-object-libviiper-library) + +Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. + +## Emulatable devices + +- Xbox 360 controller emulation; see [Devices › Xbox 360 Controller](devices/xbox360.md) +- HID Keyboard with N-key rollover and LED feedback; see [Devices › Keyboard](devices/keyboard.md) +- HID Mouse with 5 buttons and horizontal/vertical wheel; see [Devices › Mouse](devices/mouse.md) +- PS4 controller emulation; see [Devices › DualShock 4 Controller](devices/dualshock4.md) +- PS5 DualSense controller emulation (including Edge variant); see [Devices › DualSense Controller](devices/dualsense.md) +- Nintendo Switch 2 Pro Controller emulation; see [Devices › Switch 2 Pro Controller](devices/ns2pro.md) + +--- + +## 🥫 Feeder application development + +You have two options for developing feeder applications that control the virtual devices created by VIIPER: + +- Use the standalone VIIPER server and interface via the exposed TCP-API (preferably using one of the available client libraries) +- Integrate libVIIPER directly into your application. + See [libVIIPER documentation](libviiper/overview.md) for details and examples. + +### 🔌 API + +VIIPER includes a lightweight TCP based API for device and bus management, as well as streaming device control. +It's designed to be trivial to drive from any language that can open a TCP socket and send null-byte-terminated commands. + +!!! tip "Client Libraries Available" + Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. + See [Client Libraries Available](api/overview.md). + +- The TCP API uses a string-based request/response protocol + terminated by null bytes (`\0`) for device and bus management. + - Requests have a "_path_" and optional payload (sometimes JSON). + eg. `bus/{id}/add {"type": "keyboard", "idVendor": "0x6969"}\0` + - Responses are often JSON as well! + - Errors are reported using JSON objectes similar to + - [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807) + The use of JSON allows for future extenability without breaking compatibility ;) +- For controlling, or feeding, a device a long lived TCP stream is used, with a wire-protocol specific to each device type. + After an initial "_handshake_" (`bus/{busId}/{deviceId}\0`) a _device-specific **binary protocol**_ is used to send input reports and receive output reports (e.g., rumble commands). + +VIIPER takes care of all USBIP protocol details, so you can focus on implementing the device logic only. +On `localhost` VIIPER also automatically attached the USBIP client, so you don't have to worry about USBIP details at all. + +!!! info "Security: Authentication & Encryption" + VIIPER **requires authentication for remote connections** + to prevent unauthorized device creation. + All authenticated connections use fast **ChaCha20-Poly1305 encryption** + to protect against man-in-the-middle attacks. + Localhost connections are exempt from authentication by default for convenience. + +See the [API documentation](api/overview) for details + +--- + +## ❓ FAQ + +### What is USBIP and why does VIIPER use it? + +USBIP is a protocol that allows USB devices to be shared over a network. +VIIPER uses it because it's already built into Linux and available for Windows, making virtual device emulation possible without writing custom kernel drivers yourself. + +### Why choose the standalone executable and interfacing via TCP over, and the (shared-object) libVIIPER library + +- Flexibility + - allows one to use VIIPER as a service on the same host as the USBIP-Client and use the feeder on a different, remote machine. + - allows for software written utilizing VIIPER to **not be** licensed under the terms of the GPLv3 + - Allows users to idenpendently update VIIPER to receive updates and bugfixes without affecting other components or having to recompile applications themselves. + This also takes away maintenance burdens for feeder-application developers (likely you) + +### Can I use VIIPER for gaming? + +Yes! VIIPER can create virtual input devices that appear as real hardware to games and applications. + +This works with Steam, native Windows games and any other application that supports the emulated device types. + +### How is VIIPER different from other controller emulators? + +Many controller emulation approaches require writing a custom kernel driver for every device type you want to support. +VIIPER uses USBIP to handle the USB protocol layer, so device emulation code lives entirely in userspace. + +USBIP itself does require a kernel driver. +On Linux, the USBIP driver is built into the kernel. +On Windows, [usbip-win2](https://github.com/vadimgrn/usbip-win2) provides a signed kernel mode driver. +That driver is generic and does not need to know anything about specific device types. +All device-type logic stays in userspace. + +This makes VIIPER portable, easier to extend and simpler to bundle with applications. +Adding a new device type never requires touching kernel code. + +### Can I add support for other device types? + +Yes! VIIPER's architecture is designed to be extensible. + +### What about the proxy mode? + +Proxy mode sits between a USBIP client and a USBIP server (like a Linux machine sharing real USB devices). +VIIPER intercepts and logs all USB traffic passing through, without handling the devices directly. +Useful for reverse engineering USB protocols and understanding how devices communicate. + +### What about TCP overhead or input latency performance? + +End-to-end input latency for virtual devices created with VIIPER is typically well below 1 millisecond on a modern desktop (e.g. Windows / Ryzen 3900X test machine). +Detailed methodology and sample runs can be found in [E2E Latency Benchmarks](testing/e2e_latency.md). +However, to not stress the CPU excessively, reports get batched and sent every millisecond. So the best you will achive is a 1000Hz update rate, which is more than enough and more than what most real hardware devices provide. +_Note_: Actual device polling rates may be lower depending on the device type and configuration. diff --git a/docs/libviiper/overview.md b/docs/libviiper/overview.md new file mode 100644 index 00000000..78956c3c --- /dev/null +++ b/docs/libviiper/overview.md @@ -0,0 +1,126 @@ +# libVIIPER Documentation + +libVIIPER is a shared library (`libVIIPER.dll` on Windows, `libVIIPER.so` on Linux) that embeds the full VIIPER USB/USBIP stack directly into your application. + +- Single shared library (`libVIIPER.dll` / `libVIIPER.so`) +- Pure C API callable from any language with C FFI support +- In-process + threadsafe + the USBIP server runs in a background thread inside your application +- Optional auto-attach to the local USBIP client on the same machine + +!!! warning "License" + libVIIPER is licensed under **GPL-3.0**. + Linking against it **requires your application to be GPL-3.0 compatible**. + If your project cannot comply with the GPL-3.0, use the standalone VIIPER executable and the [TCP API](../api/overview.md) instead. All TCP client libraries are **MIT licensed**. + +!!! info "USBIP Required" + libVIIPER uses USBIP internally. A USBIP client must be installed on the target machine. + See [Installation › Requirements](../getting-started/installation.md#usbip) for setup instructions. + +## API Overview + +The libVIIPER C API is declared in `libVIIPER.h`. +All functions return `bool` (`true` on success, `false` on failure). +Handles (`USBServerHandle`, `Xbox360DeviceHandle`, …) are opaque `uintptr_t` values. + +### Server lifecycle + +| Function | Description | +| -------------------------------------- | ----------------------------------------- | +| `NewUSBServer(config, &handle, logCb)` | Start a USB server in a background thread | +| `CloseUSBServer(handle)` | Stop the server and free all resources | + +### Bus management + +| Function | Description | +| ------------------------------------ | ------------------------------------------------- | +| `CreateUSBBus(serverHandle, &busID)` | Create a new USB bus (pass `0` to auto-assign ID) | +| `RemoveUSBBus(serverHandle, busID)` | Remove a bus and all its devices | + +## Examples + +Full working examples are in [`examples/libVIIPER/`](https://github.com/Alia5/VIIPER/tree/main/examples/libVIIPER). + +=== "C" + + ```c + USBServerConfig conf = { .addr = "localhost:3245" }; + USBServerHandle serverHandle = 0; + NewUSBServer(&conf, &serverHandle, logCallback); + + uint32_t busID = 0; + CreateUSBBus(serverHandle, &busID); + + Xbox360DeviceHandle deviceHandle = 0; + CreateXbox360Device(serverHandle, &deviceHandle, busID, /*autoAttach=*/true, 0, 0, 0); + + SetXbox360RumbleCallback(deviceHandle, rumbleCallback); + + Xbox360DeviceState state = {0}; + while (running) { + // only required when an actual change occurs + state.Buttons = XBOX360_BUTTON_A; + state.LT = 128; + state.LX = 20000; + SetXbox360DeviceState(deviceHandle, state); + _sleep(16); + } + + CloseUSBServer(serverHandle); + ``` + +=== "C#" + + ```csharp + USBServerConfig conf = new() { addr = "localhost:3245" }; + LibVIIPER.NewUSBServer(ref conf, out nuint serverHandle, logCb); + + uint busID = 0; + LibVIIPER.CreateUSBBus(serverHandle, ref busID); + + LibVIIPER.CreateXbox360Device(serverHandle, out nuint deviceHandle, busID, autoAttachLocalhost: true, 0, 0, 0); + + Xbox360RumbleCallbackDelegate rumbleCb = RumbleCallback; + LibVIIPER.SetXbox360RumbleCallback(deviceHandle, rumbleCb); + + Xbox360DeviceState state = new(); + while (running) { + // only required when an actual change occurs + state.Buttons = Xbox360Buttons.A; + state.LT = 128; + state.LX = 20000; + LibVIIPER.SetXbox360DeviceState(deviceHandle, state); + Thread.Sleep(16); + } + + LibVIIPER.CloseUSBServer(serverHandle); + ``` + + See [`examples/libVIIPER/C#/`](https://github.com/Alia5/VIIPER/tree/main/examples/libVIIPER/C%23) for the full project including P/Invoke declarations. + +## Devices + +- [Xbox 360 Controller](../devices/xbox360.md) +- [DualShock 4](../devices/dualshock4.md) +- [DualSense (and Edge)](../devices/dualsense.md) +- [Switch 2 Pro Controller](../devices/ns2pro.md) +- [Keyboard](../devices/keyboard.md) +- [Mouse](../devices/mouse.md) + +### Logging + +Pass a `VIIPERLogCallback` to `NewUSBServer` to receive log messages from the library. +Pass `NULL` to discard all log output. + +```c +typedef enum { + VIIPER_LOG_DEBUG = -4, + VIIPER_LOG_INFO = 0, + VIIPER_LOG_WARN = 4, + VIIPER_LOG_ERROR = 8, +} VIIPERLogLevel; + +typedef void (*VIIPERLogCallback)(VIIPERLogLevel level, const char* message); +``` + + diff --git a/docs/misc/support.md b/docs/misc/support.md new file mode 100644 index 00000000..0ee90c23 --- /dev/null +++ b/docs/misc/support.md @@ -0,0 +1,23 @@ +# Cummonity & Support + +Still stuck after reading the documentation? +No worries, there are several ways to get help from the community + +## Discord + +[![Discord](https://img.shields.io/discord/368823110817808384?logo=discord&logoColor=white&label=Discord&color=%23535fe5 +)](https://discord.gg/hs34MtcHJY) + +Feel free to joind the Discord server to ask for help, or a general chat and hangout with other users and the developers + +## GitHub Discussions + +The repository has [Discussions](https://github.com/Alia5/VIIPER/discussions) enabled, browse existing topics and open your own +if your search didn't bring up satisfying results + +## GitHub Issues + +Please respect that the GitHub issue tracker is a collaboration platform mainly intended for developers. +Please do not use it to ask for general support or help. +If you are absolutely sure you found a bug, you may go ahead with detailed steps on how to reproduce. +Otherwise, please use GitHub Discussions or Discord for general support. diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md new file mode 100644 index 00000000..fe3bbc0a --- /dev/null +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -0,0 +1,260 @@ +# DualSense Bluetooth Haptics Notes + +These notes track the implementation path for forwarding a game's virtual +DualSense haptics to a physical Bluetooth DualSense. + +## Ground truth + +- SAxense (`egormanga/SAxense`) streams haptics to a Bluetooth DualSense by + writing HID output report `0x32` to the controller's `hidraw` node. +- SAxense accepts signed 8-bit stereo PCM at 3000 Hz. +- Each Bluetooth report is 141 bytes: + - byte 0: report ID `0x32` + - byte 1: tag/sequence nibble + - packet `0x11`: stream/control packet, length 7, body + `FE 00 00 00 00 FF ` + - packet `0x12`: haptics PCM packet, length 64, body is 64 bytes of stereo + PCM + - final 4 bytes: DualSense Bluetooth CRC32 with seed `0xEADA2D49` +- VIIPER now has `BuildBluetoothHapticsReport` mirroring that packet layout so + the runtime can package haptics PCM without re-discovering the format. + +## PadForge / HIDMaestro read + +PadForge and HIDMaestro are useful references, but mostly for the HID side of +the problem. + +What the public code proves: + +- PadForge uses HIDMaestro to create virtual DualSense / DualSense Edge HID + devices. +- HIDMaestro profiles declare USB DualSense output report `0x02` as 48 bytes. + The important HID fields are: + - byte 1: `validFlag0` + - byte 2: `validFlag1` + - bytes 3-4: compatible rumble motors + - byte 9: mute LED + - bytes 11-21: right trigger effect + - bytes 22-32: left trigger effect + - bytes 45-47: lightbar RGB + - bytes 1-47: `effectPayload` +- HIDMaestro profiles declare Bluetooth DualSense output report `0x31` as 78 + bytes, with a rolling tag, BT flag byte, shifted HID field offsets, and a + CRC32 footer using prefix `[0xA2, 0x31]`. +- PadForge listens for HIDMaestro `OutputDecoded`, takes the decoded + `effectPayload`, and forwards it to an assigned physical DualSense via + `SDL_SendGamepadEffect`. +- PadForge also has a raw HID writer that encodes profile fields through + HIDMaestro and writes to the physical HID path directly, bypassing SDL's + effect state machine when needed. + +What the public code does not prove: + +- It does not obviously expose a virtual DualSense USB Audio Class function to + games. +- It does not obviously capture host-to-device isochronous audio endpoint + transfers from a game. +- Its README "audio" features appear, from source review, to include + audio-reactive user effects and HID effect passthrough, not necessarily the + native DualSense advanced-haptics audio interface that Ghost of Tsushima uses + on a wired controller. + +Practical takeaway: + +- Use HIDMaestro's profiles as a spec oracle for USB `0x02` and Bluetooth + `0x31` HID report mapping. +- Keep PadForge's queueing model in mind: HID output callbacks should enqueue + and return quickly, while a worker forwards effects to physical hardware. +- Do not assume PadForge solves advanced haptics audio for VIIPER. The missing + piece is still a virtual USB audio/haptics function or another endpoint path + that lets the game send PCM-like haptics data to the virtual controller. + +## What Ghost of Tsushima is likely sending + +For native DualSense behavior, Ghosts can send separate channels of data: + +- HID output report `0x02`: adaptive trigger state, lightbar/player LEDs, mute + LED, and ordinary compatible rumble flags. +- USB audio stream: advanced haptics, exposed by a real wired DualSense as an + audio function. SAxense's input format strongly suggests that the useful + Bluetooth haptics payload is 3000 Hz stereo 8-bit PCM. + +VIIPER's current DualSense device is HID-only. It captures report `0x02`, but it +does not expose a virtual DualSense USB audio function yet. That means a game +that sends advanced haptics through the DualSense audio interface has nowhere to +send those frames in the current virtual device, and the HID traffic dump alone +cannot recover them. + +## Required next implementation path + +1. Capture a real wired DualSense USB descriptor, including all audio + interfaces, alternate settings, isochronous endpoints, and class-specific + audio descriptors. +2. Extend VIIPER's DualSense descriptor to expose the same USB audio/haptics + interface in addition to the HID interface. +3. Route host-to-device audio endpoint transfers into a DualSense haptics + diagnostics ring buffer. +4. Add an experimental bridge that converts captured haptics PCM into + `BuildBluetoothHapticsReport` frames and writes them to the physical + Bluetooth DualSense transport. +5. Keep HID report `0x02` handling separate for adaptive triggers and LEDs. + +## Near-term VIIPER work from the references + +1. Add a USB `0x02` to BT `0x31` mapping helper using HIDMaestro's declared + offsets and CRC32 scope. This is for ordinary HID effects: rumble, lightbar, + mute LED, player LEDs, and adaptive trigger command blobs. +2. Keep the raw USB `0x02` report in diagnostics, because Ghost's HID output + tells us when it is selecting the haptics path versus ordinary rumble. +3. Add audio endpoint capture only after the descriptor exposes the real + DualSense audio interfaces. Until then, captures will show HID output only. + +## Debug workflow + +1. Start DualSense traffic capture in DS4Windows' VIIPER debugger. +2. Keep capture running while the game has focus. +3. Exercise the feature in game. +4. Return to DS4Windows and use Export DS Traffic. +5. Inspect the exported JSON for HID reports. If no audio endpoint capture is + present, that confirms the current virtual device is still HID-only and the + audio descriptor work is the next blocker. + +## Ghost of Tsushima bow capture, 2026-06-14 + +Capture file: + +- `%APPDATA%\DS4Windows\Logs\dualsense_traffic_20260614_103717.json` + +Summary: + +- 2,497 total traffic events. +- 1,248 parsed DualSense output reports. +- Three clear bow-draw clusters were captured. +- Each bow draw lasts roughly 1.5-1.7 seconds. +- Each bow draw contains: + - 47-48 adaptive trigger output reports. + - 94-100 compatible rumble output reports. + - large motor rumble ramping up to `0xFF`. + +Representative trigger sequence: + +```text +21 ff 03 49 92 24 09 00 00 00 00 +26 ff 03 49 92 24 09 00 00 7f 00 +26 ff 03 49 92 24 09 00 00 7d 00 +26 ff 03 49 92 24 09 00 00 7b 00 +26 ff 03 49 92 24 09 00 00 79 00 +26 ff 03 49 92 24 09 00 00 77 00 +26 ff 03 49 92 24 09 00 00 75 00 +26 ff 03 92 24 49 12 00 00 74 00 +26 ff 03 92 24 49 12 00 00 72 00 +26 ff 03 92 24 49 12 00 00 70 00 +26 ff 03 92 24 49 12 00 00 6e 00 +26 ff 03 92 24 49 12 00 00 6c 00 +``` + +Representative compatible rumble report: + +```text +02 02 40 00 ff 00 00 00 ... +``` + +Interpretation: + +- Ghost is definitely driving the adaptive trigger over normal DualSense HID + output report `0x02`. +- The harsh vibration felt during bow draw is visible as a compatible rumble + ramp, not just inferred haptics. +- This capture does not include report `0x32` haptics PCM. That is expected: + report `0x32` is the Bluetooth HID report SAxense writes to the physical + controller after it already has PCM. +- The next bridge should first forward HID report `0x02` as Bluetooth `0x31` + for adaptive triggers, while keeping compatible rumble separate from future + SAxense-style haptics PCM. + +## Ghost of Tsushima bow capture with rumble disabled, 2026-06-14 + +Capture file: + +- `%APPDATA%\DS4Windows\Logs\dualsense_traffic_20260614_104112.json` + +Summary: + +- 2,936 total traffic events. +- 1,468 parsed DualSense output reports. +- Three clear bow-draw clusters were captured. +- Each bow draw lasts roughly 1.3 seconds. +- Each bow draw contains 47 adaptive-trigger output reports. +- Compatible rumble is absent: `small=0`, `large=0` for the draw clusters. + +Interpretation: + +- Disabling game rumble removes the normal `0x02/0x40` compatible rumble ramp. +- The remaining vibration felt during bow draw comes from the adaptive trigger + effect mode itself, not from advanced haptics being converted into ordinary + controller rumble. +- This validates keeping the trigger HID path separate from the SAxense + haptics/audio path. + +## Experimental SAxense bridge in VIIPER + +VIIPER now has an experimental DualSense haptics/audio OUT endpoint: + +- endpoint: `0x05` +- type: isochronous OUT +- format advertised in the descriptor: 4-channel, 16-bit, 48000 Hz PCM + (channels 3/4 are downsampled to the Bluetooth haptics stream; channels + 1/2 remain available as the virtual controller speaker pair) + +When host software writes 64-byte haptics/audio chunks to this endpoint, +VIIPER records: + +- `audio-haptics-out`: the raw host-to-device audio/haptics bytes. +- `saxense-hid-0x32`: the generated 141-byte SAxense-style Bluetooth HID + haptics report. + +The extended DualSense feedback stream now appends one optional 141-byte +Bluetooth haptics report after the existing 76-byte feedback payload. DS4Windows +can forward that report to a real Bluetooth DualSense through its existing +physical controller handle. This creates the first end-to-end testable contract: +if Ghost opens the virtual audio endpoint, the traffic export should include +`audio-haptics-out` events, matching `saxense-hid-0x32` packets, and the +physical controller should receive report `0x32`. + +Windows reported the experimental audio interface as a failed `MEDIA` device on +`USB\VID_054C&PID_0CE6&MI_01` while the AudioControl descriptor was malformed. +The current UAC1 AudioControl topology is 0x002A bytes: header, Input Terminal, +Feature Unit, and Output Terminal. The server also implements standard +`GET_INTERFACE` / `SET_INTERFACE` handling so the audio streaming interface +can switch from alternate setting 0 to alternate setting 1. + +Captured wired DualSense descriptors use a nine-byte audio OUT endpoint with +two trailing zero bytes before the class-specific endpoint descriptor. VIIPER +preserves that hardware layout. The virtual endpoint also answers UAC1 +`GET_CUR`, `GET_MIN`, `GET_MAX`, `GET_RES`, and `SET_CUR` +sampling-frequency requests with the fixed 48 kHz format it advertises. + +Descriptor fidelity alone is not enough for usable audio. USB audio uses +isochronous URBs, including packet-descriptor arrays and realtime completion. +The current USBIP transport must implement those frames and pacing before the +experimental audio function can be considered supported. vDS validates this +requirement with a UdeCx driver that completes ISO URBs at audio cadence. + +## vDS reference findings + +`hurryman2212/vds` includes captured DualSense USB descriptor data and a +Windows UdeCx implementation. Its captured standard DualSense audio OUT path +uses high-speed USB, endpoint `0x01`, a 392-byte maximum packet, interval 4 +(one millisecond at high speed), and 48 kHz four-channel S16_LE PCM. The +driver reads each ISO packet descriptor, completes every packet, and delays the +completion by the stream duration. Those are protocol requirements, not +optimizations: immediate completions cause the host audio engine to burst PCM, +which overflows a Bluetooth haptics queue and produces stutter. + +VIIPER now models the captured endpoint values and preserves USB/IP ISO packet +descriptor arrays. A complete virtual DualSense audio implementation still +needs the remaining captured composite topology (audio input and feature-unit +controls) and hardware testing with the usbip-win2 client. + +Credit: SAxense research by egormanga/Sdore should be credited anywhere this +Bluetooth haptics packet path is surfaced to users or shipped. diff --git a/docs/research/dualsense-bluetooth-microphone.md b/docs/research/dualsense-bluetooth-microphone.md new file mode 100644 index 00000000..006dd6ce --- /dev/null +++ b/docs/research/dualsense-bluetooth-microphone.md @@ -0,0 +1,119 @@ +# DualSense Bluetooth Microphone Notes + +This document records the evidence and integration constraints for presenting a +physical Bluetooth DualSense microphone through a virtual DualSense audio +device. It is deliberately separate from the haptics notes: microphone capture +is controller-to-host traffic, while advanced haptics are host-to-controller +audio traffic. + +## Conclusion + +Bluetooth microphone capture from a physical DualSense is possible. This is not +a speculative protocol: two independently maintained Pico W bridge projects +implement it and expose it as a standard USB Audio Class capture endpoint. + +The missing piece for DS4Windows is Windows transport validation. The working +bridges own the Bluetooth HID interrupt L2CAP channel directly. DS4Windows uses +Windows HidBth through a HID device handle, so it must prove both of these paths +before a user-facing microphone feature is claimed: + +1. A Bluetooth control/audio report `0x36` of 398 bytes reaches the controller + through `WriteFile` on the HidBth HID handle. +2. HidBth delivers microphone-tagged `0x31` reports to the existing HID input + handle instead of filtering them. + +The current DS4Windows receive loop is unsafe for the second case: it CRC-checks +every Bluetooth packet as a normal 78-byte input report before classifying the +packet. A microphone frame must be recognized and queued before normal input +CRC and gamepad parsing. Otherwise it can be counted as corruption and lead to +a disconnect. + +## Observed Bluetooth microphone protocol + +Both `awalol/DS5Dongle` and `hurryman2212/DS5_Bridge` implement the same +wire behavior: + +- The controller microphone is enabled by bit 0 in byte 4 of a Bluetooth output + report `0x36`. `0xFE` means disabled and `0xFF` means enabled. +- The control report is 398 bytes, contains a `0x11` stream/control subreport, + a `0x10` SetStateData subreport, and a silent `0x12` haptics subreport. +- It is sent over the HID interrupt channel with HIDP output prefix `0xA2` and + a DualSense Bluetooth CRC32 footer (seed `0xEADA2D49`). +- The controller returns a Bluetooth input report `0x31`. Bit 1 of its flag + byte marks a microphone packet. +- In raw L2CAP framing, the Opus payload begins at byte 4: `A1 31 flags ...`. + It is a 71-byte Opus frame, mono, 48 kHz, 480 samples per frame (10 ms). +- The bridge projects decode it with `opus_decode(..., 48000, 1, 480)` and + duplicate mono samples into a stereo UAC capture endpoint because the real + DualSense audio device is commonly enumerated by Windows as stereo capture. +- The mic stream is sticky after enabling. The OLED fork sends a silent control + `0x36` at roughly 4 Hz only until frames arrive, then stops. It resumes that + keepalive when the stream stalls. This avoids making microphone capture depend + on a speaker stream and limits controller battery impact. + +## Sources checked + +All source repositories below are present in this workspace. DS5Dongle and its +OLED fork are MIT licensed. DS5_Bridge is AGPL-3.0, so it is a behavioral/spec +reference only and must not be copied into a non-AGPL component. + +| Source | Relevant evidence | +| --- | --- | +| `external/DS5Dongle/src/main.cpp` | Classifies interrupt `0x31` reports with flag bit 1, then queues `data + 4`. | +| `external/DS5Dongle/src/audio.cpp` | Defines `MIC_OPUS_SIZE = 71`, `MIC_FRAMES = 480`, creates a 48 kHz mono Opus decoder, and sends report `0x36` with the mic enable bit. | +| `external/DS5Dongle/src/bt.cpp` | Prefixes outgoing interrupt reports with `0xA2` and calculates the DualSense Bluetooth CRC. | +| `external/DS5Dongle-OLED-Edition/BLUETOOTH_AUDIO_NOTES.md` | Documents the mic-enable discovery, sticky stream behavior, four-Hz arming keepalive, Opus decoding, jitter buffer, and tests with Discord/OBS. | +| `external/DS5_Bridge/src/main.cpp` and `src/audio.cpp` | Independently classifies `0x31` flag bit 1, accepts 71-byte packets, decodes mono Opus at 48 kHz/480 frames, and has loss concealment. Behavioral reference only because of AGPL-3.0. | +| `external/vds/module/vds_hcd_core.c` | Implements the virtual DualSense audio-IN endpoint and real-time ISO-IN completion pacing, but currently fills every input packet with silence. Its README explicitly states microphone input is unsupported. This is a useful virtual-UAC reference, not a physical microphone implementation. | +| `DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs` | Current physical-controller path. Its Bluetooth input loop assumes only ordinary 78-byte `0x31` reports; it already uses `WriteOutputReportViaInterrupt` for experimental `0x32`/`0x35` reports. | + +## Exact DS4Windows experiment before implementation + +Do not wire the audio capture endpoint or add UI first. Build a narrowly scoped +diagnostic on a physical Bluetooth DualSense with verbose logging: + +1. Log HID capabilities: input, output, and feature report byte lengths. +2. Submit one CRC-correct 398-byte, silent `0x36` microphone-enable report via + `WriteOutputReportViaInterrupt`; log the returned Win32 error and exact byte + count. Do not retry rapidly. +3. Add a pre-CRC classifier for `0x31` input reports. It must log only header, + length, and the mic flag, not raw voice data. +4. If flagged frames arrive, copy exactly 71 bytes into a bounded queue and + bypass normal controller-state parsing. +5. Decode the frames in a worker with Opus PLC/jitter buffering, then expose + them only after a real virtual UAC capture endpoint exists. +6. On controller disconnect, profile change, and DS4Windows shutdown, send one + best-effort mic-disable `0x36` report and dispose decoder/queues. + +The report offset is transport-dependent: direct L2CAP sources see `A1 31 ...`; +Windows HidBth may strip the HIDP transaction prefix. The diagnostic must derive +the offset from the actual received buffer and never assume that raw L2CAP byte +positions map one-for-one onto `HidDevice.ReadFile` buffers. + +## User-facing constraints + +- Enabling controller mic should be explicit and opt-in. The documented stream + keeps the controller audio subsystem active and costs battery. +- It must not be tied to Bluetooth speaker mirroring or advanced haptics. +- It should be disabled automatically when no app has opened the virtual audio + capture endpoint, and immediately on DS4Windows shutdown. +- Audio should not be logged. Diagnostics should contain packet lengths, timing, + counters, decoder results, and errors only. + +## Relationship to virtual DualSense audio + +VIIPER still needs a full composite UAC capture interface and reliable USB/IP +isochronous IN transfer handling before Windows can enumerate the virtual +DualSense microphone. vDS demonstrates the required device-side shape: its +virtual controller has audio-IN endpoint `0x82`, 48 kHz stereo S16_LE timing, +and completion pacing at audio cadence. Its present implementation zero-fills +the ISO-IN buffers, so it cannot be lifted wholesale. The physical Bluetooth +capture protocol above is independent of that virtual-device work. It is +valuable to validate the physical capture path first, because it isolates +HidBth compatibility from UAC/USBIP descriptor work. + +## Attribution + +Any implementation that ports the MIT-licensed DS5Dongle/OLED ideas should +retain their copyright and license notice. Cite `awalol/DS5Dongle` and the +OLED fork's Bluetooth audio notes in source attribution and release notes. diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md new file mode 100644 index 00000000..5fc090af --- /dev/null +++ b/docs/testing/e2e_latency.md @@ -0,0 +1,70 @@ +# E2E Latency Benchmarks + +The script `viiper/_testing/e2e/scripts/lat_bench.go` runs (or parses) end‑to‑end input latency benchmarks and produces enriched output (table, markdown, or JSON). + +It groups repeated cycles when `-count > 1` and uses the single press E2E measurement (`E2E-InputDelay`) as the 100% baseline. + +## Output + +| Column | Meaning | +| --------------- | ----------------------------------------------------------------------------------------- | +| Benchmark | Name of the sub benchmark | +| Count | Iterations performed (from Go bench output; affected by `-benchtime`) | +| ns/op | Nanoseconds per operation (direct Go benchmark figure) | +| % of Full | Relative to `E2E-InputDelay` (single press baseline) | +| Client Share % | Portion attributed to the (go) client write phase (for E2E rows) | +| Latency Share % | Remainder attributed to transport + virtual device/host stack + tight device polling loop | + +`E2E-PressAndRelease` includes both press and release cycles, so it is expected to be ~2× the single press and thus can exceed 100% in `% of Full`. + +## Scope / Methodology + +- All benchmarks included here are executed against a VIIPER server on the same host (localhost). + They therefore measure in-process client emission plus local USBIP stack + emulated device processing only. + Remote/network USBIP attachment will add network RTT and jitter which is intentionally excluded from these baseline figures. +- Benchmarks use a single emulated Xbox360 controller device. + Other devices might produce slightly different results depending on USB report size and VIIPER-InputState size. +- Benchmarks use a single button press, which is enough as clients/VIIPER always produce a full report of the devices state. + +## Benchtime Mode + +Runs use a fixed-iteration benchtime (e.g. `-benchtime=1000x`, `-benchtime=10000x`) rather than time-based (e.g. `2s`). + +## Running + +From repository root: + +```bash +cd testing/e2e +# Single run, 1000 fixed iterations per sub benchmark +go run ./scripts/lat_bench.go -benchtime=1000x -count=1 -format markdown +``` + +Results (Arch Linux / SteamDeck Kernel / Steam Deck LCD / Go 1.25+, 10k iterations): + +| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | +| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | +| 1_Go-Client-Write | 10000 | 10668 | 11.98 | 100.00 | 0.00 | +| 2_InputDelay-Without-Client | 10000 | 74154 | 83.25 | 0.00 | 100.00 | +| 3_E2E-InputDelay | 10000 | 89078 | 100.00 | 11.98 | 88.02 | +| 4_E2E-PressAndRelease | 10000 | 184870 | 207.54 | 11.54 | 88.46 | + +Example output (Windows / AMD Ryzen 9 3900X / Go 1.25+, 10k iterations): + +| Benchmark | Count | ns/op | % of Full | Client Share % | Latency Share % | +| --------------------------- | ----- | ------ | --------- | -------------- | --------------- | +| 1_Go-Client-Write | 10000 | 27933 | 16.60 | 100.00 | 0.00 | +| 2_InputDelay-Without-Client | 10000 | 133724 | 79.45 | 0.00 | 100.00 | +| 3_E2E-InputDelay | 10000 | 168307 | 100.00 | 16.60 | 83.40 | +| 4_E2E-PressAndRelease | 10000 | 331439 | 196.93 | 16.86 | 83.14 | + +Variability across repeated measurement runs has been negligible. +Use a larger `-count` if you want to increase the number of runs. + +## Notes + +- Memory statistics from Go benchmarks are intentionally omitted. +- `% of Full` falls back to the largest ns/op if the baseline row is missing. +- All benchmarking must run with parallelism 1 in underlying benches. +- Benchmarks use a tight polling loop using SDL3 to detect input state changes on the emulated device. +- Benchmarks must be run without an already running VIIPER server instance. diff --git a/docs/viiper.svg b/docs/viiper.svg index c7716b80..6ab3e89e 100644 --- a/docs/viiper.svg +++ b/docs/viiper.svg @@ -1 +1,158 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/.gitignore b/examples/.gitignore index d1638636..cbe46e51 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1 +1,3 @@ -build/ \ No newline at end of file +build/ +target/ +deps/ \ No newline at end of file diff --git a/examples/.vscode/launch.json b/examples/.vscode/launch.json index 9d0a2c93..8057fb10 100644 --- a/examples/.vscode/launch.json +++ b/examples/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "debug", - "program": "${workspaceFolder}/gp/virtual_x360_pad", + "program": "${workspaceFolder}/go/virtual_x360_pad", "args": [ "localhost:3242", ], diff --git a/examples/c/CMakeLists.txt b/examples/c/CMakeLists.txt deleted file mode 100644 index 5b23bfa9..00000000 --- a/examples/c/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(viiper_examples_c C) - -add_subdirectory(virtual_x360_pad) -add_subdirectory(virtual_keyboard) diff --git a/examples/c/virtual_keyboard/CMakeLists.txt b/examples/c/virtual_keyboard/CMakeLists.txt deleted file mode 100644 index fb9740a7..00000000 --- a/examples/c/virtual_keyboard/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(virtual_keyboard C) - -# Include generated SDK headers -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../../clients/c/include) - -add_executable(virtual_keyboard main.c) - -# Link against generated viiper library -if (WIN32) - set(VIIPER_LIB ${CMAKE_CURRENT_SOURCE_DIR}/../../../clients/c/build/Release/viiper.lib) -else() - set(VIIPER_LIB ${CMAKE_CURRENT_SOURCE_DIR}/../../../clients/c/build/libviiper.a) -endif() - -target_link_libraries(virtual_keyboard PRIVATE ${VIIPER_LIB}) - -# Copy runtime DLL next to executable on Windows so running from build dir works -if (WIN32) - add_custom_command(TARGET virtual_keyboard POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VIIPER_LIB}" "$" - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${CMAKE_CURRENT_SOURCE_DIR}/../../../clients/c/build/Release/viiper.dll" "$" - ) -endif() diff --git a/examples/c/virtual_keyboard/main.c b/examples/c/virtual_keyboard/main.c deleted file mode 100644 index 02265dd8..00000000 --- a/examples/c/virtual_keyboard/main.c +++ /dev/null @@ -1,159 +0,0 @@ -#include "viiper/viiper.h" -#include "viiper/viiper_keyboard.h" - -#include -#include -#include -#include -#include - -#if defined(_WIN32) || defined(_WIN64) -# include -static void sleep_ms(int ms) { Sleep(ms); } -#else -# include -static void sleep_ms(int ms) { usleep(ms * 1000); } -#endif - -static uint32_t choose_or_create_bus(viiper_client_t* client) -{ - viiper_bus_list_response_t list; memset(&list, 0, sizeof list); - if (viiper_bus_list(client, &list) != VIIPER_OK) { - fprintf(stderr, "BusList error: %s\n", viiper_get_error(client)); - return 0; - } - uint32_t busId = 0; - if (list.buses_count > 0) { - busId = list.Buses[0]; - for (size_t i = 1; i < list.buses_count; ++i) - if (list.Buses[i] < busId) busId = list.Buses[i]; - printf("Using existing bus %u\n", (unsigned)busId); - viiper_free_bus_list_response(&list); - return busId; - } - viiper_free_bus_list_response(&list); - - viiper_bus_create_response_t cr; memset(&cr, 0, sizeof cr); - if (viiper_bus_create(client, NULL, &cr) == VIIPER_OK) { - printf("Created bus %u\n", (unsigned)cr.BusID); - return cr.BusID; - } - fprintf(stderr, "BusCreate failed: %s\n", viiper_get_error(client)); - return 0; -} - -static void on_leds(const void* output, size_t output_size, void* user) -{ - (void)user; - if (!output || output_size == 0) return; - const uint8_t* p = (const uint8_t*)output; - /* Keyboard LED state is 1 byte messages; handle possible coalesced bytes */ - for (size_t i = 0; i < output_size; ++i) { - uint8_t b = p[i]; - printf("→ LEDs: Num=%u Caps=%u Scroll=%u Compose=%u Kana=%u\n", - (b & VIIPER_KEYBOARD_LEDNUMLOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LEDCAPSLOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LEDSCROLLLOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LEDCOMPOSE) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LEDKANA) ? 1u : 0u); - } -} - -static void send_keys(viiper_device_t* dev, uint8_t modifiers, const uint8_t* keys, uint8_t nkeys) -{ - /* Build packet: [modifiers, count, keys...] with true N-key rollover */ - if (!keys && nkeys) return; - /* Protocol uses u8 for count */ - size_t pkt_len = (size_t)2 + (size_t)nkeys; - uint8_t* buf = (uint8_t*)malloc(pkt_len); - if (!buf) return; - buf[0] = modifiers; - buf[1] = nkeys; - if (nkeys && keys) memcpy(buf + 2, keys, nkeys); - viiper_device_send(dev, buf, pkt_len); - free(buf); -} - -static void press_and_release(viiper_device_t* dev, uint8_t modifiers, uint8_t key) -{ - uint8_t keys[1] = { key }; - send_keys(dev, modifiers, keys, 1); - sleep_ms(100); - send_keys(dev, 0, NULL, 0); /* release all */ - sleep_ms(100); -} - -static void type_string_hello(viiper_device_t* dev) -{ - /* H e l l o ! */ - press_and_release(dev, VIIPER_KEYBOARD_MODLEFTSHIFT, VIIPER_KEYBOARD_KEYH); /* 'H' */ - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYE); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYL); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYL); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYO); - /* '!' = Shift + '1' */ - press_and_release(dev, VIIPER_KEYBOARD_MODLEFTSHIFT, VIIPER_KEYBOARD_KEY1); -} - -int main(int argc, char** argv) -{ - /* Args: 0 -> default 127.0.0.1:3242; 1 -> host:port */ - char host[256]; uint16_t port = 3242; - if (argc <= 1) { - snprintf(host, sizeof host, "%s", "127.0.0.1"); - } else if (argc == 2) { - const char* addr = argv[1]; const char* colon = strrchr(addr, ':'); - if (!colon) { fprintf(stderr, "Usage: virtual_keyboard [host:port]\n"); return 1; } - size_t len = (size_t)(colon - addr); if (len >= sizeof(host)) len = sizeof(host)-1; - memcpy(host, addr, len); host[len] = '\0'; - port = (uint16_t)atoi(colon + 1); - } else { fprintf(stderr, "Usage: virtual_keyboard [host:port]\n"); return 1; } - - viiper_client_t* client = NULL; - if (viiper_client_create(host, port, &client) != VIIPER_OK) { - fprintf(stderr, "client_create failed\n"); - return 1; - } - - uint32_t busId = choose_or_create_bus(client); - if (busId == 0) { viiper_client_free(client); return 1; } - - /* Create device via management API */ - viiper_device_add_response_t addResp; memset(&addResp, 0, sizeof addResp); - char busIdStr[16]; snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)busId); - viiper_error_t rc = viiper_bus_device_add(client, busIdStr, "keyboard", &addResp); - if (rc != VIIPER_OK) { - fprintf(stderr, "device_add failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); return 1; - } - const char* hyphen = strchr(addResp.ID ? addResp.ID : "", '-'); - if (!hyphen || !*(hyphen+1)) { - fprintf(stderr, "Malformed device ID: %s\n", addResp.ID ? addResp.ID : "(null)"); - viiper_free_device_add_response(&addResp); - viiper_client_free(client); return 1; - } - const char* devId = hyphen + 1; - viiper_device_t* dev = NULL; - rc = viiper_device_create(client, busId, devId, &dev); - viiper_free_device_add_response(&addResp); - if (rc != VIIPER_OK) { - fprintf(stderr, "device_create failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); return 1; - } - - /* Register LED callback */ - viiper_device_on_output(dev, on_leds, NULL); - - printf("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.\n"); - for (;;) { - type_string_hello(dev); - sleep_ms(100); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYENTER); - printf("→ Typed: Hello!\n"); - sleep_ms(5000); - } - - viiper_device_close(dev); - viiper_client_free(client); - return 0; -} diff --git a/examples/c/virtual_x360_pad/CMakeLists.txt b/examples/c/virtual_x360_pad/CMakeLists.txt deleted file mode 100644 index 4d954658..00000000 --- a/examples/c/virtual_x360_pad/CMakeLists.txt +++ /dev/null @@ -1,21 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(virtual_x360_pad C) - -set(CMAKE_C_STANDARD 99) - -# Paths to the generated C SDK -set(VIIPER_SDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../clients/c") -set(VIIPER_INCLUDE_DIR "${VIIPER_SDK_ROOT}/include") -set(VIIPER_LIB_DIR "${VIIPER_SDK_ROOT}/build/$") - -add_executable(virtual_x360_pad main.c) - -target_include_directories(virtual_x360_pad PRIVATE "${VIIPER_INCLUDE_DIR}") - -target_link_libraries(virtual_x360_pad PRIVATE "${VIIPER_LIB_DIR}/viiper.lib") - -add_custom_command(TARGET virtual_x360_pad POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VIIPER_LIB_DIR}/viiper.dll" - "$" -) diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c deleted file mode 100644 index 980d685b..00000000 --- a/examples/c/virtual_x360_pad/main.c +++ /dev/null @@ -1,194 +0,0 @@ -#include "viiper/viiper.h" -#include "viiper/viiper_xbox360.h" - -#include -#include -#include -#include -#include - -#if defined(_WIN32) || defined(_WIN64) -# include -static void sleep_ms(int ms) -{ - Sleep(ms); -} -#else -# include -static void sleep_ms(int ms) -{ - usleep(ms * 1000); -} -#endif - -static uint32_t choose_or_create_bus(viiper_client_t* client) -{ - viiper_bus_list_response_t list; - memset(&list, 0, sizeof list); - if (viiper_bus_list(client, &list) != VIIPER_OK){ - fprintf(stderr, "BusList error: %s\n", viiper_get_error(client)); - return 0; - } - uint32_t busId = 0; - if (list.buses_count > 0){ - busId = list.Buses[0]; - for (size_t i = 1; i < list.buses_count; i++) - { - if (list.Buses[i] < busId) - { - busId = list.Buses[i]; - } - } - printf("Using existing bus %u\n", (unsigned)busId); - viiper_free_bus_list_response(&list); - return busId; - } - viiper_free_bus_list_response(&list); - - viiper_bus_create_response_t cr; - memset(&cr, 0, sizeof cr); - if (viiper_bus_create(client, NULL, &cr) == VIIPER_OK){ - printf("Created bus %u\n", (unsigned)cr.BusID); - return cr.BusID; - } - fprintf(stderr, "BusCreate failed: %s\n", viiper_get_error(client)); - return 0; -} - -static void on_rumble(const void* output, size_t output_size, void* user) -{ - (void)user; - if (output_size >= 2) { - const viiper_xbox360_output_t* out = (const viiper_xbox360_output_t*)output; - printf("← Rumble: Left=%u, Right=%u\n", out->left, out->right); - } -} - -int main(int argc, char** argv) -{ - /* Args: 0 -> default 127.0.0.1:3242; 1 -> host:port; else usage */ - char host[256]; - uint16_t port = 3242; - if (argc <= 1) - { - snprintf(host, sizeof host, "%s", "127.0.0.1"); - } - else if (argc == 2) - { - const char* addr = argv[1]; - const char* colon = strrchr(addr, ':'); - if (!colon) - { - fprintf(stderr, "Usage: virtual_x360_pad [host:port]\n"); - return 1; - } - size_t len = (size_t)(colon - addr); - if (len >= sizeof(host)) len = sizeof(host) - 1; - memcpy(host, addr, len); - host[len] = '\0'; - port = (uint16_t)atoi(colon + 1); - } - else - { - fprintf(stderr, "Usage: virtual_x360_pad [host:port]\n"); - return 1; - } - - viiper_client_t* client = NULL; - if (viiper_client_create(host, port, &client) != VIIPER_OK){ - fprintf(stderr, "client_create failed\n"); - return 1; - } - - uint32_t busId = choose_or_create_bus(client); - if (busId == 0) - { - viiper_client_free(client); - return 1; - } - - /* First create device via management API */ - viiper_device_add_response_t addResp; - memset(&addResp, 0, sizeof addResp); - char busIdStr[16]; - snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)busId); - viiper_error_t rc = viiper_bus_device_add(client, busIdStr, "xbox360", &addResp); - if (rc != VIIPER_OK){ - fprintf(stderr, "device_add failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); - return 1; - } - /* Parse id like "busId-devId" */ - const char* hyphen = strchr(addResp.ID ? addResp.ID : "", '-'); - if (!hyphen || !*(hyphen+1)) { - fprintf(stderr, "Malformed device ID: %s\n", addResp.ID ? addResp.ID : "(null)"); - viiper_free_device_add_response(&addResp); - viiper_client_free(client); - return 1; - } - const char* devId = hyphen + 1; - printf("Created device %s on bus %u\n", devId, (unsigned)busId); - - /* Open device stream (generic API) */ - viiper_device_t* dev = NULL; - rc = viiper_device_create(client, busId, devId, &dev); - viiper_free_device_add_response(&addResp); - if (rc != VIIPER_OK){ - fprintf(stderr, "device_create failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); - return 1; - } - - /* Register async backchannel callback */ - viiper_device_on_output(dev, on_rumble, NULL); - printf("Connected to device stream\n"); - - unsigned long long frame = 0; - for (;;){ - ++frame; - viiper_xbox360_input_t in; - memset(&in, 0, sizeof in); - switch ((frame / 60) % 4){ - case 0: - in.buttons = VIIPER_XBOX360_BUTTONA; - break; - case 1: - in.buttons = VIIPER_XBOX360_BUTTONB; - break; - case 2: - in.buttons = VIIPER_XBOX360_BUTTONX; - break; - default: - in.buttons = VIIPER_XBOX360_BUTTONY; - break; - } - in.lt = (uint8_t)((frame * 2) % 256); - in.rt = (uint8_t)((frame * 3) % 256); - in.lx = (int16_t)(20000.0 * 0.7071); - in.ly = (int16_t)(20000.0 * 0.7071); - in.rx = 0; - in.ry = 0; - rc = viiper_device_send(dev, &in, sizeof(in)); - if (rc != VIIPER_OK){ - fprintf(stderr, "send error: %s\n", viiper_get_error(client)); - break; - } - if (frame % 60 == 0){ - printf("→ Sent input (frame %llu): buttons=0x%04x, LT=%u, RT=%u\n", frame, (unsigned)in.buttons, in.lt, in.rt); - } - - sleep_ms(16); - } - - viiper_device_close(dev); - // Optional: remove bus (will fail if devices are still present) - char idbuf[16]; - snprintf(idbuf, sizeof idbuf, "%u", (unsigned)busId); - viiper_bus_remove_response_t br; - memset(&br, 0, sizeof br); - if (viiper_bus_remove(client, idbuf, &br) != VIIPER_OK){ - fprintf(stderr, "BusRemove failed (continuing): %s\n", viiper_get_error(client)); - } - viiper_client_free(client); - return 0; -} diff --git a/examples/cpp/CMakeLists.txt b/examples/cpp/CMakeLists.txt new file mode 100644 index 00000000..ff35b72b --- /dev/null +++ b/examples/cpp/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.14) +project(viiper-cpp-examples LANGUAGES CXX) + +# Require C++20 (SDK uses concepts) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Strict warnings +if(MSVC) + add_compile_options(/W4) +else() + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# VIIPER SDK is header-only - just add include path +set(VIIPER_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../clients/cpp/include") + +# Fetch nlohmann_json via FetchContent +include(FetchContent) +FetchContent_Declare(json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(json) + +find_package(OpenSSL REQUIRED) + +# Create interface library for VIIPER includes +add_library(viiper_sdk INTERFACE) +target_include_directories(viiper_sdk INTERFACE ${VIIPER_INCLUDE_DIR}) +target_link_libraries(viiper_sdk INTERFACE OpenSSL::SSL OpenSSL::Crypto) +target_link_libraries(viiper_sdk INTERFACE nlohmann_json::nlohmann_json) + +# Example: virtual_keyboard +add_executable(virtual_keyboard virtual_keyboard.cpp) +target_link_libraries(virtual_keyboard PRIVATE viiper_sdk) + +# Example: virtual_mouse +add_executable(virtual_mouse virtual_mouse.cpp) +target_link_libraries(virtual_mouse PRIVATE viiper_sdk) + +# Example: virtual_x360_pad +add_executable(virtual_x360_pad virtual_x360_pad.cpp) +target_link_libraries(virtual_x360_pad PRIVATE viiper_sdk) + +# Platform-specific libraries +if(WIN32) + target_link_libraries(virtual_keyboard PRIVATE ws2_32) + target_link_libraries(virtual_mouse PRIVATE ws2_32) + target_link_libraries(virtual_x360_pad PRIVATE ws2_32) +else() + find_package(Threads REQUIRED) + target_link_libraries(virtual_keyboard PRIVATE Threads::Threads) + target_link_libraries(virtual_mouse PRIVATE Threads::Threads) + target_link_libraries(virtual_x360_pad PRIVATE Threads::Threads) +endif() diff --git a/examples/cpp/virtual_keyboard.cpp b/examples/cpp/virtual_keyboard.cpp new file mode 100644 index 00000000..16c13b1d --- /dev/null +++ b/examples/cpp/virtual_keyboard.cpp @@ -0,0 +1,169 @@ +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include +#include +#include +#include +#include +#include + +std::atomic running{true}; + +void signal_handler(int) { + running = false; +} + +void type_string(viiper::ViiperDevice& stream, const std::string& text) { + for (char ch : text) { + auto it = viiper::keyboard::CHAR_TO_KEY.find(static_cast(ch)); + if (it == viiper::keyboard::CHAR_TO_KEY.end()) continue; + std::uint8_t key = it->second; + + std::uint8_t mods = 0; + if (viiper::keyboard::SHIFT_CHARS.contains(static_cast(ch))) { + mods = viiper::keyboard::ModLeftShift; + } + + viiper::keyboard::Input down = { + .modifiers = mods, + .keys = {key}, + }; + stream.send(down); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + viiper::keyboard::Input up = { + .modifiers = 0, + .keys = {}, + }; + stream.send(up); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + +void press_key(viiper::ViiperDevice& stream, std::uint8_t key) { + viiper::keyboard::Input press = { + .modifiers = 0, + .keys = {key}, + }; + stream.send(press); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + viiper::keyboard::Input release = { + .modifiers = 0, + .keys = {}, + }; + stream.send(release); +} + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " \n"; + std::cerr << "Example: " << argv[0] << " localhost:3242\n"; + return 1; + } + + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + + const std::string addr = argv[1]; + const auto colon_pos = addr.find(':'); + const std::string host = addr.substr(0, colon_pos); + const std::uint16_t port = colon_pos != std::string::npos + ? static_cast(std::stoul(addr.substr(colon_pos + 1))) + : 3242; + + viiper::ViiperClient client(host, port); + + // Find or create a bus + std::uint32_t bus_id; + bool created_bus = false; + + auto buses_result = client.buslist(); + if (buses_result.is_error()) { + std::cerr << "BusList error: " << buses_result.error().to_string() << "\n"; + return 1; + } + + if (buses_result.value().buses.empty()) { + auto create_result = client.buscreate(std::nullopt); + if (create_result.is_error()) { + std::cerr << "BusCreate failed: " << create_result.error().to_string() << "\n"; + return 1; + } + bus_id = create_result.value().busid; + created_bus = true; + std::cout << "Created bus " << bus_id << "\n"; + } else { + bus_id = buses_result.value().buses[0]; + std::cout << "Using existing bus " << bus_id << "\n"; + } + + // Add device + auto device_result = client.busdeviceadd(bus_id, {.type = "keyboard"}); + if (device_result.is_error()) { + std::cerr << "AddDevice error: " << device_result.error().to_string() << "\n"; + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto device_info = std::move(device_result.value()); + + // Connect to device stream + auto stream_result = client.connectDevice(device_info.busid, device_info.devid); + if (stream_result.is_error()) { + std::cerr << "ConnectDevice error: " << stream_result.error().to_string() << "\n"; + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto stream = std::move(stream_result.value()); + + std::cout << "Created and connected to device " << device_info.devid + << " on bus " << device_info.busid << "\n"; + + stream->on_disconnect([]() { + std::cerr << "Device disconnected by server\n"; + std::exit(0); + }); + + stream->on_output(viiper::keyboard::OUTPUT_SIZE, [](const std::uint8_t* data, std::size_t len) { + if (len < viiper::keyboard::OUTPUT_SIZE) return; + auto result = viiper::keyboard::Output::from_bytes(data, len); + if (result.is_error()) return; + auto& leds = result.value(); + bool num_lock = (leds.leds & 0x01) != 0; + bool caps_lock = (leds.leds & 0x02) != 0; + bool scroll_lock = (leds.leds & 0x04) != 0; + bool compose = (leds.leds & 0x08) != 0; + bool kana = (leds.leds & 0x10) != 0; + std::cout << "← LEDs: Num=" << num_lock << " Caps=" << caps_lock + << " Scroll=" << scroll_lock << " Compose=" << compose + << " Kana=" << kana << "\n"; + }); + + std::cout << "Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.\n"; + + // Type "Hello!" + Enter every 5 seconds + while (running && stream->is_connected()) { + type_string(*stream, "Hello!"); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + press_key(*stream, viiper::keyboard::KeyEnter); + + std::cout << "→ Typed: Hello!\n"; + std::this_thread::sleep_for(std::chrono::seconds(5)); + } + + // Cleanup + stream->stop(); + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + + return 0; +} diff --git a/examples/cpp/virtual_mouse.cpp b/examples/cpp/virtual_mouse.cpp new file mode 100644 index 00000000..671b3077 --- /dev/null +++ b/examples/cpp/virtual_mouse.cpp @@ -0,0 +1,146 @@ +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include +#include +#include +#include +#include +#include + +std::atomic running{true}; + +void signal_handler(int) { + running = false; +} + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " \n"; + std::cerr << "Example: " << argv[0] << " localhost:3242\n"; + return 1; + } + + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + + const std::string addr = argv[1]; + const auto colon_pos = addr.find(':'); + const std::string host = addr.substr(0, colon_pos); + const std::uint16_t port = colon_pos != std::string::npos + ? static_cast(std::stoul(addr.substr(colon_pos + 1))) + : 3242; + + viiper::ViiperClient client(host, port); + + // Find or create a bus + std::uint32_t bus_id; + bool created_bus = false; + + auto buses_result = client.buslist(); + if (buses_result.is_error()) { + std::cerr << "BusList error: " << buses_result.error().to_string() << "\n"; + return 1; + } + + if (buses_result.value().buses.empty()) { + auto create_result = client.buscreate(std::nullopt); + if (create_result.is_error()) { + std::cerr << "BusCreate failed: " << create_result.error().to_string() << "\n"; + return 1; + } + bus_id = create_result.value().busid; + created_bus = true; + std::cout << "Created bus " << bus_id << "\n"; + } else { + bus_id = buses_result.value().buses[0]; + std::cout << "Using existing bus " << bus_id << "\n"; + } + + // Add device + auto device_result = client.busdeviceadd(bus_id, {.type = "mouse"}); + if (device_result.is_error()) { + std::cerr << "AddDevice error: " << device_result.error().to_string() << "\n"; + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto device_info = std::move(device_result.value()); + + // Connect to device stream + auto stream_result = client.connectDevice(device_info.busid, device_info.devid); + if (stream_result.is_error()) { + std::cerr << "ConnectDevice error: " << stream_result.error().to_string() << "\n"; + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto stream = std::move(stream_result.value()); + + std::cout << "Created and connected to device " << device_info.devid + << " on bus " << device_info.busid << "\n"; + + std::cout << "Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop.\n"; + + // Send a short movement once every 3 seconds for easy local testing. + // Followed by a short click and a single scroll notch. + int dir = 1; + constexpr int step = 50; // move diagonally by 50 px in X and Y + + while (running && stream->is_connected()) { + // Move diagonally: (+step,+step) then (-step,-step) next tick + std::int16_t dx = static_cast(step * dir); + std::int16_t dy = static_cast(step * dir); + dir *= -1; + + // One-shot movement report (diagonal) + auto send_result = stream->send(viiper::mouse::Input{ + .buttons = 0, + .dx = dx, + .dy = dy, + .wheel = 0, + .pan = 0, + }); + if (send_result.is_error()) { + std::cerr << "Write error: " << send_result.error().to_string() << "\n"; + break; + } + std::cout << "→ Moved mouse dx=" << static_cast(dx) << " dy=" << static_cast(dy) << "\n"; + + // Zero state shortly after to keep movement one-shot (harmless safety) + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + stream->send(viiper::mouse::Input{.buttons = 0, .dx = 0, .dy = 0, .wheel = 0, .pan = 0}); + + // Simulate a short left click: press then release + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + stream->send(viiper::mouse::Input{ + .buttons = viiper::mouse::BtnLeft, + .dx = 0, .dy = 0, .wheel = 0, .pan = 0, + }); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + stream->send(viiper::mouse::Input{.buttons = 0, .dx = 0, .dy = 0, .wheel = 0, .pan = 0}); + std::cout << "→ Clicked (left)\n"; + + // Simulate a short scroll: one notch upwards + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + stream->send(viiper::mouse::Input{.buttons = 0, .dx = 0, .dy = 0, .wheel = 1, .pan = 0}); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + stream->send(viiper::mouse::Input{.buttons = 0, .dx = 0, .dy = 0, .wheel = 0, .pan = 0}); + std::cout << "→ Scrolled (wheel=+1)\n"; + + std::this_thread::sleep_for(std::chrono::seconds(3)); + } + + // Cleanup + stream->stop(); + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + + return 0; +} diff --git a/examples/cpp/virtual_x360_pad.cpp b/examples/cpp/virtual_x360_pad.cpp new file mode 100644 index 00000000..8855534e --- /dev/null +++ b/examples/cpp/virtual_x360_pad.cpp @@ -0,0 +1,150 @@ +#define VIIPER_JSON_INCLUDE +#define VIIPER_JSON_NAMESPACE nlohmann +#define VIIPER_JSON_TYPE json + +#include +#include +#include +#include +#include +#include +#include + +std::atomic running{true}; + +void signal_handler(int) { + running = false; +} + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " \n"; + std::cerr << "Example: " << argv[0] << " localhost:3242\n"; + return 1; + } + + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + + const std::string addr = argv[1]; + const auto colon_pos = addr.find(':'); + const std::string host = addr.substr(0, colon_pos); + const std::uint16_t port = colon_pos != std::string::npos + ? static_cast(std::stoul(addr.substr(colon_pos + 1))) + : 3242; + + viiper::ViiperClient client(host, port); + + // Find or create a bus + std::uint32_t bus_id; + bool created_bus = false; + + auto buses_result = client.buslist(); + if (buses_result.is_error()) { + std::cerr << "BusList error: " << buses_result.error().to_string() << "\n"; + return 1; + } + + if (buses_result.value().buses.empty()) { + auto create_result = client.buscreate(std::nullopt); + if (create_result.is_error()) { + std::cerr << "BusCreate failed: " << create_result.error().to_string() << "\n"; + return 1; + } + bus_id = create_result.value().busid; + created_bus = true; + std::cout << "Created bus " << bus_id << "\n"; + } else { + bus_id = buses_result.value().buses[0]; + std::cout << "Using existing bus " << bus_id << "\n"; + } + + // Add device + auto device_result = client.busdeviceadd(bus_id, {.type = "xbox360"}); + if (device_result.is_error()) { + std::cerr << "AddDevice error: " << device_result.error().to_string() << "\n"; + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto device_info = std::move(device_result.value()); + + // Connect to device stream + auto stream_result = client.connectDevice(device_info.busid, device_info.devid); + if (stream_result.is_error()) { + std::cerr << "ConnectDevice error: " << stream_result.error().to_string() << "\n"; + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + return 1; + } + auto stream = std::move(stream_result.value()); + + std::cout << "Created and connected to device " << device_info.devid + << " on bus " << device_info.busid << "\n"; + + stream->on_disconnect([]() { + std::cerr << "Device disconnected by server\n"; + std::exit(0); + }); + + stream->on_output(viiper::xbox360::OUTPUT_SIZE, [](const std::uint8_t* data, std::size_t len) { + if (len < viiper::xbox360::OUTPUT_SIZE) return; + auto result = viiper::xbox360::Output::from_bytes(data, len); + if (result.is_error()) return; + auto& rumble = result.value(); + std::cout << "← Rumble: Left=" << static_cast(rumble.left) + << ", Right=" << static_cast(rumble.right) << "\n"; + }); + + // Send controller inputs at 60fps (16ms intervals) + std::uint64_t frame = 0; + + while (running && stream->is_connected()) { + ++frame; + + std::uint16_t buttons; + switch ((frame / 60) % 4) { + case 0: buttons = viiper::xbox360::ButtonA; break; + case 1: buttons = viiper::xbox360::ButtonB; break; + case 2: buttons = viiper::xbox360::ButtonX; break; + default: buttons = viiper::xbox360::ButtonY; break; + } + + viiper::xbox360::Input state = { + .buttons = buttons, + .lt = static_cast((frame * 2) % 256), + .rt = static_cast((frame * 3) % 256), + .lx = static_cast(20000.0 * 0.7071), + .ly = static_cast(20000.0 * 0.7071), + .rx = 0, + .ry = 0, + }; + + auto send_result = stream->send(state); + if (send_result.is_error()) { + std::cerr << "Write error: " << send_result.error().to_string() << "\n"; + break; + } + + if (frame % 60 == 0) { + std::cout << "→ Sent input (frame " << frame << "): buttons=0x" + << std::hex << state.buttons << std::dec + << ", LT=" << static_cast(state.lt) + << ", RT=" << static_cast(state.rt) << "\n"; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + + // Cleanup + stream->stop(); + client.busdeviceremove(device_info.busid, device_info.devid); + if (created_bus) { + client.busremove(bus_id); + } + + return 0; +} diff --git a/examples/csharp/.gitignore b/examples/csharp/.gitignore new file mode 100644 index 00000000..c7569574 --- /dev/null +++ b/examples/csharp/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ \ No newline at end of file diff --git a/examples/csharp/virtual_ds4_pad/Program.cs b/examples/csharp/virtual_ds4_pad/Program.cs new file mode 100644 index 00000000..56cd75f5 --- /dev/null +++ b/examples/csharp/virtual_ds4_pad/Program.cs @@ -0,0 +1,119 @@ +using Viiper.Client; +using Viiper.Client.Devices.Dualshock4; +using Viiper.Client.Types; + +if (args.Length < 1) +{ + Console.WriteLine("Usage: dotnet run -- [port]"); + Console.WriteLine("Example: dotnet run -- localhost 3242"); + return; +} +var host = args[0]; +var port = args.Length > 1 && int.TryParse(args[1], out var p) ? p : 3242; +var client = new ViiperClient(host, port); + +// Find or create a bus +uint busId; +bool createdBus = false; +{ + var list = await client.BusListAsync(); + if (list.Buses.Length == 0) + { + try + { + var r = await client.BusCreateAsync(null); + busId = r.BusID; + createdBus = true; + Console.WriteLine($"Created bus {busId}"); + } + catch (Exception ex) + { + Console.WriteLine($"BusCreate failed: {ex}"); + return; + } + } + else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } +} + +// Add device and connect +Device resp; ViiperDevice device; +try +{ + resp = await client.BusDeviceAddAsync(busId, new DeviceCreateRequest { Type = "dualshock4" }); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); +} +catch (Exception ex) +{ + if (createdBus) { try { await client.BusRemoveAsync(busId); } catch { } } + Console.WriteLine($"AddDevice/connect error: {ex}"); + return; +} + +AppDomain.CurrentDomain.ProcessExit += async (_, __) => await Cleanup(); +Console.CancelKeyPress += async (_, e) => { e.Cancel = true; await Cleanup(); Environment.Exit(0); }; + +async Task Cleanup() +{ + try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevID); Console.WriteLine($"Removed device {resp.DevID}"); } catch { } + if (createdBus) { try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } } +} + +// Read rumble/LED output using callback with stream +device.OnOutput = async stream => +{ + var buf = new byte[Dualshock4.OutputSize]; + await stream.ReadAsync(buf, 0, buf.Length); + byte rumbleSmall = buf[0]; + byte rumbleLarge = buf[1]; + byte ledRed = buf[2]; + byte ledGreen = buf[3]; + byte ledBlue = buf[4]; + byte flashOn = buf[5]; + byte flashOff = buf[6]; + Console.WriteLine($"← Output: RumbleSmall={rumbleSmall}, RumbleLarge={rumbleLarge}, LED=#{ledRed:X2}{ledGreen:X2}{ledBlue:X2}, Flash={flashOn}/{flashOff}"); +}; + +// Handle disconnect +device.OnDisconnect = () => Console.WriteLine("!!! Server disconnected"); + +// Send inputs at ~60 FPS +var sw = new PeriodicTimer(TimeSpan.FromMilliseconds(16)); +ulong frame = 0; +while (await sw.WaitForNextTickAsync()) +{ + frame++; + ushort buttons = (ushort)(((frame / 60) % 4) switch + { + 0 => (ulong)Button.Cross, + 1 => (ulong)Button.Circle, + 2 => (ulong)Button.Square, + _ => (ulong)Button.Triangle, + }); + var state = new Dualshock4Input + { + Buttons = buttons, + Dpad = (byte)D.PadUSBNeutral, + Sticklx = (sbyte)((frame * 2) % 128), + Stickly = (sbyte)((frame * 3) % 128), + Stickrx = 0, + Stickry = 0, + Triggerl2 = (byte)((frame * 2) % 256), + Triggerr2 = (byte)((frame * 3) % 256), + Touch1x = 0, + Touch1y = 0, + Touch1active = 0, + Touch2x = 0, + Touch2y = 0, + Touch2active = 0, + Gyrox = 0, + Gyroy = 0, + Gyroz = 0, + Accelx = 0, + Accely = 0, + Accelz = (short)Default.AccelZRaw, + }; + await device.SendAsync(state); + if (frame % 60 == 0) + Console.WriteLine($"→ Sent input (frame {frame}): buttons=0x{buttons:X4}, L2={state.Triggerl2}, R2={state.Triggerr2}"); +} diff --git a/examples/csharp/virtual_ds4_pad/VirtualDs4Pad.csproj b/examples/csharp/virtual_ds4_pad/VirtualDs4Pad.csproj new file mode 100644 index 00000000..dbc51a0a --- /dev/null +++ b/examples/csharp/virtual_ds4_pad/VirtualDs4Pad.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs new file mode 100644 index 00000000..c2ec9ab9 --- /dev/null +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -0,0 +1,148 @@ +using System.Buffers; +using System.Text; +using Viiper.Client; +using Viiper.Client.Devices.Keyboard; +using Viiper.Client.Types; + +// Usage: dotnet run -- (e.g., localhost) +if (args.Length < 1) +{ + Console.WriteLine("Usage: dotnet run -- [port]"); + Console.WriteLine("Example: dotnet run -- localhost 3242"); + return; +} + +var host = args[0]; +var port = args.Length > 1 && int.TryParse(args[1], out var p) ? p : 3242; +var client = new ViiperClient(host, port); + +// Find or create a bus +uint busId; +bool createdBus = false; +{ + var list = await client.BusListAsync(); + if (list.Buses.Length == 0) + { + try + { + var resp = await client.BusCreateAsync(null); + busId = resp.BusID; + createdBus = true; + Console.WriteLine($"Created bus {busId}"); + } + catch (Exception ex) + { + Console.WriteLine($"BusCreate failed: {ex}"); + return; + } + } + else + { + busId = list.Buses.Min(); + Console.WriteLine($"Using existing bus {busId}"); + } +} + +Device deviceInfo; +ViiperDevice device; +try +{ + // 2) Add device and connect + deviceInfo = await client.BusDeviceAddAsync(busId, new DeviceCreateRequest { Type = "keyboard" }); + device = await client.ConnectDeviceAsync(deviceInfo.BusID, deviceInfo.DevID); + Console.WriteLine($"Created and connected to device {deviceInfo.DevID} on bus {deviceInfo.BusID}"); +} +catch (Exception ex) +{ + if (createdBus) + { + try { await client.BusRemoveAsync(busId); } catch { } + } + Console.WriteLine($"AddDevice/connect error: {ex}"); + return; +} + +// Cleanup on exit +AppDomain.CurrentDomain.ProcessExit += async (_, __) => await Cleanup(); +Console.CancelKeyPress += async (_, e) => { e.Cancel = true; await Cleanup(); Environment.Exit(0); }; + +async Task Cleanup() +{ + try { await client.BusDeviceRemoveAsync(deviceInfo.BusID, deviceInfo.DevID); Console.WriteLine($"Removed device {deviceInfo.DevID}"); } catch { } + if (createdBus) + { + try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } + } +} + +// Subscribe to LED output using callback with stream +device.OnOutput = async stream => +{ + var buf = new byte[Keyboard.OutputSize]; + await stream.ReadAsync(buf, 0, buf.Length); + byte leds = buf[0]; + Console.WriteLine($"→ LEDs: Num={(leds & (byte)LED.NumLock) != 0} Caps={(leds & (byte)LED.CapsLock) != 0} Scroll={(leds & (byte)LED.ScrollLock) != 0} Compose={(leds & (byte)LED.Compose) != 0} Kana={(leds & (byte)LED.Kana) != 0}"); +}; + +// Handle disconnect +device.OnDisconnect = () => Console.WriteLine("!!! Server disconnected"); + +Console.WriteLine("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop."); +var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + +while (await timer.WaitForNextTickAsync()) +{ + await TypeString(device, "Hello!"); + await PressAndRelease(device, Key.Enter); + Console.WriteLine("→ Typed: Hello!"); +} + +static async Task TypeString(ViiperDevice dev, string text) +{ + foreach (var c in text) + { + var (mods, key) = MapChar(c); + if (key != 0) + { + // Press + await Send(dev, mods, new byte[] { (byte)key }); + await Task.Delay(100); + // Release + await Send(dev, 0, Array.Empty()); + await Task.Delay(100); + } + } +} + +static async Task PressAndRelease(ViiperDevice dev, Key key) +{ + await Task.Delay(100); + await Send(dev, 0, new byte[] { (byte)key }); + await Task.Delay(100); + await Send(dev, 0, Array.Empty()); +} + +static async Task Send(ViiperDevice dev, byte modifiers, byte[] keys) +{ + var input = new KeyboardInput + { + Modifiers = modifiers, + Count = (byte)keys.Length, + Keys = keys + }; + await dev.SendAsync(input); +} + +static (byte mods, Key key) MapChar(char ch) +{ + // Use the generated CharToKey map + if (!CharToKey.TryGetValue((byte)ch, out var key)) + { + return (0, 0); // Character not found + } + + // Check if shift is needed using the ShiftChars map + byte mods = ShiftChars.ContainsKey((byte)ch) ? (byte)Mod.LeftShift : (byte)0; + + return (mods, key); +} diff --git a/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj b/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj new file mode 100644 index 00000000..dbc51a0a --- /dev/null +++ b/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs new file mode 100644 index 00000000..2ac075f3 --- /dev/null +++ b/examples/csharp/virtual_mouse/Program.cs @@ -0,0 +1,92 @@ +using Viiper.Client; +using Viiper.Client.Devices.Mouse; +using Viiper.Client.Types; + +if (args.Length < 1) +{ + Console.WriteLine("Usage: dotnet run -- [port]"); + Console.WriteLine("Example: dotnet run -- localhost 3242"); + return; +} +var host = args[0]; +var port = args.Length > 1 && int.TryParse(args[1], out var p) ? p : 3242; +var client = new ViiperClient(host, port); + +// Find or create a bus +uint busId; +bool createdBus = false; +{ + var list = await client.BusListAsync(); + if (list.Buses.Length == 0) + { + try + { + var r = await client.BusCreateAsync(null); + busId = r.BusID; + createdBus = true; + Console.WriteLine($"Created bus {busId}"); + } + catch (Exception ex) + { + Console.WriteLine($"BusCreate failed: {ex}"); + return; + } + } + else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } +} + +// Add device and connect +Device resp; ViiperDevice device; +try +{ + resp = await client.BusDeviceAddAsync(busId, new Viiper.Client.Types.DeviceCreateRequest { Type = "mouse" }); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); +} +catch (Exception ex) +{ + if (createdBus) { try { await client.BusRemoveAsync(busId); } catch { } } + Console.WriteLine($"AddDevice/connect error: {ex}"); + return; +} + +AppDomain.CurrentDomain.ProcessExit += async (_, __) => await Cleanup(); +Console.CancelKeyPress += async (_, e) => { e.Cancel = true; await Cleanup(); Environment.Exit(0); }; + +async Task Cleanup() +{ + try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevID); Console.WriteLine($"Removed device {resp.DevID}"); } catch { } + if (createdBus) { try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } } +} + +// Handle disconnect +device.OnDisconnect = () => Console.WriteLine("!!! Server disconnected"); + +// Send movement/click/scroll every 3s +var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); +short dir = 1; const short step = 50; +Console.WriteLine("Every 3s: move diagonally by 50px, click left, scroll +1. Ctrl+C to stop."); +while (await timer.WaitForNextTickAsync()) +{ + var dx = (short)(step * dir); + var dy = (short)(step * dir); + dir = (sbyte)-dir; + + await device.SendAsync(new MouseInput { Dx = dx, Dy = dy, Buttons = 0, Wheel = 0, Pan = 0 }); + Console.WriteLine($"→ Moved mouse dx={dx} dy={dy}"); + + await Task.Delay(30); + await device.SendAsync(new MouseInput { Buttons = 0, Dx = 0, Dy = 0, Wheel = 0, Pan = 0 }); // zero state + + await Task.Delay(50); + await device.SendAsync(new MouseInput { Buttons = (byte)Btn.Left, Dx = 0, Dy = 0, Wheel = 0, Pan = 0 }); + await Task.Delay(60); + await device.SendAsync(new MouseInput { Buttons = 0, Dx = 0, Dy = 0, Wheel = 0, Pan = 0 }); + Console.WriteLine("→ Clicked (left)"); + + await Task.Delay(50); + await device.SendAsync(new MouseInput { Buttons = 0, Dx = 0, Dy = 0, Wheel = 1, Pan = 0 }); + await Task.Delay(30); + await device.SendAsync(new MouseInput { Buttons = 0, Dx = 0, Dy = 0, Wheel = 0, Pan = 0 }); + Console.WriteLine("→ Scrolled (wheel=+1)"); +} diff --git a/examples/csharp/virtual_mouse/VirtualMouse.csproj b/examples/csharp/virtual_mouse/VirtualMouse.csproj new file mode 100644 index 00000000..dbc51a0a --- /dev/null +++ b/examples/csharp/virtual_mouse/VirtualMouse.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs new file mode 100644 index 00000000..2887b7ef --- /dev/null +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -0,0 +1,95 @@ +using Viiper.Client; +using Viiper.Client.Devices.Xbox360; +using Viiper.Client.Types; + +if (args.Length < 1) +{ + Console.WriteLine("Usage: dotnet run -- [port]"); + Console.WriteLine("Example: dotnet run -- localhost 3242"); + return; +} +var host = args[0]; +var port = args.Length > 1 && int.TryParse(args[1], out var p) ? p : 3242; +var client = new ViiperClient(host, port); + +// Find or create a bus +uint busId; +bool createdBus = false; +{ + var list = await client.BusListAsync(); + if (list.Buses.Length == 0) + { + try + { + var r = await client.BusCreateAsync(null); + busId = r.BusID; + createdBus = true; + Console.WriteLine($"Created bus {busId}"); + } + catch (Exception ex) + { + Console.WriteLine($"BusCreate failed: {ex}"); + return; + } + } + else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } +} + +// Add device and connect +Device resp; ViiperDevice device; +try +{ + resp = await client.BusDeviceAddAsync(busId, new DeviceCreateRequest { Type = "xbox360" }); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); +} +catch (Exception ex) +{ + if (createdBus) { try { await client.BusRemoveAsync(busId); } catch { } } + Console.WriteLine($"AddDevice/connect error: {ex}"); + return; +} + +AppDomain.CurrentDomain.ProcessExit += async (_, __) => await Cleanup(); +Console.CancelKeyPress += async (_, e) => { e.Cancel = true; await Cleanup(); Environment.Exit(0); }; + +async Task Cleanup() +{ + try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevID); Console.WriteLine($"Removed device {resp.DevID}"); } catch { } + if (createdBus) { try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } } +} + +// Read rumble output using callback with stream +device.OnOutput = async stream => +{ + var buf = new byte[Xbox360.OutputSize]; + await stream.ReadAsync(buf, 0, buf.Length); + byte left = buf[0]; + byte right = buf[1]; + Console.WriteLine($"← Rumble: Left={left}, Right={right}"); +}; + +// Handle disconnect +device.OnDisconnect = () => Console.WriteLine("!!! Server disconnected"); + +// Send inputs at ~60 FPS +var sw = new PeriodicTimer(TimeSpan.FromMilliseconds(16)); +ulong frame = 0; +while (await sw.WaitForNextTickAsync()) +{ + frame++; + uint buttons = (uint)(((frame / 60) % 4) switch { 0 => (ulong)Button.A, 1 => (ulong)Button.B, 2 => (ulong)Button.X, _ => (ulong)Button.Y }); + var state = new Xbox360Input + { + Buttons = buttons, + Lt = (byte)((frame * 2) % 256), + Rt = (byte)((frame * 3) % 256), + Lx = (short)20000, + Ly = (short)20000, + Rx = 0, + Ry = 0, + }; + await device.SendAsync(state); + if (frame % 60 == 0) + Console.WriteLine($"→ Sent input (frame {frame}): buttons=0x{buttons:X4}, LT={state.Lt}, RT={state.Rt}"); +} diff --git a/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj b/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj new file mode 100644 index 00000000..dbc51a0a --- /dev/null +++ b/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/examples/go/go.mod b/examples/go/go.mod deleted file mode 100644 index 3e6430c5..00000000 --- a/examples/go/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module viiper_examples - -go 1.25 - -require viiper v0.0.0 - -replace viiper => ../../viiper diff --git a/examples/go/virtual_ds4/main.go b/examples/go/virtual_ds4/main.go new file mode 100644 index 00000000..9e4b8c97 --- /dev/null +++ b/examples/go/virtual_ds4/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "math" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/viiperclient" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: virtual_ds4 ") + fmt.Println("Example: virtual_ds4 localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := viiperclient.New(addr) + + // Find or create a bus + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + // Add device and connect to stream in one call + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "dualshock4", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Created and connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) + + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } else { + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) + } + if createdBus { + if _, err := api.BusRemoveCtx(ctx, busID); err != nil { + fmt.Printf("BusRemove error: %v\n", err) + } else { + fmt.Printf("Removed bus %d\n", busID) + } + } + }() + + feedbackCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [7]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + msg := new(dualshock4.OutputState) + if err := msg.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return msg, nil + }) + + go func() { + for { + select { + case feedback := <-feedbackCh: + f := feedback.(*dualshock4.OutputState) + fmt.Printf("[Output] Rumble: S=%d L=%d, LED: R=%d G=%d B=%d, Flash: On=%d Off=%d\n", + f.RumbleSmall, f.RumbleLarge, f.LedRed, f.LedGreen, f.LedBlue, f.FlashOn, f.FlashOff) + case err := <-errCh: + fmt.Printf("[Output read error] %v\n", err) + return + } + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + fmt.Println("DualShock 4 device active. Press Ctrl+C to exit.") + fmt.Println("Demo: Slowly moving left stick in a circle...") + + angle := 0.0 + for { + select { + case <-ticker.C: + angle += 0.05 + if angle > 6.28 { + angle = 0 + } + + lx := int8(0x40 * math.Cos(angle)) + ly := int8(0x40 * math.Sin(angle)) + + state := dualshock4.InputState{ + LX: lx, + LY: ly, + RX: 0, + RY: 0, + Buttons: 0, + DPad: 0, + L2: 0, + R2: 0, + Touch1X: 0, + Touch1Y: 0, + Touch1Active: false, + Touch2X: 0, + Touch2Y: 0, + Touch2Active: false, + GyroX: 0, + GyroY: 0, + GyroZ: 0, + AccelX: 0, + AccelY: 0, + AccelZ: 0, + } + + if err := stream.WriteBinary(&state); err != nil { + fmt.Printf("Send error: %v\n", err) + return + } + + case <-sigCh: + fmt.Println("\nShutting down...") + return + } + } +} diff --git a/examples/go/virtual_ds4_cli/main.go b/examples/go/virtual_ds4_cli/main.go new file mode 100644 index 00000000..42ad99c9 --- /dev/null +++ b/examples/go/virtual_ds4_cli/main.go @@ -0,0 +1,681 @@ +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/viiperclient" +) + +// Usage: +// +// virtual_ds4_cli +// +// Example: +// +// virtual_ds4_cli localhost:3242 +// +// Commands (case-insensitive): +// +// LX=-100 +// R2=82 +// GyroX=12 +// Circle=true +// Circle=false +// Triangle=true 12ms # pulse for 12ms +// DPadUp=true +// DPadLeft=false +// print +// reset +// help +// quit +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: virtual_ds4_cli ") + fmt.Println("Example: virtual_ds4_cli localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + api := viiperclient.New(addr) + + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "dualshock4", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Connected to DualShock 4 device %s on bus %d\n", addResp.DevID, addResp.BusID) + + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + }() + + feedbackCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [7]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + msg := new(dualshock4.OutputState) + if err := msg.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return msg, nil + }) + + go func() { + for { + select { + case feedback := <-feedbackCh: + f := feedback.(*dualshock4.OutputState) + fmt.Printf("[Output] Rumble: S=%d L=%d, LED: R=%d G=%d B=%d, Flash: On=%d Off=%d\n", + f.RumbleSmall, f.RumbleLarge, f.LedRed, f.LedGreen, f.LedBlue, f.FlashOn, f.FlashOff) + case err := <-errCh: + if err != nil { + fmt.Printf("[Output read error] %v\n", err) + } + return + case <-ctx.Done(): + return + } + } + }() + + type stateBox struct { + mu sync.Mutex + state dualshock4.InputState + timers map[string]*time.Timer + } + + box := &stateBox{ + state: dualshock4.InputState{ + LX: 0, LY: 0, RX: 0, RY: 0, + Buttons: 0, + DPad: 0, + L2: 0, + R2: 0, + GyroX: 0, + GyroY: 0, + GyroZ: 0, + AccelX: 0, + AccelY: 0, + AccelZ: 0, + }, + timers: map[string]*time.Timer{}, + } + + sendTicker := time.NewTicker(5 * time.Millisecond) + defer sendTicker.Stop() + + go func() { + for { + select { + case <-sendTicker.C: + box.mu.Lock() + st := box.state + box.mu.Unlock() + if err := stream.WriteBinary(&st); err != nil { + fmt.Printf("Send error: %v\n", err) + cancel() + return + } + case <-ctx.Done(): + return + } + } + }() + + fmt.Println("DS4 CLI ready. Type 'help' for commands. Ctrl+C to exit.") + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("> ") + select { + case <-sigCh: + fmt.Println("\nShutting down...") + cancel() + return + default: + } + + if !scanner.Scan() { + cancel() + return + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + lower := strings.ToLower(line) + switch lower { + case "quit", "exit": + cancel() + return + case "help", "?": + printHelp() + continue + case "print": + box.mu.Lock() + fmt.Printf("%+v\n", box.state) + box.mu.Unlock() + continue + case "reset": + box.mu.Lock() + box.state = dualshock4.InputState{} + box.mu.Unlock() + fmt.Println("state reset") + continue + } + + key, val, dur, ok, err := parseAssignment(line) + if err != nil { + fmt.Printf("parse error: %v\n", err) + continue + } + if !ok { + fmt.Println("unrecognized command; try 'help'") + continue + } + + box.mu.Lock() + before := box.state + applyErr := applyKeyValue(&box.state, key, val) + if applyErr != nil { + box.mu.Unlock() + fmt.Printf("apply error: %v\n", applyErr) + continue + } + + if dur > 0 { + id := strings.ToLower(key) + if t := box.timers[id]; t != nil { + t.Stop() + } + after := box.state + box.timers[id] = time.AfterFunc(dur, func() { + box.mu.Lock() + revertKey(&box.state, id, before, after) + box.mu.Unlock() + }) + } + box.mu.Unlock() + } +} + +func printHelp() { + fmt.Println("Assignments: Key=Value [duration]") + fmt.Println(" Example: LX=-100") + fmt.Println(" Example: Triangle=true 12ms") + fmt.Println("Keys (case-insensitive):") + fmt.Println(" Sticks: LX, LY, RX, RY (int8, -128..127)") + fmt.Println(" Triggers: L2, R2 (uint8, 0..255)") + fmt.Println(" Sensors: GyroX, GyroY, GyroZ (int16)") + fmt.Println(" AccelX, AccelY, AccelZ (int16)") + fmt.Println(" Buttons (bool): Square, Cross, Circle, Triangle") + fmt.Println(" L1, R1, Share, Options, L3, R3") + fmt.Println(" PS (aka PlayStation/Guide), Touchpad") + fmt.Println(" Touchpad:") + fmt.Printf(" Touch1X, Touch1Y, Touch1Active (u16, u16, bool; X=%d..%d, Y=%d..%d)\n", + dualshock4.TouchpadMinX, dualshock4.TouchpadMaxX, + dualshock4.TouchpadMinY, dualshock4.TouchpadMaxY) + fmt.Printf(" Touch2X, Touch2Y, Touch2Active (u16, u16, bool; X=%d..%d, Y=%d..%d)\n", + dualshock4.TouchpadMinX, dualshock4.TouchpadMaxX, + dualshock4.TouchpadMinY, dualshock4.TouchpadMaxY) + fmt.Println(" Touch1=123,456 (sets Touch1X/Touch1Y + Touch1Active=true)") + fmt.Println(" Touch2=123,456 (sets Touch2X/Touch2Y + Touch2Active=true)") + fmt.Println(" DPad (bool): DPadUp, DPadDown, DPadLeft, DPadRight") + fmt.Println("Other commands: print | reset | help | quit") + fmt.Println("NOTE: This is a temporary hacky tool; it only supports what the current wire protocol exposes.") +} + +func parseAssignment(line string) (key string, val string, dur time.Duration, ok bool, err error) { + parts := strings.Fields(line) + if len(parts) == 0 { + return "", "", 0, false, nil + } + kv := parts[0] + eq := strings.IndexByte(kv, '=') + if eq < 0 { + return "", "", 0, false, nil + } + key = strings.TrimSpace(kv[:eq]) + val = strings.TrimSpace(kv[eq+1:]) + if key == "" { + return "", "", 0, false, fmt.Errorf("missing key") + } + if len(parts) >= 2 { + d, e := time.ParseDuration(parts[1]) + if e != nil { + return "", "", 0, false, fmt.Errorf("bad duration %q", parts[1]) + } + dur = d + } + return key, val, dur, true, nil +} + +func applyKeyValue(st *dualshock4.InputState, key string, val string) error { + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.ToLower(strings.TrimSpace(val)) + + parseI8 := func() (int8, error) { + i, err := strconv.ParseInt(val, 10, 8) + return int8(i), err + } + parseU8 := func() (uint8, error) { + u, err := strconv.ParseUint(val, 10, 8) + return uint8(u), err + } + parseI16 := func() (int16, error) { + i, err := strconv.ParseInt(val, 10, 16) + return int16(i), err + } + parseU16 := func() (uint16, error) { + u, err := strconv.ParseUint(val, 10, 16) + return uint16(u), err + } + parseXY := func() (uint16, uint16, error) { + parts := strings.Split(val, ",") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("expected x,y got %q", val) + } + xs := strings.TrimSpace(parts[0]) + ys := strings.TrimSpace(parts[1]) + xu, err := strconv.ParseUint(xs, 10, 16) + if err != nil { + return 0, 0, fmt.Errorf("bad x %q: %w", xs, err) + } + yu, err := strconv.ParseUint(ys, 10, 16) + if err != nil { + return 0, 0, fmt.Errorf("bad y %q: %w", ys, err) + } + return uint16(xu), uint16(yu), nil + } + parseBool := func() (bool, error) { + switch v { + case "1", "true", "t", "yes", "y", "on": + return true, nil + case "0", "false", "f", "no", "n", "off": + return false, nil + default: + return false, fmt.Errorf("expected bool, got %q", val) + } + } + + setButton := func(mask uint16, on bool) { + if on { + st.Buttons |= mask + } else { + st.Buttons &^= mask + } + } + setDPad := func(mask uint8, on bool) { + if on { + st.DPad |= mask + } else { + st.DPad &^= mask + } + } + clampTouchX := func(x uint16) uint16 { + if x < dualshock4.TouchpadMinX { + return dualshock4.TouchpadMinX + } + if x > dualshock4.TouchpadMaxX { + return dualshock4.TouchpadMaxX + } + return x + } + clampTouchY := func(y uint16) uint16 { + if y < dualshock4.TouchpadMinY { + return dualshock4.TouchpadMinY + } + if y > dualshock4.TouchpadMaxY { + return dualshock4.TouchpadMaxY + } + return y + } + + switch k { + case "lx": + x, err := parseI8() + if err != nil { + return err + } + st.LX = x + case "ly": + x, err := parseI8() + if err != nil { + return err + } + st.LY = x + case "rx": + x, err := parseI8() + if err != nil { + return err + } + st.RX = x + case "ry": + x, err := parseI8() + if err != nil { + return err + } + st.RY = x + + case "l2": + x, err := parseU8() + if err != nil { + return err + } + st.L2 = x + case "r2": + x, err := parseU8() + if err != nil { + return err + } + st.R2 = x + + case "gyrox": + x, err := parseI16() + if err != nil { + return err + } + st.GyroX = x + case "gyroy": + x, err := parseI16() + if err != nil { + return err + } + st.GyroY = x + case "gyroz": + x, err := parseI16() + if err != nil { + return err + } + st.GyroZ = x + + case "accelx": + x, err := parseI16() + if err != nil { + return err + } + st.AccelX = x + case "accely": + x, err := parseI16() + if err != nil { + return err + } + st.AccelY = x + case "accelz": + x, err := parseI16() + if err != nil { + return err + } + st.AccelZ = x + + case "touch1x": + x, err := parseU16() + if err != nil { + return err + } + st.Touch1X = clampTouchX(x) + case "touch1y": + y, err := parseU16() + if err != nil { + return err + } + st.Touch1Y = clampTouchY(y) + case "touch1active", "touch1down": + on, err := parseBool() + if err != nil { + return err + } + st.Touch1Active = on + case "touch2x": + x, err := parseU16() + if err != nil { + return err + } + st.Touch2X = clampTouchX(x) + case "touch2y": + y, err := parseU16() + if err != nil { + return err + } + st.Touch2Y = clampTouchY(y) + case "touch2active", "touch2down": + on, err := parseBool() + if err != nil { + return err + } + st.Touch2Active = on + case "touch1": + if v == "false" || v == "off" || v == "0" { + st.Touch1Active = false + return nil + } + x, y, err := parseXY() + if err != nil { + return err + } + st.Touch1X, st.Touch1Y = clampTouchX(x), clampTouchY(y) + st.Touch1Active = true + case "touch2": + if v == "false" || v == "off" || v == "0" { + st.Touch2Active = false + return nil + } + x, y, err := parseXY() + if err != nil { + return err + } + st.Touch2X, st.Touch2Y = clampTouchX(x), clampTouchY(y) + st.Touch2Active = true + + case "square": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualshock4.ButtonSquare), on) + case "cross", "x": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualshock4.ButtonCross), on) + case "circle": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualshock4.ButtonCircle), on) + case "triangle": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualshock4.ButtonTriangle), on) + + case "l1": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonL1, on) + case "r1": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonR1, on) + case "share": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonShare, on) + case "options": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonOptions, on) + case "l3": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonL3, on) + case "r3": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonR3, on) + case "ps", "playstation", "guide": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonPS, on) + case "touchpad", "touchpadbutton": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualshock4.ButtonTouchpadClick, on) + + case "dpadup": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualshock4.DPadUp, on) + case "dpaddown": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualshock4.DPadDown, on) + case "dpadleft": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualshock4.DPadLeft, on) + case "dpadright": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualshock4.DPadRight, on) + + default: + return fmt.Errorf("unknown key %q", key) + } + return nil +} + +func revertKey(st *dualshock4.InputState, key string, before dualshock4.InputState, after dualshock4.InputState) { + switch key { + case "lx": + st.LX = before.LX + case "ly": + st.LY = before.LY + case "rx": + st.RX = before.RX + case "ry": + st.RY = before.RY + case "l2": + st.L2 = before.L2 + case "r2": + st.R2 = before.R2 + case "gyrox": + st.GyroX = before.GyroX + case "gyroy": + st.GyroY = before.GyroY + case "gyroz": + st.GyroZ = before.GyroZ + case "accelx": + st.AccelX = before.AccelX + case "accely": + st.AccelY = before.AccelY + case "accelz": + st.AccelZ = before.AccelZ + case "touch1x": + st.Touch1X = before.Touch1X + case "touch1y": + st.Touch1Y = before.Touch1Y + case "touch1active", "touch1down", "touch1": + st.Touch1Active = before.Touch1Active + st.Touch1X = before.Touch1X + st.Touch1Y = before.Touch1Y + case "touch2x": + st.Touch2X = before.Touch2X + case "touch2y": + st.Touch2Y = before.Touch2Y + case "touch2active", "touch2down", "touch2": + st.Touch2Active = before.Touch2Active + st.Touch2X = before.Touch2X + st.Touch2Y = before.Touch2Y + case "square", "cross", "x", "circle", "triangle", "l1", "r1", "share", "options", "l3", "r3", "ps", "playstation", "guide", "touchpad", "touchpadbutton": + st.Buttons = before.Buttons + case "dpadup", "dpaddown", "dpadleft", "dpadright": + st.DPad = before.DPad + default: + _ = after + } +} diff --git a/examples/go/virtual_ds_and_edge_cli/main.go b/examples/go/virtual_ds_and_edge_cli/main.go new file mode 100644 index 00000000..4d52e985 --- /dev/null +++ b/examples/go/virtual_ds_and_edge_cli/main.go @@ -0,0 +1,720 @@ +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/viiperclient" +) + +// Usage: +// +// virtual_ds4_cli +// +// Example: +// +// virtual_ds4_cli localhost:3242 edge +// +// Commands (case-insensitive): +// +// LX=-100 +// R2=82 +// GyroX=12 +// Circle=true +// Circle=false +// Triangle=true 12ms # pulse for 12ms +// DPadUp=true +// DPadLeft=false +// print +// reset +// help +// quit +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: virtual_dsedge_cli ") + fmt.Println("Example: virtual_dsedge_cli localhost:3242 edge") + os.Exit(1) + } + + addr := os.Args[1] + edge := len(os.Args) > 2 && strings.ToLower(os.Args[2]) == "edge" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + api := viiperclient.New(addr) + + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + deviceType := "dualsense" + if edge { + deviceType = "dualsenseedge" + } + + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, deviceType, nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Connected to %s device %s on bus %d\n", deviceType, addResp.DevID, addResp.BusID) + + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + }() + + feedbackCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [dualsense.OutputStateSize]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + msg := new(dualsense.OutputState) + if err := msg.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return msg, nil + }) + + go func() { + for { + select { + case feedback := <-feedbackCh: + f := feedback.(*dualsense.OutputState) + fmt.Printf("[Output] Rumble: S=%d L=%d, LED: R=%d G=%d B=%d, Player LEDs: %d\n", + f.RumbleSmall, f.RumbleLarge, f.LedRed, f.LedGreen, f.LedBlue, f.PlayerLeds) + case err := <-errCh: + if err != nil { + fmt.Printf("[Output read error] %v\n", err) + } + return + case <-ctx.Done(): + return + } + } + }() + + type stateBox struct { + mu sync.Mutex + state dualsense.InputState + timers map[string]*time.Timer + } + + box := &stateBox{ + state: dualsense.InputState{ + LX: 0, LY: 0, RX: 0, RY: 0, + Buttons: 0, + DPad: 0, + L2: 0, + R2: 0, + GyroX: 0, + GyroY: 0, + GyroZ: 0, + AccelX: 0, + AccelY: 0, + AccelZ: 0, + }, + timers: map[string]*time.Timer{}, + } + + sendTicker := time.NewTicker(5 * time.Millisecond) + defer sendTicker.Stop() + + go func() { + for { + select { + case <-sendTicker.C: + box.mu.Lock() + st := box.state + box.mu.Unlock() + if err := stream.WriteBinary(&st); err != nil { + fmt.Printf("Send error: %v\n", err) + cancel() + return + } + case <-ctx.Done(): + return + } + } + }() + + fmt.Printf("%s CLI ready. Type 'help' for commands. Ctrl+C to exit.\n", deviceType) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("> ") + select { + case <-sigCh: + fmt.Println("\nShutting down...") + cancel() + return + default: + } + + if !scanner.Scan() { + cancel() + return + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + lower := strings.ToLower(line) + switch lower { + case "quit", "exit": + cancel() + return + case "help", "?": + printHelp() + continue + case "print": + box.mu.Lock() + fmt.Printf("%+v\n", box.state) + box.mu.Unlock() + continue + case "reset": + box.mu.Lock() + box.state = dualsense.InputState{} + box.mu.Unlock() + fmt.Println("state reset") + continue + } + + key, val, dur, ok, err := parseAssignment(line) + if err != nil { + fmt.Printf("parse error: %v\n", err) + continue + } + if !ok { + fmt.Println("unrecognized command; try 'help'") + continue + } + + box.mu.Lock() + before := box.state + applyErr := applyKeyValue(&box.state, key, val) + if applyErr != nil { + box.mu.Unlock() + fmt.Printf("apply error: %v\n", applyErr) + continue + } + + if dur > 0 { + id := strings.ToLower(key) + if t := box.timers[id]; t != nil { + t.Stop() + } + after := box.state + box.timers[id] = time.AfterFunc(dur, func() { + box.mu.Lock() + revertKey(&box.state, id, before, after) + box.mu.Unlock() + }) + } + box.mu.Unlock() + } +} + +func printHelp() { + fmt.Println("Assignments: Key=Value [duration]") + fmt.Println(" Example: LX=-100") + fmt.Println(" Example: Triangle=true 12ms") + fmt.Println("Keys (case-insensitive):") + fmt.Println(" Sticks: LX, LY, RX, RY (int8, -128..127)") + fmt.Println(" Triggers: L2, R2 (uint8, 0..255)") + fmt.Println(" Sensors: GyroX, GyroY, GyroZ (int16)") + fmt.Println(" AccelX, AccelY, AccelZ (int16)") + fmt.Println(" Buttons (bool): Square, Cross, Circle, Triangle") + fmt.Println(" L1, R1, Share, Options, L3, R3") + fmt.Println(" PS (aka PlayStation/Guide), Touchpad") + fmt.Println(" L4, R4, LFn, RFn, MicMute") + fmt.Println(" Touchpad:") + fmt.Printf(" Touch1X, Touch1Y, Touch1Active (u16, u16, bool; X=%d..%d, Y=%d..%d)\n", + dualsense.TouchpadMinX, dualsense.TouchpadMaxX, + dualsense.TouchpadMinY, dualsense.TouchpadMaxY) + fmt.Printf(" Touch2X, Touch2Y, Touch2Active (u16, u16, bool; X=%d..%d, Y=%d..%d)\n", + dualsense.TouchpadMinX, dualsense.TouchpadMaxX, + dualsense.TouchpadMinY, dualsense.TouchpadMaxY) + fmt.Println(" Touch1=123,456 (sets Touch1X/Touch1Y + Touch1Active=true)") + fmt.Println(" Touch2=123,456 (sets Touch2X/Touch2Y + Touch2Active=true)") + fmt.Println(" DPad (bool): DPadUp, DPadDown, DPadLeft, DPadRight") + fmt.Println("Other commands: print | reset | help | quit") + fmt.Println("NOTE: This is a temporary hacky tool; it only supports what the current wire protocol exposes.") +} + +func parseAssignment(line string) (key string, val string, dur time.Duration, ok bool, err error) { + parts := strings.Fields(line) + if len(parts) == 0 { + return "", "", 0, false, nil + } + kv := parts[0] + before, after, ok0 := strings.Cut(kv, "=") + if !ok0 { + return "", "", 0, false, nil + } + key = strings.TrimSpace(before) + val = strings.TrimSpace(after) + if key == "" { + return "", "", 0, false, fmt.Errorf("missing key") + } + if len(parts) >= 2 { + d, e := time.ParseDuration(parts[1]) + if e != nil { + return "", "", 0, false, fmt.Errorf("bad duration %q", parts[1]) + } + dur = d + } + return key, val, dur, true, nil +} + +func applyKeyValue(st *dualsense.InputState, key string, val string) error { + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.ToLower(strings.TrimSpace(val)) + + parseI8 := func() (int8, error) { + i, err := strconv.ParseInt(val, 10, 8) + return int8(i), err + } + parseU8 := func() (uint8, error) { + u, err := strconv.ParseUint(val, 10, 8) + return uint8(u), err + } + parseI16 := func() (int16, error) { + i, err := strconv.ParseInt(val, 10, 16) + return int16(i), err + } + parseU16 := func() (uint16, error) { + u, err := strconv.ParseUint(val, 10, 16) + return uint16(u), err + } + parseXY := func() (uint16, uint16, error) { + parts := strings.Split(val, ",") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("expected x,y got %q", val) + } + xs := strings.TrimSpace(parts[0]) + ys := strings.TrimSpace(parts[1]) + xu, err := strconv.ParseUint(xs, 10, 16) + if err != nil { + return 0, 0, fmt.Errorf("bad x %q: %w", xs, err) + } + yu, err := strconv.ParseUint(ys, 10, 16) + if err != nil { + return 0, 0, fmt.Errorf("bad y %q: %w", ys, err) + } + return uint16(xu), uint16(yu), nil + } + parseBool := func() (bool, error) { + switch v { + case "1", "true", "t", "yes", "y", "on": + return true, nil + case "0", "false", "f", "no", "n", "off": + return false, nil + default: + return false, fmt.Errorf("expected bool, got %q", val) + } + } + + setButton := func(mask uint32, on bool) { + if on { + st.Buttons |= mask + } else { + st.Buttons &^= mask + } + } + setDPad := func(mask uint8, on bool) { + if on { + st.DPad |= mask + } else { + st.DPad &^= mask + } + } + clampTouchX := func(x uint16) uint16 { + if x < dualsense.TouchpadMinX { + return dualsense.TouchpadMinX + } + if x > dualsense.TouchpadMaxX { + return dualsense.TouchpadMaxX + } + return x + } + clampTouchY := func(y uint16) uint16 { + if y < dualsense.TouchpadMinY { + return dualsense.TouchpadMinY + } + if y > dualsense.TouchpadMaxY { + return dualsense.TouchpadMaxY + } + return y + } + + switch k { + case "lx": + x, err := parseI8() + if err != nil { + return err + } + st.LX = x + case "ly": + x, err := parseI8() + if err != nil { + return err + } + st.LY = x + case "rx": + x, err := parseI8() + if err != nil { + return err + } + st.RX = x + case "ry": + x, err := parseI8() + if err != nil { + return err + } + st.RY = x + + case "l2": + x, err := parseU8() + if err != nil { + return err + } + st.L2 = x + case "r2": + x, err := parseU8() + if err != nil { + return err + } + st.R2 = x + + case "gyrox": + x, err := parseI16() + if err != nil { + return err + } + st.GyroX = x + case "gyroy": + x, err := parseI16() + if err != nil { + return err + } + st.GyroY = x + case "gyroz": + x, err := parseI16() + if err != nil { + return err + } + st.GyroZ = x + + case "accelx": + x, err := parseI16() + if err != nil { + return err + } + st.AccelX = x + case "accely": + x, err := parseI16() + if err != nil { + return err + } + st.AccelY = x + case "accelz": + x, err := parseI16() + if err != nil { + return err + } + st.AccelZ = x + + case "touch1x": + x, err := parseU16() + if err != nil { + return err + } + st.Touch1X = clampTouchX(x) + case "touch1y": + y, err := parseU16() + if err != nil { + return err + } + st.Touch1Y = clampTouchY(y) + case "touch1active", "touch1down": + on, err := parseBool() + if err != nil { + return err + } + st.Touch1Active = on + case "touch2x": + x, err := parseU16() + if err != nil { + return err + } + st.Touch2X = clampTouchX(x) + case "touch2y": + y, err := parseU16() + if err != nil { + return err + } + st.Touch2Y = clampTouchY(y) + case "touch2active", "touch2down": + on, err := parseBool() + if err != nil { + return err + } + st.Touch2Active = on + case "touch1": + if v == "false" || v == "off" || v == "0" { + st.Touch1Active = false + return nil + } + x, y, err := parseXY() + if err != nil { + return err + } + st.Touch1X, st.Touch1Y = clampTouchX(x), clampTouchY(y) + st.Touch1Active = true + case "touch2": + if v == "false" || v == "off" || v == "0" { + st.Touch2Active = false + return nil + } + x, y, err := parseXY() + if err != nil { + return err + } + st.Touch2X, st.Touch2Y = clampTouchX(x), clampTouchY(y) + st.Touch2Active = true + + case "square": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualsense.ButtonSquare), on) + case "cross", "x": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualsense.ButtonCross), on) + case "circle": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualsense.ButtonCircle), on) + case "triangle": + on, err := parseBool() + if err != nil { + return err + } + setButton((dualsense.ButtonTriangle), on) + + case "l1": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonL1, on) + case "r1": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonR1, on) + case "share": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonCreate, on) + case "options": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonOptions, on) + case "l3": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonL3, on) + case "r3": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonR3, on) + case "ps", "playstation", "guide": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonPS, on) + case "touchpad", "touchpadbutton": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonTouchpad, on) + + case "dpadup": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualsense.DPadUp, on) + case "dpaddown": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualsense.DPadDown, on) + case "dpadleft": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualsense.DPadLeft, on) + case "dpadright": + on, err := parseBool() + if err != nil { + return err + } + setDPad(dualsense.DPadRight, on) + + case "r4": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonEdgeR4, on) + case "rfn": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonEdgeRFn, on) + case "l4": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonEdgeL4, on) + case "lfn": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonEdgeLFn, on) + case "micmute": + on, err := parseBool() + if err != nil { + return err + } + setButton(dualsense.ButtonMicMute, on) + + default: + return fmt.Errorf("unknown key %q", key) + } + return nil +} + +func revertKey(st *dualsense.InputState, key string, before dualsense.InputState, after dualsense.InputState) { + switch key { + case "lx": + st.LX = before.LX + case "ly": + st.LY = before.LY + case "rx": + st.RX = before.RX + case "ry": + st.RY = before.RY + case "l2": + st.L2 = before.L2 + case "r2": + st.R2 = before.R2 + case "gyrox": + st.GyroX = before.GyroX + case "gyroy": + st.GyroY = before.GyroY + case "gyroz": + st.GyroZ = before.GyroZ + case "accelx": + st.AccelX = before.AccelX + case "accely": + st.AccelY = before.AccelY + case "accelz": + st.AccelZ = before.AccelZ + case "touch1x": + st.Touch1X = before.Touch1X + case "touch1y": + st.Touch1Y = before.Touch1Y + case "touch1active", "touch1down", "touch1": + st.Touch1Active = before.Touch1Active + st.Touch1X = before.Touch1X + st.Touch1Y = before.Touch1Y + case "touch2x": + st.Touch2X = before.Touch2X + case "touch2y": + st.Touch2Y = before.Touch2Y + case "touch2active", "touch2down", "touch2": + st.Touch2Active = before.Touch2Active + st.Touch2X = before.Touch2X + st.Touch2Y = before.Touch2Y + case "square", "cross", "x", "circle", "triangle", "l1", "r1", "share", "options", "l3", "r3", "ps", "playstation", "guide", "touchpad", "touchpadbutton", "r4", "rfn", "l4", "lfn", "micmute": + st.Buttons = before.Buttons + case "dpadup", "dpaddown", "dpadleft", "dpadright": + st.DPad = before.DPad + default: + _ = after + } +} diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 06cbe752..1399e3b1 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -1,166 +1,158 @@ -package main - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "os" - "os/signal" - "syscall" - "time" - - "viiper/pkg/apiclient" - "viiper/pkg/device/keyboard" -) - -// Minimal example: create a keyboard device, type "Hello!" + Enter every 5 seconds, monitor LEDs. -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: virtual_keyboard ") - fmt.Println("Example: virtual_keyboard localhost:3242") - os.Exit(1) - } - - addr := os.Args[1] - ctx := context.Background() - api := apiclient.New(addr) - - // Find or create a bus - busesResp, err := api.BusListCtx(ctx) - if err != nil { - fmt.Printf("BusList error: %v\n", err) - os.Exit(1) - } - var busID uint32 - createdBus := false - if len(busesResp.Buses) == 0 { - var createErr error - for try := uint32(1); try <= 100; try++ { - if r, err := api.BusCreateCtx(ctx, try); err == nil { - busID = r.BusID - createdBus = true - break - } - createErr = err - } - if busID == 0 { - fmt.Printf("BusCreate failed: %v\n", createErr) - os.Exit(1) - } - fmt.Printf("Created bus %d\n", busID) - } else { - busID = busesResp.Buses[0] - for _, b := range busesResp.Buses[1:] { - if b < busID { - busID = b - } - } - fmt.Printf("Using existing bus %d\n", busID) - } - - // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "keyboard") - if err != nil { - fmt.Printf("AddDeviceAndConnect error: %v\n", err) - if createdBus { - _, _ = api.BusRemoveCtx(ctx, busID) - } - os.Exit(1) - } - defer stream.Close() - - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) - - // Cleanup on exit - defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { - fmt.Printf("DeviceRemove error: %v\n", err) - } else { - fmt.Printf("Removed device %s\n", deviceBusId) - } - if createdBus { - if _, err := api.BusRemoveCtx(ctx, busID); err != nil { - fmt.Printf("BusRemove error: %v\n", err) - } else { - fmt.Printf("Removed bus %d\n", busID) - } - } - }() - - // Start reading LED feedback (1 byte per LED state change) using StartReading - ledCh, ledErrCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { - var b [1]byte - if _, err := io.ReadFull(r, b[:]); err != nil { - return nil, err - } - st := new(keyboard.LEDState) - if err := st.UnmarshalBinary(b[:]); err != nil { - return nil, err - } - return st, nil - }) - - go func() { - for { - select { - case m := <-ledCh: - if m == nil { - continue - } - lm := m.(*keyboard.LEDState) - fmt.Printf("→ LEDs: Num=%v Caps=%v Scroll=%v Compose=%v Kana=%v\n", - lm.NumLock, lm.CapsLock, lm.ScrollLock, lm.Compose, lm.Kana) - case err := <-ledErrCh: - if err != nil { - fmt.Printf("LED read error: %v\n", err) - } - return - } - } - }() - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - - fmt.Println("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.") - for { - select { - case <-ticker.C: - // Type "Hello!" character by character - states := keyboard.TypeString("Hello!") - for _, state := range states { - if err := stream.WriteBinary(&state); err != nil { - fmt.Printf("Write error: %v\n", err) - return - } - time.Sleep(100 * time.Millisecond) - } - - // Press and release Enter - time.Sleep(100 * time.Millisecond) - enterPress := keyboard.PressKey(keyboard.KeyEnter) - if err := stream.WriteBinary(&enterPress); err != nil { - fmt.Printf("Write error (enter): %v\n", err) - return - } - - time.Sleep(100 * time.Millisecond) - enterRelease := keyboard.Release() - if err := stream.WriteBinary(&enterRelease); err != nil { - fmt.Printf("Write error (release): %v\n", err) - return - } - - fmt.Println("→ Typed: Hello!") - case <-sigCh: - fmt.Println("Signal received, stopping…") - return - } - } -} +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/viiperclient" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: virtual_keyboard ") + fmt.Println("Example: virtual_keyboard localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := viiperclient.New(addr) + + // Find or create a bus + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + // Add device and connect to stream in one call + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "keyboard", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevID, addResp.BusID) + + // Cleanup on exit + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } else { + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) + } + if createdBus { + if _, err := api.BusRemoveCtx(ctx, busID); err != nil { + fmt.Printf("BusRemove error: %v\n", err) + } else { + fmt.Printf("Removed bus %d\n", busID) + } + } + }() + + // Start reading LED feedback (1 byte per LED state change) using StartReading + ledCh, ledErrCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [1]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + st := new(keyboard.LEDState) + if err := st.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return st, nil + }) + + go func() { + for { + select { + case m := <-ledCh: + if m == nil { + continue + } + lm := m.(*keyboard.LEDState) + fmt.Printf("→ LEDs: Num=%v Caps=%v Scroll=%v Compose=%v Kana=%v\n", + lm.NumLock, lm.CapsLock, lm.ScrollLock, lm.Compose, lm.Kana) + case err := <-ledErrCh: + if err != nil { + fmt.Printf("LED read error: %v\n", err) + } + return + } + } + }() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + fmt.Println("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.") + for { + select { + case <-ticker.C: + // Type "Hello!" character by character + states := keyboard.TypeString("Hello!") + for _, state := range states { + if err := stream.WriteBinary(&state); err != nil { + fmt.Printf("Write error: %v\n", err) + return + } + time.Sleep(100 * time.Millisecond) + } + + // Press and release Enter + time.Sleep(100 * time.Millisecond) + enterPress := keyboard.PressKey(keyboard.KeyEnter) + if err := stream.WriteBinary(&enterPress); err != nil { + fmt.Printf("Write error (enter): %v\n", err) + return + } + + time.Sleep(100 * time.Millisecond) + enterRelease := keyboard.Release() + if err := stream.WriteBinary(&enterRelease); err != nil { + fmt.Printf("Write error (release): %v\n", err) + return + } + + fmt.Println("→ Typed: Hello!") + case <-sigCh: + fmt.Println("Signal received, stopping…") + return + } + } +} diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 2fd27854..05d308ee 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -1,160 +1,152 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "viiper/pkg/apiclient" - "viiper/pkg/device/mouse" -) - -// Minimal example: ensure a bus, create a mouse device, stream inputs, clean up on exit. -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: virtual_mouse ") - fmt.Println("Example: virtual_mouse localhost:3242") - os.Exit(1) - } - - addr := os.Args[1] - ctx := context.Background() - api := apiclient.New(addr) - - // Find or create a bus - busesResp, err := api.BusListCtx(ctx) - if err != nil { - fmt.Printf("BusList error: %v\n", err) - os.Exit(1) - } - var busID uint32 - createdBus := false - if len(busesResp.Buses) == 0 { - var createErr error - for try := uint32(1); try <= 100; try++ { - if r, err := api.BusCreateCtx(ctx, try); err == nil { - busID = r.BusID - createdBus = true - break - } - createErr = err - } - if busID == 0 { - fmt.Printf("BusCreate failed: %v\n", createErr) - os.Exit(1) - } - fmt.Printf("Created bus %d\n", busID) - } else { - busID = busesResp.Buses[0] - for _, b := range busesResp.Buses[1:] { - if b < busID { - busID = b - } - } - fmt.Printf("Using existing bus %d\n", busID) - } - - // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "mouse") - if err != nil { - fmt.Printf("AddDeviceAndConnect error: %v\n", err) - if createdBus { - _, _ = api.BusRemoveCtx(ctx, busID) - } - os.Exit(1) - } - defer stream.Close() - - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) - - // Cleanup on exit - defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { - fmt.Printf("DeviceRemove error: %v\n", err) - } else { - fmt.Printf("Removed device %s\n", deviceBusId) - } - if createdBus { - if _, err := api.BusRemoveCtx(ctx, busID); err != nil { - fmt.Printf("BusRemove error: %v\n", err) - } else { - fmt.Printf("Removed bus %d\n", busID) - } - } - }() - - // Send a short movement once every 3 seconds for easy local testing. - // Followed by a short click and a single scroll notch. - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - - // Alternate direction to keep the pointer near its origin. - dir := int8(1) - const step = int8(50) // move diagonally by 50 px in X and Y - fmt.Println("Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop.") - for { - select { - case <-ticker.C: - // Move diagonally: (+step,+step) then (-step,-step) next tick - dx := step * dir - dy := step * dir - dir *= -1 - - // One-shot movement report (diagonal) - move := &mouse.InputState{DX: dx, DY: dy} - if err := stream.WriteBinary(move); err != nil { - fmt.Printf("Write error (move): %v\n", err) - return - } - fmt.Printf("→ Moved mouse dx=%d dy=%d\n", dx, dy) - - // Zero state shortly after to keep movement one-shot (harmless safety) - time.Sleep(30 * time.Millisecond) - zero := &mouse.InputState{} - if err := stream.WriteBinary(zero); err != nil { - fmt.Printf("Write error (zero after move): %v\n", err) - return - } - - // Simulate a short left click: press then release - time.Sleep(50 * time.Millisecond) - press := &mouse.InputState{Buttons: mouse.Btn_Left} - if err := stream.WriteBinary(press); err != nil { - fmt.Printf("Write error (press): %v\n", err) - return - } - time.Sleep(60 * time.Millisecond) - rel := &mouse.InputState{Buttons: 0x00} - if err := stream.WriteBinary(rel); err != nil { - fmt.Printf("Write error (release): %v\n", err) - return - } - fmt.Printf("→ Clicked (left)\n") - - // Simulate a short scroll: one notch upwards - time.Sleep(50 * time.Millisecond) - scr := &mouse.InputState{Wheel: 1} - if err := stream.WriteBinary(scr); err != nil { - fmt.Printf("Write error (scroll): %v\n", err) - return - } - time.Sleep(30 * time.Millisecond) - scr0 := &mouse.InputState{} - if err := stream.WriteBinary(scr0); err != nil { - fmt.Printf("Write error (zero after scroll): %v\n", err) - return - } - fmt.Printf("→ Scrolled (wheel=+1)\n") - case <-sigCh: - fmt.Println("Signal received, stopping…") - return - } - } -} +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/viiperclient" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: virtual_mouse ") + fmt.Println("Example: virtual_mouse localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := viiperclient.New(addr) + + // Find or create a bus + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + // Add device and connect to stream in one call + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "mouse", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevID, addResp.BusID) + + // Cleanup on exit + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } else { + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) + } + if createdBus { + if _, err := api.BusRemoveCtx(ctx, busID); err != nil { + fmt.Printf("BusRemove error: %v\n", err) + } else { + fmt.Printf("Removed bus %d\n", busID) + } + } + }() + + // Send a short movement once every 3 seconds for easy local testing. + // Followed by a short click and a single scroll notch. + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + // Alternate direction to keep the pointer near its origin. + dir := int16(1) + const step = int16(50) // move diagonally by 50 px in X and Y + fmt.Println("Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop.") + for { + select { + case <-ticker.C: + // Move diagonally: (+step,+step) then (-step,-step) next tick + dx := step * dir + dy := step * dir + dir *= -1 + + // One-shot movement report (diagonal) + move := &mouse.InputState{DX: dx, DY: dy} + if err := stream.WriteBinary(move); err != nil { + fmt.Printf("Write error (move): %v\n", err) + return + } + fmt.Printf("→ Moved mouse dx=%d dy=%d\n", dx, dy) + + // Zero state shortly after to keep movement one-shot (harmless safety) + time.Sleep(30 * time.Millisecond) + zero := &mouse.InputState{} + if err := stream.WriteBinary(zero); err != nil { + fmt.Printf("Write error (zero after move): %v\n", err) + return + } + + // Simulate a short left click: press then release + time.Sleep(50 * time.Millisecond) + press := &mouse.InputState{Buttons: mouse.BtnLeft} + if err := stream.WriteBinary(press); err != nil { + fmt.Printf("Write error (press): %v\n", err) + return + } + time.Sleep(60 * time.Millisecond) + rel := &mouse.InputState{Buttons: 0x00} + if err := stream.WriteBinary(rel); err != nil { + fmt.Printf("Write error (release): %v\n", err) + return + } + fmt.Printf("→ Clicked (left)\n") + + // Simulate a short scroll: one notch upwards + time.Sleep(50 * time.Millisecond) + scr := &mouse.InputState{Wheel: 1} + if err := stream.WriteBinary(scr); err != nil { + fmt.Printf("Write error (scroll): %v\n", err) + return + } + time.Sleep(30 * time.Millisecond) + scr0 := &mouse.InputState{} + if err := stream.WriteBinary(scr0); err != nil { + fmt.Printf("Write error (zero after scroll): %v\n", err) + return + } + fmt.Printf("→ Scrolled (wheel=+1)\n") + case <-sigCh: + fmt.Println("Signal received, stopping…") + return + } + } +} diff --git a/examples/go/virtual_ns2pro/main.go b/examples/go/virtual_ns2pro/main.go new file mode 100644 index 00000000..2b65db9c --- /dev/null +++ b/examples/go/virtual_ns2pro/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "math" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/viiperclient" +) + +func main() { + if len(os.Args) != 2 { + fmt.Println("Usage: virtual_ns2pro ") + fmt.Println("Example: virtual_ns2pro localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := viiperclient.New(addr) + + busID, createdBus, err := findOrCreateBus(ctx, api) + if err != nil { + fmt.Printf("Bus setup error: %v\n", err) + os.Exit(1) + } + + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "ns2pro", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() // nolint + + fmt.Printf("Created and connected to Switch 2 Pro device %s on bus %d\n", addResp.DevID, addResp.BusID) + + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } else { + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) + } + if createdBus { + if _, err := api.BusRemoveCtx(ctx, busID); err != nil { + fmt.Printf("BusRemove error: %v\n", err) + } else { + fmt.Printf("Removed bus %d\n", busID) + } + } + }() + + rumbleCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [ns2pro.OutputWireSize]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + msg := new(ns2pro.OutputState) + if err := msg.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return msg, nil + }) + + go func() { + for { + select { + case msg := <-rumbleCh: + if msg == nil { + continue + } + rumble := msg.(*ns2pro.OutputState) + fmt.Printf("[Output] HD rumble L=% x R=% x\n", rumble.LeftRumble[:6], rumble.RightRumble[:6]) + case err := <-errCh: + if err != nil { + fmt.Printf("[Output read error] %v\n", err) + } + return + } + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(16 * time.Millisecond) + defer ticker.Stop() + + fmt.Println("Switch 2 Pro device active. Press Ctrl+C to exit.") + fmt.Println("Demo: left stick circles, face buttons rotate, gyro/accel drift gently.") + + var frame uint64 + for { + select { + case <-ticker.C: + frame++ + angle := float64(frame) * 0.045 + + state := ns2pro.InputState{ + Buttons: demoButtons(frame), + LX: stickAxis(math.Cos(angle)), + LY: stickAxis(math.Sin(angle)), + RX: ns2pro.StickCenter, + RY: ns2pro.StickCenter, + AccelX: int16(800 * math.Sin(angle*0.5)), + AccelY: int16(800 * math.Cos(angle*0.5)), + AccelZ: -4096, + GyroX: int16(500 * math.Sin(angle)), + GyroY: int16(500 * math.Cos(angle)), + GyroZ: int16(250 * math.Sin(angle*0.33)), + } + + if err := stream.WriteBinary(&state); err != nil { + fmt.Printf("Send error: %v\n", err) + return + } + + if frame%60 == 0 { + fmt.Printf("→ Sent frame %d buttons=0x%06x LX=%04x LY=%04x Gyro=(%d,%d,%d)\n", + frame, state.Buttons, state.LX, state.LY, state.GyroX, state.GyroY, state.GyroZ) + } + + case <-sigCh: + fmt.Println("\nShutting down...") + return + } + } +} + +func findOrCreateBus(ctx context.Context, api *viiperclient.Client) (uint32, bool, error) { + busesResp, err := api.BusListCtx(ctx) + if err != nil { + return 0, false, err + } + + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + return 0, false, err + } + fmt.Printf("Created bus %d\n", r.BusID) + return r.BusID, true, nil + } + + busID := busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + return busID, false, nil +} + +func demoButtons(frame uint64) uint32 { + switch (frame / 60) % 6 { + case 0: + return ns2pro.ButtonA + case 1: + return ns2pro.ButtonB + case 2: + return ns2pro.ButtonX + case 3: + return ns2pro.ButtonY + case 4: + return ns2pro.ButtonL | ns2pro.ButtonR + default: + return ns2pro.ButtonZL | ns2pro.ButtonZR + } +} + +func stickAxis(v float64) uint16 { + const radius = 1400.0 + raw := float64(ns2pro.StickCenter) + radius*v + if raw < float64(ns2pro.StickMin) { + return ns2pro.StickMin + } + if raw > float64(ns2pro.StickMax) { + return ns2pro.StickMax + } + return uint16(raw) +} diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 6a4cd6ce..026d43e6 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -1,167 +1,159 @@ -package main - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "os" - "os/signal" - "syscall" - "time" - - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" -) - -// Minimal example: ensure a bus, create an xbox360 device, stream inputs, read rumble, clean up on exit. -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: xbox360_client ") - fmt.Println("Example: xbox360_client localhost:3242") - os.Exit(1) - } - - addr := os.Args[1] - ctx := context.Background() - api := apiclient.New(addr) - - // Find or create a bus - busesResp, err := api.BusListCtx(ctx) - if err != nil { - fmt.Printf("BusList error: %v\n", err) - os.Exit(1) - } - var busID uint32 - createdBus := false - if len(busesResp.Buses) == 0 { - var createErr error - for try := uint32(1); try <= 100; try++ { - if r, err := api.BusCreateCtx(ctx, try); err == nil { - busID = r.BusID - createdBus = true - break - } - createErr = err - } - if busID == 0 { - fmt.Printf("BusCreate failed: %v\n", createErr) - os.Exit(1) - } - fmt.Printf("Created bus %d\n", busID) - } else { - busID = busesResp.Buses[0] - for _, b := range busesResp.Buses[1:] { - if b < busID { - busID = b - } - } - fmt.Printf("Using existing bus %d\n", busID) - } - - // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "xbox360") - if err != nil { - fmt.Printf("AddDeviceAndConnect error: %v\n", err) - if createdBus { - _, _ = api.BusRemoveCtx(ctx, busID) - } - os.Exit(1) - } - defer stream.Close() - - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) - - // Cleanup on exit - defer func() { - if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { - fmt.Printf("DeviceRemove error: %v\n", err) - } else { - fmt.Printf("Removed device %s\n", deviceBusId) - } - if createdBus { - if _, err := api.BusRemoveCtx(ctx, busID); err != nil { - fmt.Printf("BusRemove error: %v\n", err) - } else { - fmt.Printf("Removed bus %d\n", busID) - } - } - }() - - // Start event-driven rumble reading - rumbleCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { - var b [2]byte - if _, err := io.ReadFull(r, b[:]); err != nil { - return nil, err - } - msg := new(xbox360.XRumbleState) - if err := msg.UnmarshalBinary(b[:]); err != nil { - return nil, err - } - return msg, nil - }) - - go func() { - for { - select { - case msg := <-rumbleCh: - if msg != nil { - rumble := msg.(*xbox360.XRumbleState) - fmt.Printf("← Rumble: Left=%d, Right=%d\n", rumble.LeftMotor, rumble.RightMotor) - } - case err := <-errCh: - if err != nil { - fmt.Printf("Stream read error: %v\n", err) - } - return - } - } - }() - - // Send controller inputs - ticker := time.NewTicker(16 * time.Millisecond) - defer ticker.Stop() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - - var frame uint64 - for { - select { - case <-ticker.C: - frame++ - var buttons uint32 - switch (frame / 60) % 4 { - case 0: - buttons = xbox360.ButtonA - case 1: - buttons = xbox360.ButtonB - case 2: - buttons = xbox360.ButtonX - default: - buttons = xbox360.ButtonY - } - state := &xbox360.InputState{ - Buttons: buttons, - LT: uint8((frame * 2) % 256), - RT: uint8((frame * 3) % 256), - LX: int16(20000.0 * 0.7071), - LY: int16(20000.0 * 0.7071), - RX: 0, - RY: 0, - } - if err := stream.WriteBinary(state); err != nil { - fmt.Printf("Write error: %v\n", err) - return - } - if frame%60 == 0 { - fmt.Printf("→ Sent input (frame %d): buttons=0x%04x, LT=%d, RT=%d\n", frame, state.Buttons, state.LT, state.RT) - } - case <-sigCh: - fmt.Println("Signal received, stopping…") - return - } - } -} +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/viiperclient" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: xbox360_client ") + fmt.Println("Example: xbox360_client localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := viiperclient.New(addr) + + // Find or create a bus + busesResp, err := api.BusListCtx(ctx) + if err != nil { + fmt.Printf("BusList error: %v\n", err) + os.Exit(1) + } + var busID uint32 + createdBus := false + if len(busesResp.Buses) == 0 { + r, err := api.BusCreateCtx(ctx, 0) + if err != nil { + fmt.Printf("BusCreate failed: %v\n", err) + os.Exit(1) + } + busID = r.BusID + createdBus = true + fmt.Printf("Created bus %d\n", busID) + } else { + busID = busesResp.Buses[0] + for _, b := range busesResp.Buses[1:] { + if b < busID { + busID = b + } + } + fmt.Printf("Using existing bus %d\n", busID) + } + + // Add device and connect to stream in one call + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "xbox360", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() //nolint:errcheck + + fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevID, addResp.BusID) + + // Cleanup on exit + defer func() { + if _, err := api.DeviceRemoveCtx(ctx, stream.BusID, stream.DevID); err != nil { + fmt.Printf("DeviceRemove error: %v\n", err) + } else { + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) + } + if createdBus { + if _, err := api.BusRemoveCtx(ctx, busID); err != nil { + fmt.Printf("BusRemove error: %v\n", err) + } else { + fmt.Printf("Removed bus %d\n", busID) + } + } + }() + + // Start event-driven rumble reading + rumbleCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { + var b [2]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return nil, err + } + msg := new(xbox360.XRumbleState) + if err := msg.UnmarshalBinary(b[:]); err != nil { + return nil, err + } + return msg, nil + }) + + go func() { + for { + select { + case msg := <-rumbleCh: + if msg != nil { + rumble := msg.(*xbox360.XRumbleState) + fmt.Printf("← Rumble: Left=%d, Right=%d\n", rumble.LeftMotor, rumble.RightMotor) + } + case err := <-errCh: + if err != nil { + fmt.Printf("Stream read error: %v\n", err) + } + return + } + } + }() + + // Send controller inputs + ticker := time.NewTicker(16 * time.Millisecond) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + var frame uint64 + for { + select { + case <-ticker.C: + frame++ + var buttons uint32 + switch (frame / 60) % 4 { + case 0: + buttons = xbox360.ButtonA + case 1: + buttons = xbox360.ButtonB + case 2: + buttons = xbox360.ButtonX + default: + buttons = xbox360.ButtonY + } + state := &xbox360.InputState{ + Buttons: buttons, + LT: uint8((frame * 2) % 256), + RT: uint8((frame * 3) % 256), + LX: int16(20000.0 * 0.7071), + LY: int16(20000.0 * 0.7071), + RX: 0, + RY: 0, + } + if err := stream.WriteBinary(state); err != nil { + fmt.Printf("Write error: %v\n", err) + return + } + if frame%60 == 0 { + fmt.Printf("→ Sent input (frame %d): buttons=0x%04x, LT=%d, RT=%d\n", frame, state.Buttons, state.LT, state.RT) + } + case <-sigCh: + fmt.Println("Signal received, stopping…") + return + } + } +} diff --git a/examples/libVIIPER/C/ns2pro_cli/CMakeLists.txt b/examples/libVIIPER/C/ns2pro_cli/CMakeLists.txt new file mode 100644 index 00000000..a8d526e9 --- /dev/null +++ b/examples/libVIIPER/C/ns2pro_cli/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.20) +project(ns2pro_cli C) + +set(LIB_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../dist/libVIIPER") + +add_library(libVIIPER SHARED IMPORTED GLOBAL) +set_target_properties(libVIIPER PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${LIB_DIR}") + +if(WIN32) + if(MSVC) + set(IMPLIB "${CMAKE_CURRENT_BINARY_DIR}/libVIIPER.lib") + add_custom_command(OUTPUT "${IMPLIB}" + COMMAND lib.exe /def:"${LIB_DIR}/libVIIPER.def" /out:"${IMPLIB}" /machine:x64 + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + DEPENDS "${LIB_DIR}/libVIIPER.def" "${LIB_DIR}/libVIIPER.dll") + else() + set(IMPLIB "${CMAKE_CURRENT_BINARY_DIR}/libVIIPER.dll.a") + add_custom_command(OUTPUT "${IMPLIB}" + COMMAND dlltool -d "${LIB_DIR}/libVIIPER.def" -l "${IMPLIB}" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + DEPENDS "${LIB_DIR}/libVIIPER.def" "${LIB_DIR}/libVIIPER.dll") + endif() + add_custom_target(libVIIPER_implib DEPENDS "${IMPLIB}") + set_target_properties(libVIIPER PROPERTIES + IMPORTED_LOCATION "${LIB_DIR}/libVIIPER.dll" + IMPORTED_IMPLIB "${IMPLIB}") +else() + set_target_properties(libVIIPER PROPERTIES + IMPORTED_LOCATION "${LIB_DIR}/libVIIPER.so") +endif() + +add_executable(ns2pro_cli main.c) +target_link_libraries(ns2pro_cli PRIVATE libVIIPER) + +if(WIN32) + add_dependencies(ns2pro_cli libVIIPER_implib) + add_custom_command(TARGET ns2pro_cli POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LIB_DIR}/libVIIPER.dll" + "$") +endif() diff --git a/examples/libVIIPER/C/ns2pro_cli/main.c b/examples/libVIIPER/C/ns2pro_cli/main.c new file mode 100644 index 00000000..9d6c368f --- /dev/null +++ b/examples/libVIIPER/C/ns2pro_cli/main.c @@ -0,0 +1,369 @@ +/* + * NS2Pro CLI example using libVIIPER. + * + * Creates a USB server + NS2Pro device and lets you set its state + * interactively via stdin commands. + * + * Usage: + * ns2pro_cli + * + * Commands (case-insensitive): + * A=true / B=false / X=1 / Y=0 ... + * L=true, ZL=true, R=true, ZR=true + * Plus=true, Minus=true, Home=true, Capture=true + * Up=true, Down=true, Left=true, Right=true (D-Pad) + * LS=true, RS=true (stick clicks) + * GL=true, GR=true, C=true, Headset=true + * LX=2048 LY=2048 RX=2048 RY=2048 (0..4095, center=2048) + * AccelX=0 AccelY=0 AccelZ=0 + * GyroX=0 GyroY=0 GyroZ=0 + * print | reset | help | quit + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "libVIIPER.h" + +#ifdef _WIN32 +#include +#define strcasecmp _stricmp +#else +#include +#endif + +/* ── signal ──────────────────────────────────────────────────────────────── */ + +static volatile bool g_running = true; +static void on_signal(int sig) +{ + (void)sig; + g_running = false; +} + +/* ── callbacks ───────────────────────────────────────────────────────────── */ + +static void output_callback(NS2ProDeviceHandle handle, NS2ProOutputState out) +{ + (void)handle; + printf("<- Output: PlayerLED=0x%02X Flags=0x%02X\n", + out.PlayerLedMask, out.Flags); +} + +static void log_callback(VIIPERLogLevel level, const char *msg) +{ + const char *lv = + level == VIIPER_LOG_DEBUG ? "DEBUG" : level == VIIPER_LOG_INFO ? "INFO" + : level == VIIPER_LOG_WARN ? "WARN" + : level == VIIPER_LOG_ERROR ? "ERROR" + : "?"; + printf("[libVIIPER/%s] %s\n", lv, msg); +} + +/* ── helpers ─────────────────────────────────────────────────────────────── */ + +static void str_tolower(char *dst, const char *src, size_t n) +{ + size_t i; + for (i = 0; i < n - 1 && src[i]; i++) + dst[i] = (char)tolower((unsigned char)src[i]); + dst[i] = '\0'; +} + +/* returns 1=true, 0=false, -1=parse error */ +static int parse_bool(const char *v) +{ + if (!v) + return -1; + if (strcmp(v, "1") == 0 || strcasecmp(v, "true") == 0 || + strcasecmp(v, "yes") == 0 || strcasecmp(v, "on") == 0) + return 1; + if (strcmp(v, "0") == 0 || strcasecmp(v, "false") == 0 || + strcasecmp(v, "no") == 0 || strcasecmp(v, "off") == 0) + return 0; + return -1; +} + +static uint16_t clamp_stick(long v) +{ + if (v < (long)NS2PRO_STICK_MIN) + return NS2PRO_STICK_MIN; + if (v > (long)NS2PRO_STICK_MAX) + return NS2PRO_STICK_MAX; + return (uint16_t)v; +} + +static void set_button(NS2ProDeviceState *s, uint32_t mask, bool on) +{ + if (on) + s->Buttons |= mask; + else + s->Buttons &= ~mask; +} + +/* ── command table ───────────────────────────────────────────────────────── */ + +static const struct +{ + const char *name; + uint32_t mask; +} k_buttons[] = { + {"a", NS2PRO_BUTTON_A}, + {"b", NS2PRO_BUTTON_B}, + {"x", NS2PRO_BUTTON_X}, + {"y", NS2PRO_BUTTON_Y}, + {"l", NS2PRO_BUTTON_L}, + {"zl", NS2PRO_BUTTON_ZL}, + {"r", NS2PRO_BUTTON_R}, + {"zr", NS2PRO_BUTTON_ZR}, + {"plus", NS2PRO_BUTTON_PLUS}, + {"minus", NS2PRO_BUTTON_MINUS}, + {"home", NS2PRO_BUTTON_HOME}, + {"capture", NS2PRO_BUTTON_CAPTURE}, + {"up", NS2PRO_BUTTON_UP}, + {"down", NS2PRO_BUTTON_DOWN}, + {"left", NS2PRO_BUTTON_LEFT}, + {"right", NS2PRO_BUTTON_RIGHT}, + {"ls", NS2PRO_BUTTON_LEFT_STICK}, + {"leftstick", NS2PRO_BUTTON_LEFT_STICK}, + {"rs", NS2PRO_BUTTON_RIGHT_STICK}, + {"rightstick", NS2PRO_BUTTON_RIGHT_STICK}, + {"gl", NS2PRO_BUTTON_GL}, + {"gr", NS2PRO_BUTTON_GR}, + {"c", NS2PRO_BUTTON_C}, + {"headset", NS2PRO_BUTTON_HEADSET}, +}; + +/* returns 0 on success, 1 on error */ +static int apply_command(NS2ProDeviceState *s, const char *raw_key, const char *val) +{ + char k[64]; + str_tolower(k, raw_key, sizeof(k)); + + /* buttons */ + for (size_t i = 0; i < sizeof(k_buttons) / sizeof(k_buttons[0]); i++) + { + if (strcmp(k, k_buttons[i].name) == 0) + { + int b = parse_bool(val); + if (b < 0) + { + printf("Expected bool for '%s', got '%s'\n", raw_key, val); + return 1; + } + set_button(s, k_buttons[i].mask, (bool)b); + return 0; + } + } + + /* sticks and IMU */ + char *end; + long lv = strtol(val, &end, 10); + if (*end != '\0') + { + printf("Expected number for '%s', got '%s'\n", raw_key, val); + return 1; + } + + if (strcmp(k, "lx") == 0) + { + s->LX = clamp_stick(lv); + return 0; + } + if (strcmp(k, "ly") == 0) + { + s->LY = clamp_stick(lv); + return 0; + } + if (strcmp(k, "rx") == 0) + { + s->RX = clamp_stick(lv); + return 0; + } + if (strcmp(k, "ry") == 0) + { + s->RY = clamp_stick(lv); + return 0; + } + if (strcmp(k, "accelx") == 0) + { + s->AccelX = (int16_t)lv; + return 0; + } + if (strcmp(k, "accely") == 0) + { + s->AccelY = (int16_t)lv; + return 0; + } + if (strcmp(k, "accelz") == 0) + { + s->AccelZ = (int16_t)lv; + return 0; + } + if (strcmp(k, "gyrox") == 0) + { + s->GyroX = (int16_t)lv; + return 0; + } + if (strcmp(k, "gyroy") == 0) + { + s->GyroY = (int16_t)lv; + return 0; + } + if (strcmp(k, "gyroz") == 0) + { + s->GyroZ = (int16_t)lv; + return 0; + } + + printf("Unknown key: '%s' (try 'help')\n", raw_key); + return 1; +} + +static void print_help(void) +{ + printf("Buttons (bool: true/false/1/0/yes/no/on/off):\n"); + printf(" A, B, X, Y, L, ZL, R, ZR\n"); + printf(" Plus, Minus, Home, Capture\n"); + printf(" Up, Down, Left, Right\n"); + printf(" LS / LeftStick, RS / RightStick\n"); + printf(" GL, GR, C, Headset\n"); + printf("Sticks (0..%u, center=%u):\n", NS2PRO_STICK_MAX, NS2PRO_STICK_CENTER); + printf(" LX, LY, RX, RY\n"); + printf("IMU (int16):\n"); + printf(" AccelX, AccelY, AccelZ\n"); + printf(" GyroX, GyroY, GyroZ\n"); + printf("Other:\n"); + printf(" print show current state\n"); + printf(" reset zero everything, re-center sticks\n"); + printf(" help this message\n"); + printf(" quit exit\n"); +} + +static void print_state(const NS2ProDeviceState *s) +{ + printf("Buttons : 0x%06X\n", s->Buttons); + printf("Sticks : LX=%-5u LY=%-5u RX=%-5u RY=%-5u\n", + s->LX, s->LY, s->RX, s->RY); + printf("Accel : X=%-6d Y=%-6d Z=%-6d\n", s->AccelX, s->AccelY, s->AccelZ); + printf("Gyro : X=%-6d Y=%-6d Z=%-6d\n", s->GyroX, s->GyroY, s->GyroZ); +} + +static NS2ProDeviceState default_state(void) +{ + NS2ProDeviceState s = {0}; + s.LX = s.LY = s.RX = s.RY = NS2PRO_STICK_CENTER; + return s; +} + +/* ── main ────────────────────────────────────────────────────────────────── */ + +int main(void) +{ + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); + + /* create server */ + USBServerConfig conf = {.addr = "localhost:3249"}; + USBServerHandle server = 0; + if (!NewUSBServer(&conf, &server, log_callback)) + { + fprintf(stderr, "Failed to create USB server\n"); + return 1; + } + printf("USB server started on localhost:3249\n"); + + /* create bus */ + uint32_t busID = 0; + if (!CreateUSBBus(server, &busID)) + { + fprintf(stderr, "Failed to create USB bus\n"); + CloseUSBServer(server); + return 1; + } + printf("Created bus %u\n", busID); + + /* create NS2Pro device, auto-attach to local USBIP driver */ + NS2ProDeviceHandle device = 0; + NS2ProMetaState meta = { + .SerialNumber = "OVERRIDE-SN-00" + }; + if (!CreateNS2ProDevice( + server, + &device, + busID, /*autoAttach=*/true, + 0, 0, &meta)) + { + fprintf(stderr, "Failed to create NS2Pro device\n"); + CloseUSBServer(server); + return 1; + } + printf("NS2Pro device created\n\n"); + + SetNS2ProOutputCallback(device, output_callback); + + printf("NS2Pro CLI ready. Type 'help' for commands, Ctrl+C or 'quit' to exit.\n"); + + NS2ProDeviceState state = default_state(); + SetNS2ProDeviceState(device, state); + + char line[256]; + while (g_running) + { + printf("> "); + fflush(stdout); + + if (!fgets(line, sizeof(line), stdin) || !g_running) + break; + + line[strcspn(line, "\r\n")] = '\0'; + if (line[0] == '\0') + continue; + + char low[256]; + str_tolower(low, line, sizeof(low)); + + if (strcmp(low, "quit") == 0 || strcmp(low, "exit") == 0) + break; + if (strcmp(low, "help") == 0 || strcmp(low, "?") == 0) + { + print_help(); + continue; + } + if (strcmp(low, "print") == 0) + { + print_state(&state); + continue; + } + if (strcmp(low, "reset") == 0) + { + state = default_state(); + SetNS2ProDeviceState(device, state); + printf("State reset\n"); + continue; + } + + char *eq = strchr(line, '='); + if (!eq) + { + printf("Unknown command '%s' (try 'help')\n", line); + continue; + } + *eq = '\0'; + const char *key = line; + const char *val = eq + 1; + + if (apply_command(&state, key, val) == 0) + SetNS2ProDeviceState(device, state); + } + + printf("\nShutting down...\n"); + RemoveNS2ProDevice(device); + CloseUSBServer(server); + return 0; +} diff --git a/examples/libVIIPER/C/xbox360/CMakeLists.txt b/examples/libVIIPER/C/xbox360/CMakeLists.txt new file mode 100644 index 00000000..52e6fded --- /dev/null +++ b/examples/libVIIPER/C/xbox360/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.20) +project(libVIIPER_example C) + +set(LIB_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../dist/libVIIPER") + +add_library(libVIIPER SHARED IMPORTED GLOBAL) +set_target_properties(libVIIPER PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${LIB_DIR}") + +if(WIN32) + if(MSVC) + set(IMPLIB "${CMAKE_CURRENT_BINARY_DIR}/libVIIPER.lib") + add_custom_command(OUTPUT "${IMPLIB}" + COMMAND lib.exe /def:"${LIB_DIR}/libVIIPER.def" /out:"${IMPLIB}" /machine:x64 + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" DEPENDS "${LIB_DIR}/libVIIPER.def" "${LIB_DIR}/libVIIPER.dll") + else() + set(IMPLIB "${CMAKE_CURRENT_BINARY_DIR}/libVIIPER.dll.a") + add_custom_command(OUTPUT "${IMPLIB}" + COMMAND dlltool -d "${LIB_DIR}/libVIIPER.def" -l "${IMPLIB}" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" DEPENDS "${LIB_DIR}/libVIIPER.def" "${LIB_DIR}/libVIIPER.dll") + endif() + add_custom_target(libVIIPER_implib DEPENDS "${IMPLIB}") + set_target_properties(libVIIPER PROPERTIES IMPORTED_LOCATION "${LIB_DIR}/libVIIPER.dll" IMPORTED_IMPLIB "${IMPLIB}") +else() + set_target_properties(libVIIPER PROPERTIES IMPORTED_LOCATION "${LIB_DIR}/libVIIPER.so") +endif() + +add_executable(libVIIPER_example main.c) +target_link_libraries(libVIIPER_example PRIVATE libVIIPER) + +if(WIN32) + add_dependencies(libVIIPER_example libVIIPER_implib) + add_custom_command(TARGET libVIIPER_example POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${LIB_DIR}/libVIIPER.dll" "$") +endif() diff --git a/examples/libVIIPER/C/xbox360/main.c b/examples/libVIIPER/C/xbox360/main.c new file mode 100644 index 00000000..fc8abc3e --- /dev/null +++ b/examples/libVIIPER/C/xbox360/main.c @@ -0,0 +1,121 @@ + + +#include +#include +#include +#include +#include +#include "libVIIPER.h" + +static volatile bool g_running = true; +static void signalHandler(int sig) { (void)sig; g_running = false; } + +void rumbleCallback(Xbox360DeviceHandle handle, uint8_t leftMotor, uint8_t rightMotor) { + (void)handle; + printf("<- Rumble: Left=%u, Right=%u\n", leftMotor, rightMotor); +} + + +void logCallback(VIIPERLogLevel level, const char* message) { + const char* levelStr; + switch (level) { + case VIIPER_LOG_DEBUG: + levelStr = "DEBUG"; + break; + case VIIPER_LOG_INFO: + levelStr = "INFO"; + break; + case VIIPER_LOG_WARN: + levelStr = "WARN"; + break; + case VIIPER_LOG_ERROR: + levelStr = "ERROR"; + break; + default: + levelStr = "UNKNOWN"; + } + printf("libVIIPER [%s] %s\n", levelStr, message); +} + +int main() { + printf("Hello, libVIIPER!\n"); + + // Create a usb-server config. + // All fields are optional, default listen address is "0.0.0.0:3241" + USBServerConfig conf = { + .addr = "localhost:3245", + }; + USBServerHandle serverHandle = 0; + // Create a new USB-Server on the specified listening address + // The server will run in a background thread, and can be stopped by calling CloseUSBServer with the returned handle. + // LogCallback can be NULL + bool success = NewUSBServer(&conf, &serverHandle, logCallback); + if (!success) { + printf("Failed to create USB server.\n"); + return 1; + } + printf("Created USB server with handle: %lu\n", (unsigned long)serverHandle); + + + uint32_t busID = 0; + // Create a new USB bus with the specified bus ID, or automatically assign one if busID is 0. + success = CreateUSBBus(serverHandle, &busID); + if (!success) { + printf("Failed to create USB bus.\n"); + return 1; + } + printf("Created USB bus with ID: %u\n", busID); + + + Xbox360DeviceHandle deviceHandle = 0; + success = CreateXbox360Device(serverHandle, &deviceHandle, busID, true, 0,0,0); + if (!success) { + printf("Failed to create Xbox 360 device.\n"); + return 1; + } + + signal(SIGINT, signalHandler); + signal(SIGTERM, signalHandler); + + SetXbox360RumbleCallback(deviceHandle, rumbleCallback); + + _sleep(5000); + + Xbox360DeviceState deviceState = {0}; + uint64_t frame = 0; + + while (g_running) { + frame++; + switch ((frame / 60) % 4) { + case 0: + deviceState.Buttons = XBOX360_BUTTON_A; + break; + case 1: + deviceState.Buttons = XBOX360_BUTTON_B; + break; + case 2: + deviceState.Buttons = XBOX360_BUTTON_X; + break; + default: + deviceState.Buttons = XBOX360_BUTTON_Y; + break; + } + deviceState.LT = (uint8_t)((frame * 2) % 256); + deviceState.RT = (uint8_t)((frame * 3) % 256); + deviceState.LX = (int16_t)(20000.0 * sin(frame * 0.02)); + deviceState.LY = (int16_t)(20000.0 * cos(frame * 0.02)); + + SetXbox360DeviceState(deviceHandle, deviceState); + + if (frame % 60 == 0) { + printf("-> Sent input (frame %llu): buttons=0x%04X, LT=%u, RT=%u\n", + (unsigned long long)frame, deviceState.Buttons, deviceState.LT, deviceState.RT); + } + + _sleep(16); + } + + printf("\nShutting down...\n"); + CloseUSBServer(serverHandle); + return 0; +} \ No newline at end of file diff --git a/examples/libVIIPER/csharp/.gitignore b/examples/libVIIPER/csharp/.gitignore new file mode 100644 index 00000000..c7569574 --- /dev/null +++ b/examples/libVIIPER/csharp/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ \ No newline at end of file diff --git a/examples/libVIIPER/csharp/Program.cs b/examples/libVIIPER/csharp/Program.cs new file mode 100644 index 00000000..98bde94d --- /dev/null +++ b/examples/libVIIPER/csharp/Program.cs @@ -0,0 +1,179 @@ +using System.Runtime.InteropServices; + +class Program +{ + static void RumbleCallback(nuint handle, byte leftMotor, byte rightMotor) + => Console.WriteLine($"<- Rumble: Left={leftMotor}, Right={rightMotor}"); + + static void LogCallback(VIIPERLogLevel level, string message) + { + string levelStr = level switch + { + VIIPERLogLevel.Debug => "DEBUG", + VIIPERLogLevel.Info => "INFO", + VIIPERLogLevel.Warn => "WARN", + VIIPERLogLevel.Error => "ERROR", + _ => "UNKNOWN", + }; + Console.WriteLine($"libVIIPER [{levelStr}] {message}"); + } + + static int Main() + { + Console.WriteLine("Hello, libVIIPER!"); + + bool run = true; + + USBServerConfig conf = new() { + addr = "localhost:3245" + }; + + VIIPERLogCallbackDelegate logCb = LogCallback; + bool success = LibVIIPER.NewUSBServer(ref conf, out nuint serverHandle, logCb); + if (!success) { + Console.WriteLine("Failed to create USB server."); + return 1; + } + Console.WriteLine($"Created USB server with handle: {serverHandle}"); + + uint busID = 0; + success = LibVIIPER.CreateUSBBus(serverHandle, ref busID); + if (!success) { + Console.WriteLine("Failed to create USB bus."); + return 1; + } + Console.WriteLine($"Created USB bus with ID: {busID}"); + + success = LibVIIPER.CreateXbox360Device(serverHandle, out nuint deviceHandle, busID, true, 0, 0, 0); + if (!success) { + Console.WriteLine("Failed to create Xbox 360 device."); + return 1; + } + + Console.CancelKeyPress += (_, e) => { + e.Cancel = true; + run = false; + }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => run = false; + + Xbox360RumbleCallbackDelegate rumbleCb = RumbleCallback; + LibVIIPER.SetXbox360RumbleCallback(deviceHandle, rumbleCb); + + Thread.Sleep(5000); + + Xbox360DeviceState deviceState = new(); + ulong frame = 0; + + while (run) { + frame++; + deviceState.Buttons = (uint)((frame / 60) % 4) switch + { + 0 => Xbox360Buttons.A, + 1 => Xbox360Buttons.B, + 2 => Xbox360Buttons.X, + _ => Xbox360Buttons.Y, + }; + deviceState.LT = (byte)((frame * 2) % 256); + deviceState.RT = (byte)((frame * 3) % 256); + deviceState.LX = (short)(20000.0 * Math.Sin(frame * 0.02)); + deviceState.LY = (short)(20000.0 * Math.Cos(frame * 0.02)); + + LibVIIPER.SetXbox360DeviceState(deviceHandle, deviceState); + + if (frame % 60 == 0) { + Console.WriteLine($"-> Sent input (frame {frame}): buttons=0x{deviceState.Buttons:X4}, LT={deviceState.LT}, RT={deviceState.RT}"); + } + + Thread.Sleep(16); + } + + Console.WriteLine("\nShutting down..."); + LibVIIPER.CloseUSBServer(serverHandle); + return 0; + } +} + +enum VIIPERLogLevel +{ + Debug = -4, + Info = 0, + Warn = 4, + Error = 8, +} + +static class Xbox360Buttons +{ + public const uint DPadUp = 0x0001; + public const uint DPadDown = 0x0002; + public const uint DPadLeft = 0x0004; + public const uint DPadRight = 0x0008; + public const uint Start = 0x0010; + public const uint Back = 0x0020; + public const uint LThumb = 0x0040; + public const uint RThumb = 0x0080; + public const uint LShoulder = 0x0100; + public const uint RShoulder = 0x0200; + public const uint Guide = 0x0400; + public const uint A = 0x1000; + public const uint B = 0x2000; + public const uint X = 0x4000; + public const uint Y = 0x8000; +} + +[StructLayout(LayoutKind.Sequential)] +struct USBServerConfig +{ + [MarshalAs(UnmanagedType.LPStr)] + public string? addr; + public ulong connection_timeout_ms; + public ulong device_handler_connect_timeout_ms; + public uint write_batch_flush_interval_ms; +} + +[StructLayout(LayoutKind.Sequential)] +struct Xbox360DeviceState +{ + public uint Buttons; + public byte LT; + public byte RT; + public short LX; + public short LY; + public short RX; + public short RY; + public byte Reserved0, Reserved1, Reserved2, Reserved3, Reserved4, Reserved5; +} + +[UnmanagedFunctionPointer(CallingConvention.Cdecl)] +delegate void Xbox360RumbleCallbackDelegate(nuint handle, byte leftMotor, byte rightMotor); + +[UnmanagedFunctionPointer(CallingConvention.Cdecl)] +delegate void VIIPERLogCallbackDelegate(VIIPERLogLevel level, [MarshalAs(UnmanagedType.LPStr)] string message); + +static class LibVIIPER +{ + const string Lib = "libVIIPER"; + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool NewUSBServer([In] ref USBServerConfig config, out nuint outHandle, VIIPERLogCallbackDelegate? logCallback); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool CloseUSBServer(nuint handle); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool CreateUSBBus(nuint handle, ref uint busID); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool CreateXbox360Device(nuint serverHandle, out nuint outDeviceHandle, uint busID, [MarshalAs(UnmanagedType.I1)] bool autoAttachLocalhost, ushort idVendor, ushort idProduct, byte xinputSubType); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool SetXbox360DeviceState(nuint deviceHandle, Xbox360DeviceState state); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool SetXbox360RumbleCallback(nuint deviceHandle, Xbox360RumbleCallbackDelegate? callback); +} diff --git a/examples/libVIIPER/csharp/libVIIPER_example.csproj b/examples/libVIIPER/csharp/libVIIPER_example.csproj new file mode 100644 index 00000000..6cf771ee --- /dev/null +++ b/examples/libVIIPER/csharp/libVIIPER_example.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + enable + enable + + + + PreserveNewest + + + + + PreserveNewest + + + diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock new file mode 100644 index 00000000..5122982e --- /dev/null +++ b/examples/rust/Cargo.lock @@ -0,0 +1,723 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "async_virtual_keyboard" +version = "0.1.0" +dependencies = [ + "tokio", + "viiper-client", +] + +[[package]] +name = "async_virtual_mouse" +version = "0.1.0" +dependencies = [ + "tokio", + "viiper-client", +] + +[[package]] +name = "async_virtual_x360_pad" +version = "0.1.0" +dependencies = [ + "tokio", + "viiper-client", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "viiper-client" +version = "0.0.1-dev" +dependencies = [ + "chacha20poly1305", + "hmac", + "lazy_static", + "pbkdf2", + "rand", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-util", +] + +[[package]] +name = "virtual_keyboard" +version = "0.1.0" +dependencies = [ + "tokio", + "viiper-client", +] + +[[package]] +name = "virtual_mouse" +version = "0.1.0" +dependencies = [ + "viiper-client", +] + +[[package]] +name = "virtual_x360_pad" +version = "0.1.0" +dependencies = [ + "viiper-client", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml new file mode 100644 index 00000000..d963d807 --- /dev/null +++ b/examples/rust/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "sync/virtual_keyboard", + "sync/virtual_mouse", + "sync/virtual_x360_pad", + "async/virtual_keyboard", + "async/virtual_mouse", + "async/virtual_x360_pad", +] + +resolver = "2" + +[workspace.dependencies] +viiper-client = { path = "../../clients/rust" } +tokio = { version = "1.0", features = ["full"] } diff --git a/examples/rust/async/virtual_keyboard/Cargo.toml b/examples/rust/async/virtual_keyboard/Cargo.toml new file mode 100644 index 00000000..53aff742 --- /dev/null +++ b/examples/rust/async/virtual_keyboard/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "async_virtual_keyboard" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true, features = ["async"] } +tokio = { workspace = true } diff --git a/examples/rust/async/virtual_keyboard/src/main.rs b/examples/rust/async/virtual_keyboard/src/main.rs new file mode 100644 index 00000000..a930d36a --- /dev/null +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -0,0 +1,188 @@ +use tokio::time::{sleep, Duration}; +use std::net::ToSocketAddrs; +use viiper_client::{AsyncViiperClient, devices::keyboard::*}; + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = AsyncViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list().await { + Ok(resp) if resp.buses.is_empty() => { + match client.bus_create(None).await { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + } + } + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add(bus_id, &viiper_client::types::DeviceCreateRequest { + r#type: Some("keyboard".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }).await { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + // Connect to device stream + let mut stream = match client.connect_device(device_info.bus_id, &device_info.dev_id).await { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + println!("Created and connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + + stream.on_disconnect(|| { + eprintln!("Device disconnected by server"); + std::process::exit(0); + }).expect("Failed to register disconnect callback"); + + stream.on_output(|stream| async move { + use tokio::io::AsyncReadExt; + let mut buf = [0u8; OUTPUT_SIZE]; + let mut guard = stream.lock().await; + guard.read_exact(&mut buf).await?; + drop(guard); + let leds = buf[0]; + let num_lock = (leds & 0x01) != 0; + let caps_lock = (leds & 0x02) != 0; + let scroll_lock = (leds & 0x04) != 0; + let compose = (leds & 0x08) != 0; + let kana = (leds & 0x10) != 0; + println!("← LEDs: Num={} Caps={} Scroll={} Compose={} Kana={}", num_lock, caps_lock, scroll_lock, compose, kana); + Ok(()) + }).expect("Failed to register LED callback"); + + println!("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop."); + + // Type "Hello!" + Enter every 5 seconds + let mut interval = tokio::time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + + if let Err(e) = type_string(&mut stream, "Hello!").await { + eprintln!("Write error: {}", e); + break; + } + + sleep(Duration::from_millis(100)).await; + + if let Err(e) = press_key(&mut stream, KEY_ENTER).await { + eprintln!("Write error: {}", e); + break; + } + + println!("→ Typed: Hello!"); + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } +} + +async fn type_string(stream: &mut viiper_client::AsyncDeviceStream, text: &str) -> Result<(), viiper_client::error::ViiperError> { + for ch in text.chars() { + let code_point = ch as u32; + let key = match CHAR_TO_KEY.get(&(code_point as u8)) { + Some(&k) => k, + None => continue, + }; + + let mut mods = 0; + if SHIFT_CHARS.contains(&(code_point as u8)) { + mods = MOD_LEFT_SHIFT; + } + + // Key down + let down = KeyboardInput { + modifiers: mods, + count: 1, + keys: vec![key], + }; + stream.send(&down).await?; + sleep(Duration::from_millis(100)).await; + + // Key up + let up = KeyboardInput { + modifiers: 0, + count: 0, + keys: vec![], + }; + stream.send(&up).await?; + sleep(Duration::from_millis(100)).await; + } + Ok(()) +} + +async fn press_key(stream: &mut viiper_client::AsyncDeviceStream, key: u8) -> Result<(), viiper_client::error::ViiperError> { + let press = KeyboardInput { + modifiers: 0, + count: 1, + keys: vec![key], + }; + stream.send(&press).await?; + sleep(Duration::from_millis(100)).await; + + let release = KeyboardInput { + modifiers: 0, + count: 0, + keys: vec![], + }; + stream.send(&release).await?; + Ok(()) +} diff --git a/examples/rust/async/virtual_mouse/Cargo.toml b/examples/rust/async/virtual_mouse/Cargo.toml new file mode 100644 index 00000000..4ac7bf48 --- /dev/null +++ b/examples/rust/async/virtual_mouse/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "async_virtual_mouse" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true, features = ["async"] } +tokio = { workspace = true } diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs new file mode 100644 index 00000000..e192c53d --- /dev/null +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -0,0 +1,171 @@ +use tokio::time::{sleep, Duration}; +use std::net::ToSocketAddrs; +use viiper_client::{AsyncViiperClient, devices::mouse::*}; + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = AsyncViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list().await { + Ok(resp) if resp.buses.is_empty() => { + match client.bus_create(None).await { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + } + } + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add(bus_id, &viiper_client::types::DeviceCreateRequest { + r#type: Some("mouse".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }).await { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + // Connect to device stream + let stream = match client.connect_device(device_info.bus_id, &device_info.dev_id).await { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + println!("Created and connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + + println!("Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop."); + + // Send a short movement once every 3 seconds for easy local testing. + // Followed by a short click and a single scroll notch. + let mut dir = 1; + let step = 50; // move diagonally by 50 px in X and Y (now supports up to ±32767) + let mut interval = tokio::time::interval(Duration::from_secs(3)); + + loop { + interval.tick().await; + + // Move diagonally: (+step,+step) then (-step,-step) next tick + let dx = step * dir; + let dy = step * dir; + dir *= -1; + + // One-shot movement report (diagonal) + if let Err(e) = stream.send(&MouseInput { + buttons: 0, + dx, + dy, + wheel: 0, + pan: 0, + }).await { + eprintln!("Write error: {}", e); + break; + } + println!("→ Moved mouse dx={} dy={}", dx, dy); + + // Zero state shortly after to keep movement one-shot (harmless safety) + sleep(Duration::from_millis(30)).await; + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }).await; + + // Simulate a short left click: press then release + sleep(Duration::from_millis(50)).await; + let _ = stream.send(&MouseInput { + buttons: BTN_LEFT, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }).await; + sleep(Duration::from_millis(60)).await; + let _ = stream.send(&MouseInput { + buttons: 0x00, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }).await; + println!("→ Clicked (left)"); + + // Simulate a short scroll: one notch upwards + sleep(Duration::from_millis(50)).await; + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 1, + pan: 0, + }).await; + sleep(Duration::from_millis(30)).await; + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }).await; + println!("→ Scrolled (wheel=+1)"); + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } +} diff --git a/examples/rust/async/virtual_x360_pad/Cargo.toml b/examples/rust/async/virtual_x360_pad/Cargo.toml new file mode 100644 index 00000000..35e7f0ee --- /dev/null +++ b/examples/rust/async/virtual_x360_pad/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "async_virtual_x360_pad" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true, features = ["async"] } +tokio = { workspace = true } diff --git a/examples/rust/async/virtual_x360_pad/src/main.rs b/examples/rust/async/virtual_x360_pad/src/main.rs new file mode 100644 index 00000000..35686402 --- /dev/null +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -0,0 +1,147 @@ +use tokio::time::Duration; +use std::net::ToSocketAddrs; +use viiper_client::{AsyncViiperClient, devices::xbox360::*}; + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = AsyncViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list().await { + Ok(resp) if resp.buses.is_empty() => { + match client.bus_create(None).await { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + } + } + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add(bus_id, &viiper_client::types::DeviceCreateRequest { + r#type: Some("xbox360".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }).await { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + // Connect to device stream + let mut stream = match client.connect_device(device_info.bus_id, &device_info.dev_id).await { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } + std::process::exit(1); + } + }; + + println!("Created and connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + + stream.on_disconnect(|| { + eprintln!("Device disconnected by server"); + std::process::exit(0); + }).expect("Failed to register disconnect callback"); + + stream.on_output(|stream| async move { + use tokio::io::AsyncReadExt; + let mut buf = [0u8; OUTPUT_SIZE]; + let mut guard = stream.lock().await; + guard.read_exact(&mut buf).await?; + drop(guard); + let left = buf[0]; + let right = buf[1]; + println!("← Rumble: Left={}, Right={}", left, right); + Ok(()) + }).expect("Failed to register rumble callback"); + + // Send controller inputs at 60fps (16ms intervals) + let mut frame = 0u64; + let mut interval = tokio::time::interval(Duration::from_millis(16)); + + loop { + interval.tick().await; + frame += 1; + + let buttons = match (frame / 60) % 4 { + 0 => BUTTON_A, + 1 => BUTTON_B, + 2 => BUTTON_X, + _ => BUTTON_Y, + }; + + let state = Xbox360Input { + buttons: buttons as u32, + lt: ((frame * 2) % 256) as u8, + rt: ((frame * 3) % 256) as u8, + lx: (20000.0 * 0.7071) as i16, + ly: (20000.0 * 0.7071) as i16, + rx: 0, + ry: 0, + reserved: [0; 6], + }; + + if let Err(e) = stream.send(&state).await { + eprintln!("Write error: {}", e); + break; + } + + if frame % 60 == 0 { + println!("→ Sent input (frame {}): buttons=0x{:04x}, LT={}, RT={}", + frame, state.buttons, state.lt, state.rt); + } + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + if created_bus { + let _ = client.bus_remove(Some(bus_id)).await; + } +} diff --git a/examples/rust/sync/virtual_keyboard/Cargo.toml b/examples/rust/sync/virtual_keyboard/Cargo.toml new file mode 100644 index 00000000..c6cb5102 --- /dev/null +++ b/examples/rust/sync/virtual_keyboard/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "virtual_keyboard" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true } +tokio = { workspace = true } diff --git a/examples/rust/sync/virtual_keyboard/src/main.rs b/examples/rust/sync/virtual_keyboard/src/main.rs new file mode 100644 index 00000000..2ea5e753 --- /dev/null +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -0,0 +1,200 @@ +use std::net::ToSocketAddrs; +use std::thread; +use std::time::Duration; +use viiper_client::{devices::keyboard::*, ViiperClient}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = ViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list() { + Ok(resp) if resp.buses.is_empty() => match client.bus_create(None) { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + }, + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add( + bus_id, + &viiper_client::types::DeviceCreateRequest { + r#type: Some("keyboard".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }, + ) { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + // Connect to device stream + let mut stream = match client.connect_device(device_info.bus_id, &device_info.dev_id) { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + println!( + "Created and connected to device {} on bus {}", + device_info.dev_id, device_info.bus_id + ); + + stream + .on_disconnect(|| { + eprintln!("Device disconnected by server"); + std::process::exit(0); + }) + .expect("Failed to register disconnect callback"); + + stream + .on_output(|reader| { + let mut buf = [0u8; OUTPUT_SIZE]; + reader.read_exact(&mut buf)?; + let leds = buf[0]; + let num_lock = (leds & 0x01) != 0; + let caps_lock = (leds & 0x02) != 0; + let scroll_lock = (leds & 0x04) != 0; + let compose = (leds & 0x08) != 0; + let kana = (leds & 0x10) != 0; + println!( + "← LEDs: Num={} Caps={} Scroll={} Compose={} Kana={}", + num_lock, caps_lock, scroll_lock, compose, kana + ); + Ok(()) + }) + .expect("Failed to register LED callback"); + + println!("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop."); + + // Type "Hello!" + Enter every 5 seconds + loop { + if let Err(e) = type_string(&mut stream, "Hello!") { + eprintln!("Write error: {}", e); + break; + } + + thread::sleep(Duration::from_millis(100)); + + if let Err(e) = press_key(&mut stream, KEY_ENTER) { + eprintln!("Write error: {}", e); + break; + } + + println!("→ Typed: Hello!"); + thread::sleep(Duration::from_secs(5)); + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } +} + +fn type_string( + stream: &mut viiper_client::DeviceStream, + text: &str, +) -> Result<(), viiper_client::error::ViiperError> { + for ch in text.chars() { + let code_point = ch as u32; + let key = match CHAR_TO_KEY.get(&(code_point as u8)) { + Some(&k) => k, + None => continue, + }; + + let mut mods = 0; + if SHIFT_CHARS.contains(&(code_point as u8)) { + mods = MOD_LEFT_SHIFT; + } + + // Key down + let down = KeyboardInput { + modifiers: mods, + count: 1, + keys: vec![key], + }; + stream.send(&down)?; + thread::sleep(Duration::from_millis(100)); + + // Key up + let up = KeyboardInput { + modifiers: 0, + count: 0, + keys: vec![], + }; + stream.send(&up)?; + thread::sleep(Duration::from_millis(100)); + } + Ok(()) +} + +fn press_key( + stream: &mut viiper_client::DeviceStream, + key: u8, +) -> Result<(), viiper_client::error::ViiperError> { + let press = KeyboardInput { + modifiers: 0, + count: 1, + keys: vec![key], + }; + stream.send(&press)?; + thread::sleep(Duration::from_millis(100)); + + let release = KeyboardInput { + modifiers: 0, + count: 0, + keys: vec![], + }; + stream.send(&release)?; + Ok(()) +} diff --git a/examples/rust/sync/virtual_mouse/Cargo.toml b/examples/rust/sync/virtual_mouse/Cargo.toml new file mode 100644 index 00000000..81c12393 --- /dev/null +++ b/examples/rust/sync/virtual_mouse/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "virtual_mouse" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true } diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs new file mode 100644 index 00000000..9edbebaf --- /dev/null +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -0,0 +1,176 @@ +use std::net::ToSocketAddrs; +use std::thread; +use std::time::Duration; +use viiper_client::{devices::mouse::*, ViiperClient}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = ViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list() { + Ok(resp) if resp.buses.is_empty() => match client.bus_create(None) { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + }, + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add( + bus_id, + &viiper_client::types::DeviceCreateRequest { + r#type: Some("mouse".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }, + ) { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + // Connect to device stream + let mut stream = match client.connect_device(device_info.bus_id, &device_info.dev_id) { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + println!( + "Created and connected to device {} on bus {}", + device_info.dev_id, device_info.bus_id + ); + + println!( + "Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop." + ); + + // Send a short movement once every 3 seconds for easy local testing. + // Followed by a short click and a single scroll notch. + let mut dir = 1; + let step = 50; // move diagonally by 50 px in X and Y (now supports up to ±32767) + + loop { + // Move diagonally: (+step,+step) then (-step,-step) next tick + let dx = step * dir; + let dy = step * dir; + dir *= -1; + + // One-shot movement report (diagonal) + if let Err(e) = stream.send(&MouseInput { + buttons: 0, + dx, + dy, + wheel: 0, + pan: 0, + }) { + eprintln!("Write error: {}", e); + break; + } + println!("→ Moved mouse dx={} dy={}", dx, dy); + + // Zero state shortly after to keep movement one-shot (harmless safety) + thread::sleep(Duration::from_millis(30)); + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }); + + // Simulate a short left click: press then release + thread::sleep(Duration::from_millis(50)); + let _ = stream.send(&MouseInput { + buttons: BTN_LEFT, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }); + thread::sleep(Duration::from_millis(60)); + let _ = stream.send(&MouseInput { + buttons: 0x00, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }); + println!("→ Clicked (left)"); + + // Simulate a short scroll: one notch upwards + thread::sleep(Duration::from_millis(50)); + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 1, + pan: 0, + }); + thread::sleep(Duration::from_millis(30)); + let _ = stream.send(&MouseInput { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0, + pan: 0, + }); + println!("→ Scrolled (wheel=+1)"); + + thread::sleep(Duration::from_secs(3)); + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } +} diff --git a/examples/rust/sync/virtual_x360_pad/Cargo.toml b/examples/rust/sync/virtual_x360_pad/Cargo.toml new file mode 100644 index 00000000..8aef0a40 --- /dev/null +++ b/examples/rust/sync/virtual_x360_pad/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "virtual_x360_pad" +version = "0.1.0" +edition = "2021" + +[dependencies] +viiper-client = { workspace = true } diff --git a/examples/rust/sync/virtual_x360_pad/src/main.rs b/examples/rust/sync/virtual_x360_pad/src/main.rs new file mode 100644 index 00000000..e70688af --- /dev/null +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -0,0 +1,154 @@ +use std::net::ToSocketAddrs; +use std::thread; +use std::time::Duration; +use viiper_client::{devices::xbox360::*, ViiperClient}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} localhost:3242", args[0]); + std::process::exit(1); + } + + let addr_str = &args[1]; + let addr: std::net::SocketAddr = match addr_str.to_socket_addrs() { + Ok(mut iter) => match iter.next() { + Some(a) => a, + None => { + eprintln!("Invalid address '{}': no resolvable addresses", addr_str); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Invalid address '{}': {}", addr_str, e); + std::process::exit(1); + } + }; + + let client = ViiperClient::new(addr); + + // Find or create a bus + let (bus_id, created_bus) = match client.bus_list() { + Ok(resp) if resp.buses.is_empty() => match client.bus_create(None) { + Ok(r) => { + println!("Created bus {}", r.bus_id); + (r.bus_id, true) + } + Err(e) => { + eprintln!("BusCreate failed: {}", e); + std::process::exit(1); + } + }, + Ok(resp) => { + let bus_id = *resp.buses.iter().min().unwrap(); + println!("Using existing bus {}", bus_id); + (bus_id, false) + } + Err(e) => { + eprintln!("BusList error: {}", e); + std::process::exit(1); + } + }; + + // Add device + let device_info = match client.bus_device_add( + bus_id, + &viiper_client::types::DeviceCreateRequest { + r#type: Some("xbox360".to_string()), + id_vendor: None, + id_product: None, + device_specific: None, + }, + ) { + Ok(d) => d, + Err(e) => { + eprintln!("AddDevice error: {}", e); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + // Connect to device stream + let mut stream = match client.connect_device(device_info.bus_id, &device_info.dev_id) { + Ok(s) => s, + Err(e) => { + eprintln!("ConnectDevice error: {}", e); + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } + std::process::exit(1); + } + }; + + println!( + "Created and connected to device {} on bus {}", + device_info.dev_id, device_info.bus_id + ); + + stream + .on_disconnect(|| { + eprintln!("Device disconnected by server"); + std::process::exit(0); + }) + .expect("Failed to register disconnect callback"); + + stream + .on_output(|reader| { + let mut buf = [0u8; OUTPUT_SIZE]; + reader.read_exact(&mut buf)?; + let left = buf[0]; + let right = buf[1]; + println!("← Rumble: Left={}, Right={}", left, right); + Ok(()) + }) + .expect("Failed to register rumble callback"); + + // Send controller inputs at 60fps (16ms intervals) + let mut frame = 0u64; + + loop { + frame += 1; + + let buttons = match (frame / 60) % 4 { + 0 => BUTTON_A, + 1 => BUTTON_B, + 2 => BUTTON_X, + _ => BUTTON_Y, + }; + + let state = Xbox360Input { + buttons: buttons as u32, + lt: ((frame * 2) % 256) as u8, + rt: ((frame * 3) % 256) as u8, + lx: (20000.0 * 0.7071) as i16, + ly: (20000.0 * 0.7071) as i16, + rx: 0, + ry: 0, + reserved: [0; 6], + }; + + if let Err(e) = stream.send(&state) { + eprintln!("Write error: {}", e); + break; + } + + if frame % 60 == 0 { + println!( + "→ Sent input (frame {}): buttons=0x{:04x}, LT={}, RT={}", + frame, state.buttons, state.lt, state.rt + ); + } + + thread::sleep(Duration::from_millis(16)); + } + + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); + if created_bus { + let _ = client.bus_remove(Some(bus_id)); + } +} diff --git a/examples/typescript/.gitignore b/examples/typescript/.gitignore new file mode 100644 index 00000000..8cdcaeab --- /dev/null +++ b/examples/typescript/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/examples/typescript/package-lock.json b/examples/typescript/package-lock.json new file mode 100644 index 00000000..bff0fb97 --- /dev/null +++ b/examples/typescript/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "viiper-examples-typescript", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "viiper-examples-typescript", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "viiperclient": "file:../../clients/typescript" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "../../clients/typescript": { + "name": "viiperclient", + "version": "0.0.1-dev", + "license": "MIT", + "devDependencies": { + "@types/node": "24.10.1", + "rimraf": "6.1.0", + "typescript": "5.9.3" + } + }, + "../../clients/typescript/typescript": { + "name": "viiperclient", + "version": "0.0.1-dev", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@types/node": "24.10.1", + "rimraf": "6.1.0", + "typescript": "5.9.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/viiperclient": { + "resolved": "../../clients/typescript", + "link": true + } + } +} diff --git a/examples/typescript/package.json b/examples/typescript/package.json new file mode 100644 index 00000000..43a32728 --- /dev/null +++ b/examples/typescript/package.json @@ -0,0 +1,21 @@ +{ + "name": "viiper-examples-typescript", + "private": true, + "version": "0.0.0", + "description": "VIIPER TypeScript examples mirroring C# and C", + "license": "MIT", + "type": "commonjs", + "scripts": { + "build": "tsc -p .", + "vk": "node dist/virtual_keyboard.js", + "vm": "node dist/virtual_mouse.js", + "vx": "node dist/virtual_x360_pad.js" + }, + "dependencies": { + "viiperclient": "file:../../clients/typescript" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/typescript/tsconfig.json b/examples/typescript/tsconfig.json new file mode 100644 index 00000000..72a5d2bc --- /dev/null +++ b/examples/typescript/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.ts"] +} diff --git a/examples/typescript/virtual_keyboard.ts b/examples/typescript/virtual_keyboard.ts new file mode 100644 index 00000000..b3452922 --- /dev/null +++ b/examples/typescript/virtual_keyboard.ts @@ -0,0 +1,178 @@ +import { ViiperClient, ViiperDevice, Keyboard, Types } from "viiperclient"; + +const { KeyboardInput, Key, Mod, CharToKeyGet, ShiftCharsHas } = Keyboard; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + if (process.argv.length < 3) { + console.log("Usage: node virtual_keyboard.js "); + console.log("Example: node virtual_keyboard.js localhost:3242"); + process.exit(1); + } + + const addr = process.argv[2]; + const [host, portStr] = addr.split(':'); + const port = portStr ? parseInt(portStr, 10) : 3242; + const client = new ViiperClient(host, port); + + // Find or create a bus + const busesResp = await client.buslist(); + let busID: number; + let createdBus = false; + + if (busesResp.buses.length === 0) { + try { + const r = await client.buscreate(); + busID = r.busId; + createdBus = true; + console.log(`Created bus ${busID}`); + } catch (err) { + console.error(`BusCreate failed: ${err}`); + process.exit(1); + } + } else { + busID = Math.min(...busesResp.buses); + console.log(`Using existing bus ${busID}`); + } + + // Add device and connect to stream in one call + let dev: ViiperDevice; + let deviceDevId: string; + try { + const req: Types.DeviceCreateRequest = { type: "keyboard" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); + dev = device; + deviceDevId = addResp.devId; + console.log(`Created and connected to device ${deviceDevId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(busID).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(busID); + console.log(`Removed bus ${busID}`); + } catch (err) { + console.error(`BusRemove error: ${err}`); + } + } + }; + + // Handle LED outputs (1 byte per LED state change) + dev.on("output", (buf: Buffer) => { + if (buf.length >= 1) { + const leds = buf.readUInt8(0); + const numLock = (leds & 0x01) !== 0; + const capsLock = (leds & 0x02) !== 0; + const scrollLock = (leds & 0x04) !== 0; + const compose = (leds & 0x08) !== 0; + const kana = (leds & 0x10) !== 0; + console.log(`→ LEDs: Num=${numLock} Caps=${capsLock} Scroll=${scrollLock} Compose=${compose} Kana=${kana}`); + } + }); + + // Helper to type a string character by character + const typeString = async (text: string) => { + for (const ch of text) { + const codePoint = ch.codePointAt(0)!; + const key = CharToKeyGet(codePoint); + if (key === undefined) continue; + + let mods = 0; + if (ShiftCharsHas(codePoint)) { + mods |= Mod.LeftShift; + } + + // Key down + const down = new KeyboardInput({ Modifiers: mods, Count: 1, Keys: [key] }); + await dev.send(down); + await sleep(100); + + // Key up + const up = new KeyboardInput({ Modifiers: 0, Count: 0, Keys: [] }); + await dev.send(up); + await sleep(100); + } + }; + + // Helper to press and release a key + const pressKey = async (key: number) => { + const press = new KeyboardInput({ Modifiers: 0, Count: 1, Keys: [key] }); + await dev.send(press); + await sleep(100); + const release = new KeyboardInput({ Modifiers: 0, Count: 0, Keys: [] }); + await dev.send(release); + }; + + dev.on("error", async (err: Error) => { + console.error(`Stream error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + }); + + dev.on("end", async () => { + console.log("Stream ended by server"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + + // Handle signals for graceful shutdown + process.on("SIGINT", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + process.on("SIGTERM", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + + console.log("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop."); + + // Type "Hello!" + Enter every 5 seconds + let running = true; + const interval = setInterval(async () => { + if (!running) return; + + try { + await typeString("Hello!"); + await sleep(100); + await pressKey(Key.Enter); + console.log("→ Typed: Hello!"); + } catch (err) { + console.error(`Write error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + } + }, 5000); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/typescript/virtual_mouse.ts b/examples/typescript/virtual_mouse.ts new file mode 100644 index 00000000..8ae6e7ad --- /dev/null +++ b/examples/typescript/virtual_mouse.ts @@ -0,0 +1,164 @@ +import { ViiperClient, ViiperDevice, Mouse, Types } from "viiperclient"; + +const { MouseInput, Btn } = Mouse; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + if (process.argv.length < 3) { + console.log("Usage: node virtual_mouse.js "); + console.log("Example: node virtual_mouse.js localhost:3242"); + process.exit(1); + } + + const addr = process.argv[2]; + const [host, portStr] = addr.split(':'); + const port = portStr ? parseInt(portStr, 10) : 3242; + const client = new ViiperClient(host, port); + + // Find or create a bus + const busesResp = await client.buslist(); + let busID: number; + let createdBus = false; + + if (busesResp.buses.length === 0) { + try { + const r = await client.buscreate(); + busID = r.busId; + createdBus = true; + console.log(`Created bus ${busID}`); + } catch (err) { + console.error(`BusCreate failed: ${err}`); + process.exit(1); + } + } else { + busID = Math.min(...busesResp.buses); + console.log(`Using existing bus ${busID}`); + } + + // Add device and connect to stream in one call + let dev: ViiperDevice; + let deviceDevId: string; + try { + const req: Types.DeviceCreateRequest = { type: "mouse" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); + dev = device; + deviceDevId = addResp.devId; + console.log(`Created and connected to device ${deviceDevId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(busID).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(busID); + console.log(`Removed bus ${busID}`); + } catch (err) { + console.error(`BusRemove error: ${err}`); + } + } + }; + + // Send a short movement once every 3 seconds for easy local testing. + // Followed by a short click and a single scroll notch. + let dir = 1; + const step = 50; // move diagonally by 50 px in X and Y (now supports up to ±32767) + let running = true; + + console.log("Every 3s: move diagonally by 50px (X and Y), then click and scroll. Press Ctrl+C to stop."); + + const interval = setInterval(async () => { + if (!running) return; + + try { + // Move diagonally: (+step,+step) then (-step,-step) next tick + const dx = step * dir; + const dy = step * dir; + dir *= -1; + + // One-shot movement report (diagonal) + const move = new MouseInput({ Buttons: 0, Dx: dx, Dy: dy, Wheel: 0, Pan: 0 }); + await dev.send(move); + console.log(`→ Moved mouse dx=${dx} dy=${dy}`); + + // Zero state shortly after to keep movement one-shot (harmless safety) + await sleep(30); + const zero = new MouseInput({ Buttons: 0, Dx: 0, Dy: 0, Wheel: 0, Pan: 0 }); + await dev.send(zero); + + // Simulate a short left click: press then release + await sleep(50); + const press = new MouseInput({ Buttons: Btn.Left, Dx: 0, Dy: 0, Wheel: 0, Pan: 0 }); + await dev.send(press); + await sleep(60); + const rel = new MouseInput({ Buttons: 0x00, Dx: 0, Dy: 0, Wheel: 0, Pan: 0 }); + await dev.send(rel); + console.log("→ Clicked (left)"); + + // Simulate a short scroll: one notch upwards + await sleep(50); + const scr = new MouseInput({ Buttons: 0, Dx: 0, Dy: 0, Wheel: 1, Pan: 0 }); + await dev.send(scr); + await sleep(30); + const scr0 = new MouseInput({ Buttons: 0, Dx: 0, Dy: 0, Wheel: 0, Pan: 0 }); + await dev.send(scr0); + console.log("→ Scrolled (wheel=+1)"); + } catch (err) { + console.error(`Write error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + } + }, 3000); + + dev.on("error", async (err: Error) => { + console.error(`Stream error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + }); + + dev.on("end", async () => { + console.log("Stream ended by server"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + + // Handle signals for graceful shutdown + process.on("SIGINT", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + process.on("SIGTERM", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/typescript/virtual_x360_pad.ts b/examples/typescript/virtual_x360_pad.ts new file mode 100644 index 00000000..7e753fae --- /dev/null +++ b/examples/typescript/virtual_x360_pad.ts @@ -0,0 +1,168 @@ +import { ViiperClient, ViiperDevice, Xbox360, Types } from "viiperclient"; + +const { Xbox360Input, Button } = Xbox360; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + if (process.argv.length < 3) { + console.log("Usage: node virtual_x360_pad.js "); + console.log("Example: node virtual_x360_pad.js localhost:3242"); + process.exit(1); + } + + const addr = process.argv[2]; + const [host, portStr] = addr.split(':'); + const port = portStr ? parseInt(portStr, 10) : 3242; + const client = new ViiperClient(host, port); + + // Find or create a bus + const busesResp = await client.buslist(); + let busID: number; + let createdBus = false; + + if (busesResp.buses.length === 0) { + try { + const r = await client.buscreate(); + busID = r.busId; + createdBus = true; + console.log(`Created bus ${busID}`); + } catch (err) { + console.error(`BusCreate failed: ${err}`); + process.exit(1); + } + } else { + busID = Math.min(...busesResp.buses); + console.log(`Using existing bus ${busID}`); + } + + // Add device and connect to stream in one call + let dev: ViiperDevice; + let deviceDevId: string; + try { + const req: Types.DeviceCreateRequest = { type: "xbox360" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); + dev = device; + deviceDevId = addResp.devId; + console.log(`Created and connected to device ${deviceDevId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(busID).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(busID); + console.log(`Removed bus ${busID}`); + } catch (err) { + console.error(`BusRemove error: ${err}`); + } + } + }; + + // Start event-driven rumble reading (2 bytes per rumble state) + dev.on("output", (buf: Buffer) => { + if (buf.length >= 2) { + const leftMotor = buf.readUInt8(0); + const rightMotor = buf.readUInt8(1); + console.log(`← Rumble: Left=${leftMotor}, Right=${rightMotor}`); + } + }); + + dev.on("error", async (err: Error) => { + console.error(`Stream error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + }); + + dev.on("end", async () => { + console.log("Stream ended by server"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + + // Handle signals for graceful shutdown + process.on("SIGINT", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + process.on("SIGTERM", async () => { + console.log("Signal received, stopping…"); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(0); + }); + + // Send controller inputs at 60fps (16ms intervals) + let frame = 0; + let running = true; + const interval = setInterval(async () => { + if (!running) return; + + try { + frame++; + let buttons = 0; + switch (Math.floor((frame / 60) % 4)) { + case 0: + buttons = Button.A; + break; + case 1: + buttons = Button.B; + break; + case 2: + buttons = Button.X; + break; + default: + buttons = Button.Y; + break; + } + + const state = new Xbox360Input({ + Buttons: buttons, + Lt: (frame * 2) % 256, + Rt: (frame * 3) % 256, + Lx: Math.floor(20000.0 * 0.7071), + Ly: Math.floor(20000.0 * 0.7071), + Rx: 0, + Ry: 0, + }); + + await dev.send(state); + + if (frame % 60 === 0) { + console.log(`→ Sent input (frame ${frame}): buttons=0x${state.Buttons.toString(16).padStart(4, "0")}, LT=${state.Lt}, RT=${state.Rt}`); + } + } catch (err) { + console.error(`Write error: ${err}`); + running = false; + clearInterval(interval); + await cleanup(); + process.exit(1); + } + }, 16); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..a83535ea --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/Alia5/VIIPER + +go 1.26.2 + +require ( + fyne.io/systray v1.12.1 + github.com/alecthomas/kong v1.15.0 + github.com/alecthomas/kong-toml v0.4.0 + github.com/alecthomas/kong-yaml v0.2.0 + github.com/ncruces/zenity v0.10.14 + github.com/pelletier/go-toml v1.9.5 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.52.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/akavel/rsrc v0.10.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dchest/jsmin v1.0.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/josephspurrier/goversioninfo v1.5.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect + golang.org/x/image v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..2eb865f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= +fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= +github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong-toml v0.4.0 h1:sSK/HHi2M5jqSXYTxmuxkdZcJ+ip9jhYvwcjDGcaJBQ= +github.com/alecthomas/kong-toml v0.4.0/go.mod h1:hRVV9iGmqYsFqs17jFQgqhkjYIxiklbfy95xJ3nlpKI= +github.com/alecthomas/kong-yaml v0.2.0 h1:iiVVqVttmOsHKawlaW/TljPsjaEv1O4ODx6dloSA58Y= +github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= +github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= +github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= +github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 h1:m1yKMZwDSXkT5o2MKhd6ihdzb2dYb6eElNE04xjOSEY= +github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/.gitignore b/internal/.gitignore new file mode 100644 index 00000000..2ba49046 --- /dev/null +++ b/internal/.gitignore @@ -0,0 +1 @@ +!log \ No newline at end of file diff --git a/viiper/internal/testing/api_test_helpers.go b/internal/_testing/api_test_helpers.go similarity index 53% rename from viiper/internal/testing/api_test_helpers.go rename to internal/_testing/api_test_helpers.go index 1eb4b619..00c21bf5 100644 --- a/viiper/internal/testing/api_test_helpers.go +++ b/internal/_testing/api_test_helpers.go @@ -6,13 +6,14 @@ import ( "fmt" "io" "net" + "regexp" "strings" "testing" "time" - "viiper/internal/log" - "viiper/internal/server/api" - "viiper/internal/server/usb" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" "log/slog" ) @@ -48,59 +49,71 @@ func StartAPIServer(t *testing.T, register func(r *api.Router, s *usb.Server, ap return addr, srv, done } -// ExecCmd dials the API server, sends cmd (newline not required) and returns -// the full preliminary response line (OK/ERR plus payload). The caller should -// inspect the response. Client errors call t.Fatalf. -// ExecCmd executes a raw command against a running API server and returns the full -// response line (including JSON payload if present) without the trailing newline. +// ExecCmd dials the API server, sends cmd and reads the full response. +// The command should not include a trailing newline. Returns the response +// without the trailing newline. func ExecCmd(t *testing.T, addr string, cmd string) string { t.Helper() c, err := net.Dial("tcp", addr) if err != nil { t.Fatalf("dial failed: %v", err) } - defer c.Close() + defer c.Close() //nolint:errcheck + + // Send command with null terminator (\x00) — this matches API server framing + _, _ = fmt.Fprintf(c, "%s\x00", cmd) + + // Read response r := bufio.NewReader(c) - _, _ = fmt.Fprintf(c, "%s\n", cmd) line, err := r.ReadString('\n') - if err != nil { - if err != io.EOF { - t.Fatalf("read failed: %v", err) - } + if err != nil && err != io.EOF { + t.Fatalf("read failed: %v", err) } - if len(line) == 0 { - return "" - } - return line[:len(line)-1] // strip newline; may be empty string + + result := strings.TrimSuffix(line, "\n") + result = strings.TrimSuffix(result, "\r") + return result } -// RunAPICmd executes a command through the ApiServer's connection handler using in-memory pipes. -// It exercises routing and handler invocation without a network listener. -// ExecuteLine routes a single command string (one full line without trailing newline) -// through the provided router, emulating ApiServer.handleConn logic but without network IO. -// Returns the full response line (without trailing newline) as produced by the API contract. -func ExecuteLine(t *testing.T, r *api.Router, line string) string { +// ExecuteLine routes a command string through the provided router, +// emulating ApiServer.handleConn logic but without network IO. +// The data parameter is the full request data (path + optional payload). +// Returns the full response as produced by the API contract. +func ExecuteLine(t *testing.T, r *api.Router, data string) string { t.Helper() - line = strings.TrimSpace(line) - if line == "" { + if data == "" { return jsonError("empty") } - fields := strings.Fields(line) - if len(fields) == 0 { - return jsonError("empty") + + // Split on first whitespace character using regex \s + wsRegex := regexp.MustCompile(`\s`) + loc := wsRegex.FindStringIndex(data) + + var path, payload string + if loc != nil { + path = data[:loc[0]] + payload = data[loc[1]:] + } else { + path = data + payload = "" + } + + if path == "" { + return jsonError("empty path") } - path := strings.ToLower(fields[0]) - args := fields[1:] + + path = strings.ToLower(path) + if h, params := r.Match(path); h != nil { - req := &api.Request{Params: params, Args: args} + req := &api.Request{Params: params, Payload: payload} res := &api.Response{} if err := h(req, res, slog.Default()); err != nil { return jsonError(err.Error()) } if res.JSON == "" { - return "OK" + return "" } - return "OK " + res.JSON + return res.JSON } return jsonError("unknown path") } diff --git a/internal/_testing/mocks.go b/internal/_testing/mocks.go new file mode 100644 index 00000000..0c07a61c --- /dev/null +++ b/internal/_testing/mocks.go @@ -0,0 +1,41 @@ +package testing + +import ( + "testing" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +type mockRegistration struct { + deviceName string + handlerFunc api.StreamHandlerFunc + + createFunc func(o *device.CreateOptions) (usb.Device, error) +} + +func (m *mockRegistration) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + return m.createFunc(o) +} + +func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { + return m.handlerFunc +} + +func (m *mockRegistration) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} + +func CreateMockRegistration( + t *testing.T, + name string, + cf func(o *device.CreateOptions) (usb.Device, error), + h api.StreamHandlerFunc, +) api.DeviceHandler { + return &mockRegistration{ + deviceName: name, + handlerFunc: h, + createFunc: cf, + } +} diff --git a/internal/cmd/codegen.go b/internal/cmd/codegen.go new file mode 100644 index 00000000..4d816335 --- /dev/null +++ b/internal/cmd/codegen.go @@ -0,0 +1,26 @@ +//go:build !release + +package cmd + +import ( + "log/slog" + + "github.com/Alia5/VIIPER/internal/codegen/generator" +) + +type Codegen struct { + Output string `help:"Output directory for generated client libraries (repo-root relative). Default resolves to /clients" default:"./clients" env:"VIIPER_CODEGEN_OUTPUT"` + Lang string `help:"Target language: c, cpp, csharp, rust, typescript, or 'all'" default:"all" enum:"c,cpp,csharp,rust,typescript,all" env:"VIIPER_CODEGEN_LANG"` +} + +// Run is called by Kong when the codegen command is executed. +func (c *Codegen) Run(logger *slog.Logger) error { + logger.Info("Starting VIIPER code generation", "output", c.Output, "lang", c.Lang) + + gen := generator.New(c.Output, logger) + if c.Lang == "all" { + return gen.GenAll() + } + return gen.GenerateLang(c.Lang) + +} diff --git a/viiper/internal/cmd/config.go b/internal/cmd/config.go similarity index 93% rename from viiper/internal/cmd/config.go rename to internal/cmd/config.go index b7fae7f6..43f83729 100644 --- a/viiper/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -1,209 +1,210 @@ -package cmd - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "reflect" - "strconv" - "strings" - - "viiper/internal/configpaths" - - toml "github.com/pelletier/go-toml" - yaml "gopkg.in/yaml.v3" -) - -// ConfigCommand groups config-related subcommands. -type ConfigCommand struct { - Init ConfigInit `cmd:"" help:"Generate a configuration template"` -} - -// ConfigInit scaffolds a configuration file for a specific command. -type ConfigInit struct { - Command string `arg:"" name:"command" help:"Command to generate config for" enum:"server,proxy"` - Format string `help:"Output format" enum:"json,yaml,toml" default:"json"` - Output string `help:"Destination file path (defaults to current directory)"` - Force bool `help:"Overwrite if the file already exists"` -} - -// Run generates a configuration template dynamically via reflection of the command structs and tags. -func (c *ConfigInit) Run() error { - format := normalizeFormat(c.Format) - if format == "" { - return fmt.Errorf("unsupported format: %s", c.Format) - } - - var root map[string]any - switch c.Command { - case "server": - root = buildMapFromStruct(reflect.TypeOf(Server{})) - case "proxy": - root = buildMapFromStruct(reflect.TypeOf(Proxy{})) - default: - return errors.New("unknown command; expected 'server' or 'proxy'") - } - - dest := c.Output - if dest == "" { - ext := "json" - if format == "yaml" { - ext = "yaml" - } else if format == "toml" { - ext = "toml" - } - dest = c.Command + "." + ext - } - - if !c.Force { - if _, err := os.Stat(dest); err == nil { - return errors.New("destination exists; use --force to overwrite") - } - } - if err := configpaths.EnsureDir(dest); err != nil { - return err - } - - var data []byte - var err error - switch format { - case "json": - data, err = json.MarshalIndent(root, "", " ") - case "yaml": - data, err = yaml.Marshal(root) - case "toml": - data, err = toml.Marshal(root) - } - if err != nil { - return err - } - if err := os.WriteFile(dest, data, 0o644); err != nil { - return err - } - return nil -} - -func normalizeFormat(f string) string { - switch strings.ToLower(f) { - case "json": - return "json" - case "yaml", "yml": - return "yaml" - case "toml": - return "toml" - default: - return "" - } -} - -func lowerCamel(s string) string { - if s == "" { - return s - } - // Convert first character to lowercase - r := []rune(s) - r[0] = toLower(r[0]) - return string(r) -} - -func toLower(r rune) rune { - if r >= 'A' && r <= 'Z' { - return r + ('a' - 'A') - } - return r -} - -func buildMapFromStruct(t reflect.Type) map[string]any { - if t.Kind() == reflect.Pointer { - t = t.Elem() - } - out := map[string]any{} - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !f.IsExported() { - continue - } - if f.Tag.Get("kong") == "-" { - continue - } - - if _, ok := f.Tag.Lookup("embed"); ok { - prefix := f.Tag.Get("prefix") - name := strings.TrimSuffix(prefix, ".") - sub := buildMapFromStruct(f.Type) - if name != "" { - out[name] = sub - } else { - for k, v := range sub { - out[k] = v - } - } - continue - } - - key := lowerCamel(f.Name) - def := f.Tag.Get("default") - val := defaultValueForField(f.Type, def) - if val != nil { - out[key] = val - } - } - return out -} - -func defaultValueForField(t reflect.Type, def string) any { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.PkgPath() == "time" && t.Name() == "Duration" { - if def != "" { - return def - } - return "0s" - } - switch t.Kind() { - case reflect.String: - return def // may be empty - case reflect.Bool: - if def == "" { - return false - } - b, err := strconv.ParseBool(def) - if err != nil { - return false - } - return b - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if def == "" { - return 0 - } - n, err := strconv.ParseInt(def, 10, 64) - if err != nil { - return 0 - } - return n - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - if def == "" { - return 0 - } - n, err := strconv.ParseUint(def, 10, 64) - if err != nil { - return 0 - } - return n - case reflect.Float32, reflect.Float64: - if def == "" { - return 0 - } - f, err := strconv.ParseFloat(def, 64) - if err != nil { - return 0 - } - return f - case reflect.Struct: - return buildMapFromStruct(t) - default: - return nil - } -} +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "strconv" + "strings" + + "github.com/Alia5/VIIPER/internal/configpaths" + + toml "github.com/pelletier/go-toml" + yaml "gopkg.in/yaml.v3" +) + +// ConfigCommand groups config-related subcommands. +type ConfigCommand struct { + Init ConfigInit `cmd:"" help:"Generate a configuration template"` +} + +// ConfigInit scaffolds a configuration file for a specific command. +type ConfigInit struct { + Command string `arg:"" name:"command" help:"Command to generate config for" enum:"server,proxy"` + Format string `help:"Output format" enum:"json,yaml,toml" default:"json"` + Output string `help:"Destination file path (defaults to current directory)"` + Force bool `help:"Overwrite if the file already exists"` +} + +// Run generates a configuration template dynamically via reflection of the command structs and tags. +func (c *ConfigInit) Run() error { + format := normalizeFormat(c.Format) + if format == "" { + return fmt.Errorf("unsupported format: %s", c.Format) + } + + var root map[string]any + switch c.Command { + case "server": + root = buildMapFromStruct(reflect.TypeOf(Server{})) + case "proxy": + root = buildMapFromStruct(reflect.TypeOf(Proxy{})) + default: + return errors.New("unknown command; expected 'server' or 'proxy'") + } + + dest := c.Output + if dest == "" { + ext := "json" + switch format { + case "yaml": + ext = "yaml" + case "toml": + ext = "toml" + } + dest = c.Command + "." + ext + } + + if !c.Force { + if _, err := os.Stat(dest); err == nil { + return errors.New("destination exists; use --force to overwrite") + } + } + if err := configpaths.EnsureDir(dest); err != nil { + return err + } + + var data []byte + var err error + switch format { + case "json": + data, err = json.MarshalIndent(root, "", " ") + case "yaml": + data, err = yaml.Marshal(root) + case "toml": + data, err = toml.Marshal(root) + } + if err != nil { + return err + } + if err := os.WriteFile(dest, data, 0o644); err != nil { + return err + } + return nil +} + +func normalizeFormat(f string) string { + switch strings.ToLower(f) { + case "json": + return "json" + case "yaml", "yml": + return "yaml" + case "toml": + return "toml" + default: + return "" + } +} + +func lowerCamel(s string) string { + if s == "" { + return s + } + // Convert first character to lowercase + r := []rune(s) + r[0] = toLower(r[0]) + return string(r) +} + +func toLower(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + ('a' - 'A') + } + return r +} + +func buildMapFromStruct(t reflect.Type) map[string]any { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + out := map[string]any{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + if f.Tag.Get("kong") == "-" { + continue + } + + if _, ok := f.Tag.Lookup("embed"); ok { + prefix := f.Tag.Get("prefix") + name := strings.TrimSuffix(prefix, ".") + sub := buildMapFromStruct(f.Type) + if name != "" { + out[name] = sub + } else { + for k, v := range sub { + out[k] = v + } + } + continue + } + + key := lowerCamel(f.Name) + def := f.Tag.Get("default") + val := defaultValueForField(f.Type, def) + if val != nil { + out[key] = val + } + } + return out +} + +func defaultValueForField(t reflect.Type, def string) any { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.PkgPath() == "time" && t.Name() == "Duration" { + if def != "" { + return def + } + return "0s" + } + switch t.Kind() { + case reflect.String: + return def // may be empty + case reflect.Bool: + if def == "" { + return false + } + b, err := strconv.ParseBool(def) + if err != nil { + return false + } + return b + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if def == "" { + return 0 + } + n, err := strconv.ParseInt(def, 10, 64) + if err != nil { + return 0 + } + return n + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if def == "" { + return 0 + } + n, err := strconv.ParseUint(def, 10, 64) + if err != nil { + return 0 + } + return n + case reflect.Float32, reflect.Float64: + if def == "" { + return 0 + } + f, err := strconv.ParseFloat(def, 64) + if err != nil { + return 0 + } + return f + case reflect.Struct: + return buildMapFromStruct(t) + default: + return nil + } +} diff --git a/internal/cmd/install.go b/internal/cmd/install.go new file mode 100644 index 00000000..31d8c112 --- /dev/null +++ b/internal/cmd/install.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "errors" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// Install sets up VIIPER to run automatically. +type Install struct{} + +// Uninstall removes VIIPER startup configuration. +type Uninstall struct{} + +func (c *Install) Run(logger *slog.Logger) error { + exe, err := os.Executable() + if err != nil { + return err + } + + if strings.Contains(exe, "go-build") { + return errors.New("cannot install from 'go run'") + } + + return install(logger) +} + +func (c *Uninstall) Run(logger *slog.Logger) error { + exe, err := os.Executable() + if err != nil { + return err + } + + if strings.Contains(exe, "go-build") { + return errors.New("cannot uninstall from 'go run'") + } + + return uninstall(logger) +} + +func currentExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + + return filepath.Abs(exe) +} diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go new file mode 100644 index 00000000..c37a5c96 --- /dev/null +++ b/internal/cmd/install_linux.go @@ -0,0 +1,98 @@ +//go:build linux + +package cmd + +import ( + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + serviceName = "viiper.service" + servicePath = "/etc/systemd/system/viiper.service" +) + +func install(logger *slog.Logger) error { + exePath, err := currentExecutable() + if err != nil { + return err + } + + unit := systemdUnitContent(exePath) + if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { + return err + } + + steps := [][]string{ + {"daemon-reload"}, + {"enable", serviceName}, + {"restart", serviceName}, + } + + for _, args := range steps { + if err := runSystemctl(args...); err != nil { + return err + } + } + + logger.Info("VIIPER systemd service installed", "path", servicePath, "exe", exePath) + return nil +} + +func uninstall(logger *slog.Logger) error { + var errs []error + + if err := runSystemctl("stop", serviceName); err != nil { + errs = append(errs, err) + } + if err := runSystemctl("disable", serviceName); err != nil { + errs = append(errs, err) + } + + if err := os.Remove(servicePath); err != nil && !os.IsNotExist(err) { + errs = append(errs, err) + } + + if err := runSystemctl("daemon-reload"); err != nil { + errs = append(errs, err) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + logger.Info("VIIPER systemd service removed", "path", servicePath) + return nil +} + +func systemdUnitContent(exePath string) string { + workingDir := filepath.Dir(exePath) + return fmt.Sprintf(`[Unit] +Description=VIIPER server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%q server +WorkingDirectory=%s +Restart=on-failure + +[Install] +WantedBy=multi-user.target +`, exePath, workingDir) +} + +func runSystemctl(args ...string) error { + cmd := exec.Command("systemctl", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go new file mode 100644 index 00000000..69de36c7 --- /dev/null +++ b/internal/cmd/install_windows.go @@ -0,0 +1,195 @@ +//go:build windows + +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/Alia5/VIIPER/internal/configpaths" + "golang.org/x/sys/windows/registry" +) + +const ( + runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + runValueKey = "VIIPER" +) + +func install(logger *slog.Logger) error { + exePath, err := currentExecutable() + if err != nil { + return err + } + + previousExe, err := currentAutorunExe() + if err != nil { + return err + } + + cfgDir, err := configpaths.DefaultConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve config dir: %w", err) + } + logFile := filepath.Join(cfgDir, "viiper.log") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + return fmt.Errorf("failed to create log directory %s: %w", cfgDir, err) + } + + value := fmt.Sprintf("\"%s\" server --log.file \"%s\"", exePath, logFile) + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) + if err != nil { + return err + } + defer key.Close() //nolint:errcheck + + if err := key.SetStringValue(runValueKey, value); err != nil { + return err + } + + if previousExe != "" { + if err := killProcessesByExe(previousExe, logger); err != nil { + return fmt.Errorf("failed to stop previous autorun instance: %w", err) + } + } + + if err := exec.Command(exePath, "server", "--log.file", logFile).Start(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, "logFile", logFile) + return nil +} + +func uninstall(logger *slog.Logger) error { + autorunExe, err := currentAutorunExe() + if err != nil { + return err + } + + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if err != nil { + if !errors.Is(err, registry.ErrNotExist) { + return err + } + } else { + defer key.Close() //nolint:errcheck + + if err := key.DeleteValue(runValueKey); err != nil { + if !errors.Is(err, registry.ErrNotExist) { + return err + } + } + } + + if autorunExe != "" { + if err := killProcessesByExe(autorunExe, logger); err != nil { + return fmt.Errorf("failed to stop autorun instance: %w", err) + } + } + + logger.Info("VIIPER autorun entry removed") + return nil +} + +func currentAutorunExe() (string, error) { + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", nil + } + return "", err + } + defer key.Close() //nolint:errcheck + + val, _, err := key.GetStringValue(runValueKey) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", nil + } + return "", err + } + + trimmed := strings.TrimSpace(val) + if trimmed == "" { + return "", nil + } + + if strings.HasPrefix(trimmed, "\"") { + trimmed = strings.TrimPrefix(trimmed, "\"") + if end := strings.Index(trimmed, "\""); end >= 0 { + trimmed = trimmed[:end] + } + } + + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return "", nil + } + + path := fields[0] + if path == "" { + return "", nil + } + return filepath.Clean(path), nil +} + +func killProcessesByExe(target string, logger *slog.Logger) error { + target = filepath.Clean(target) + if target == "" { + return nil + } + + script := fmt.Sprintf( + "$ErrorActionPreference='SilentlyContinue';$t='%s';Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $t } | Select-Object -ExpandProperty ProcessId", + strings.ReplaceAll(target, "'", "''"), + ) + cmd := exec.Command("powershell", "-NoProfile", "-Command", script) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("process query failed: %w: %s", err, strings.TrimSpace(string(output))) + } + + scanner := bufio.NewScanner(bytes.NewReader(output)) + var pids []int + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + pid, err := strconv.Atoi(line) + if err == nil { + pids = append(pids, pid) + } + } + + if err := scanner.Err(); err != nil { + return err + } + + if len(pids) == 0 { + return nil + } + + self := os.Getpid() + for _, pid := range pids { + if pid == self { + continue + } + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("taskkill pid %d failed: %w: %s", pid, err, strings.TrimSpace(string(output))) + } + logger.Info("terminated autorun instance", "pid", pid) + } + + return nil +} diff --git a/viiper/internal/cmd/proxy.go b/internal/cmd/proxy.go similarity index 87% rename from viiper/internal/cmd/proxy.go rename to internal/cmd/proxy.go index c2a5e55a..2a64bbb5 100644 --- a/viiper/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -1,47 +1,48 @@ -package cmd - -import ( - "context" - "errors" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - "viiper/internal/log" - "viiper/internal/server/proxy" -) - -type Proxy struct { - ListenAddr string `help:"Proxy listen address" default:":3241" env:"VIIPER_PROXY_ADDR"` - UpstreamAddr string `help:"Upstream USB-IP server address" required:"" env:"VIIPER_PROXY_UPSTREAM"` - ConnectionTimeout time.Duration `help:"Connection timeout" default:"30s" env:"VIIPER_PROXY_TIMEOUT"` -} - -// Run is called by Kong when the proxy command is executed. -func (p *Proxy) Run(logger *slog.Logger, rawLogger log.RawLogger) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - if p.UpstreamAddr == "" { - return errors.New("Upstream address is empty") - } - - logger.Info("Starting VIIPER USB-IP proxy", "listen", p.ListenAddr, "upstream", p.UpstreamAddr) - proxySrv := proxy.New(p.ListenAddr, p.UpstreamAddr, p.ConnectionTimeout, logger, rawLogger) - - proxyErrCh := make(chan error, 1) - go func() { - proxyErrCh <- proxySrv.ListenAndServe() - }() - - select { - case <-ctx.Done(): - logger.Info("Shutting down proxy server") - _ = proxySrv.Close() - _ = <-proxyErrCh - return nil - case err := <-proxyErrCh: - return err - } -} +package cmd + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/proxy" +) + +type Proxy struct { + ListenAddr string `help:"Proxy listen address" default:":3241" env:"VIIPER_PROXY_ADDR"` + UpstreamAddr string `help:"Upstream USB-IP server address" required:"" env:"VIIPER_PROXY_UPSTREAM"` + ConnectionTimeout time.Duration `help:"Connection timeout" default:"30s" env:"VIIPER_PROXY_TIMEOUT"` +} + +// Run is called by Kong when the proxy command is executed. +func (p *Proxy) Run(logger *slog.Logger, rawLogger log.RawLogger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if p.UpstreamAddr == "" { + return errors.New("upstream address is empty") + } + + logger.Info("Starting VIIPER USB-IP proxy", "listen", p.ListenAddr, "upstream", p.UpstreamAddr) + proxySrv := proxy.New(p.ListenAddr, p.UpstreamAddr, p.ConnectionTimeout, logger, rawLogger) + + proxyErrCh := make(chan error, 1) + go func() { + proxyErrCh <- proxySrv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + logger.Info("Shutting down proxy server") + _ = proxySrv.Close() + _ = <-proxyErrCh // nolint + return nil + case err := <-proxyErrCh: + return err + } +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go new file mode 100644 index 00000000..0d445f19 --- /dev/null +++ b/internal/cmd/server.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Alia5/VIIPER/internal/configpaths" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/internal/tray" + "github.com/Alia5/VIIPER/internal/util" +) + +const keyFileName = "viiper.key.txt" + +type Server struct { + USBServerConfig usb.ServerConfig `embed:"" prefix:"usb."` + APIServerConfig api.ServerConfig `embed:"" prefix:"api."` + ConnectionTimeout time.Duration `help:"ConnectionTimeout operation timeout" default:"30s" env:"VIIPER_CONNECTION_TIMEOUT"` +} + +// Run is called by Kong when the server command is executed. +func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return s.StartServer(ctx, logger, rawLogger) +} + +func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + tray.Run(ctx, cancel) + + s.USBServerConfig.ConnectionTimeout = s.ConnectionTimeout + s.APIServerConfig.ConnectionTimeout = s.ConnectionTimeout + s.USBServerConfig.BusCleanupTimeout = s.APIServerConfig.DeviceHandlerConnectTimeout + + logger.Info("Starting VIIPER USB-IP server", "addr", s.USBServerConfig.Addr) + + keyFileDir, err := configpaths.KeyFileDir() + if err != nil { + return fmt.Errorf("failed to resolve key file path: %w", err) + } + keyFilePath := filepath.Join(keyFileDir, keyFileName) + if pwd, err := os.ReadFile(keyFilePath); err == nil { + s.APIServerConfig.Password = strings.TrimSpace(string(pwd)) + } else { + newPwd, err := auth.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate new API password: %w", err) + } + if err := os.MkdirAll(keyFileDir, 0o700); err != nil { + return fmt.Errorf("failed to create config dir for key file: %w", err) + } + if err := os.WriteFile(keyFilePath, []byte(newPwd), 0o600); err != nil { + return fmt.Errorf("failed to write new API password to file: %w", err) + } + s.APIServerConfig.Password = newPwd + logger.Info("Generated API server password", "path", keyFilePath) + logger.Info("-------------------------------------") + logger.Info("Your VIIPER API server password is:") + logger.Info("-------------------------------------") + logger.Info(newPwd) + logger.Info("-------------------------------------") + logger.Info("You can change this password at any time by editing the file") + } + + usbSrv := usb.New(s.USBServerConfig, logger, rawLogger) + + usbErrCh := make(chan error, 1) + go func() { + usbErrCh <- usbSrv.ListenAndServe() + }() + + select { + case err := <-usbErrCh: + return err + case <-usbSrv.Ready(): + } + + if s.APIServerConfig.Addr == "" { + logger.Error("API server address must be set (default :3242).") + return fmt.Errorf("API server address must be set (default :3242).") // nolint + } + + apiSrv := api.New(usbSrv, s.APIServerConfig.Addr, s.APIServerConfig, logger) + r := apiSrv.Router() + r.Register("ping", handler.Ping()) + r.Register("bus/list", handler.BusList(usbSrv)) + r.Register("bus/create", handler.BusCreate(usbSrv)) + r.Register("bus/remove", handler.BusRemove(usbSrv)) + r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) + r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) + r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) + r.Register("debug/dualsense-traffic/set", handler.DualSenseTrafficSet()) + r.Register("debug/dualsense-traffic/get", handler.DualSenseTrafficGet()) + r.Register("debug/dualsense-traffic/clear", handler.DualSenseTrafficClear()) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) + + if s.APIServerConfig.AutoAttachLocalClient { + logger.Info("Auto-attach is enabled, checking prerequisites...") + if !api.CheckAutoAttachPrerequisites(s.APIServerConfig.AutoAttachWindowsNative, logger) { + logger.Warn("Auto-attach prerequisites not met") + logger.Warn("Device auto-attachment will fail until requirements are satisfied") + logger.Info("You can disable auto-attach with --api.auto-attach-local-client=false") + } else { + logger.Info("Auto-attach prerequisites satisfied") + } + } + + if err := apiSrv.Start(); err != nil { + logger.Error("failed to start API server", "error", err) + if util.IsRunFromGUI() { + fmt.Println("Press any key to exit...") + b := make([]byte, 1) + _, _ = os.Stdin.Read(b) + } + return err + } + + if util.IsRunFromGUI() { + go (func() { + time.Sleep(250 * time.Millisecond) + util.HideConsoleWindow() + })() + } + + select { + case <-ctx.Done(): + if apiSrv != nil { + apiSrv.Close() + } + _ = usbSrv.Close() + _ = <-usbErrCh // nolint + return nil + case err := <-usbErrCh: + if apiSrv != nil { + apiSrv.Close() + } + return err + } +} diff --git a/viiper/internal/codegen/cmd/scan-constants/main.go b/internal/codegen/cmd/scan-constants/main.go similarity index 93% rename from viiper/internal/codegen/cmd/scan-constants/main.go rename to internal/codegen/cmd/scan-constants/main.go index 54cd5af7..4e982aa8 100644 --- a/viiper/internal/codegen/cmd/scan-constants/main.go +++ b/internal/codegen/cmd/scan-constants/main.go @@ -1,66 +1,66 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - - "viiper/internal/codegen/scanner" -) - -func main() { - viiperRoot := "." - if len(os.Args) > 1 { - viiperRoot = os.Args[1] - } - - // Discover device packages automatically - deviceBaseDir := filepath.Join(viiperRoot, "pkg", "device") - entries, err := os.ReadDir(deviceBaseDir) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading device directory: %v\n", err) - os.Exit(1) - } - - var devices []string - for _, entry := range entries { - if entry.IsDir() { - devices = append(devices, entry.Name()) - } - } - - for _, device := range devices { - devicePath := filepath.Join(deviceBaseDir, device) - - result, err := scanner.ScanDeviceConstants(devicePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error scanning %s: %v\n", device, err) - continue - } - - fmt.Printf("\n=== %s ===\n", result.DeviceType) - fmt.Printf("Constants: %d\n", len(result.Constants)) - fmt.Printf("Maps: %d\n", len(result.Maps)) - - if len(result.Constants) > 0 { - fmt.Printf("\nFirst 10 constants:\n") - for i, c := range result.Constants { - if i >= 10 { - break - } - fmt.Printf(" %s = %v (%s)\n", c.Name, c.Value, c.Type) - } - if len(result.Constants) > 10 { - fmt.Printf(" ... and %d more\n", len(result.Constants)-10) - } - } - - if len(result.Maps) > 0 { - fmt.Printf("\nMaps:\n") - for _, m := range result.Maps { - fmt.Printf(" %s: map[%s]%s (%d entries)\n", - m.Name, m.KeyType, m.ValueType, len(m.Entries)) - } - } - } -} +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func main() { + viiperRoot := "." + if len(os.Args) > 1 { + viiperRoot = os.Args[1] + } + + // Discover device packages automatically + deviceBaseDir := filepath.Join(viiperRoot, "pkg", "device") + entries, err := os.ReadDir(deviceBaseDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading device directory: %v\n", err) + os.Exit(1) + } + + var devices []string + for _, entry := range entries { + if entry.IsDir() { + devices = append(devices, entry.Name()) + } + } + + for _, device := range devices { + devicePath := filepath.Join(deviceBaseDir, device) + + result, err := scanner.ScanDeviceConstants(devicePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error scanning %s: %v\n", device, err) + continue + } + + fmt.Printf("\n=== %s ===\n", result.DeviceType) + fmt.Printf("Constants: %d\n", len(result.Constants)) + fmt.Printf("Maps: %d\n", len(result.Maps)) + + if len(result.Constants) > 0 { + fmt.Printf("\nFirst 10 constants:\n") + for i, c := range result.Constants { + if i >= 10 { + break + } + fmt.Printf(" %s = %v (%s)\n", c.Name, c.Value, c.Type) + } + if len(result.Constants) > 10 { + fmt.Printf(" ... and %d more\n", len(result.Constants)-10) + } + } + + if len(result.Maps) > 0 { + fmt.Printf("\nMaps:\n") + for _, m := range result.Maps { + fmt.Printf(" %s: map[%s]%s (%d entries)\n", + m.Name, m.KeyType, m.ValueType, len(m.Entries)) + } + } + } +} diff --git a/viiper/internal/codegen/cmd/scan-dtos/main.go b/internal/codegen/cmd/scan-dtos/main.go similarity index 72% rename from viiper/internal/codegen/cmd/scan-dtos/main.go rename to internal/codegen/cmd/scan-dtos/main.go index bef5afd0..5d0326e9 100644 --- a/viiper/internal/codegen/cmd/scan-dtos/main.go +++ b/internal/codegen/cmd/scan-dtos/main.go @@ -1,33 +1,33 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "viiper/internal/codegen/scanner" -) - -func main() { - projectRoot, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to get working directory: %v\n", err) - os.Exit(1) - } - - apitypesPkg := filepath.Join(projectRoot, "pkg", "apitypes") - schemas, err := scanner.ScanDTOsInPackage(apitypesPkg) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to scan DTOs: %v\n", err) - os.Exit(1) - } - - output, err := json.MarshalIndent(schemas, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "failed to marshal JSON: %v\n", err) - os.Exit(1) - } - - fmt.Println(string(output)) -} +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func main() { + projectRoot, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get working directory: %v\n", err) + os.Exit(1) + } + + viipertypesPkg := filepath.Join(projectRoot, "viipertypes") + schemas, err := scanner.ScanDTOsInPackage(viipertypesPkg) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to scan DTOs: %v\n", err) + os.Exit(1) + } + + output, err := json.MarshalIndent(schemas, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal JSON: %v\n", err) + os.Exit(1) + } + + fmt.Println(string(output)) +} diff --git a/viiper/internal/codegen/cmd/scan-routes/main.go b/internal/codegen/cmd/scan-routes/main.go similarity index 92% rename from viiper/internal/codegen/cmd/scan-routes/main.go rename to internal/codegen/cmd/scan-routes/main.go index 5b354347..2f5f1132 100644 --- a/viiper/internal/codegen/cmd/scan-routes/main.go +++ b/internal/codegen/cmd/scan-routes/main.go @@ -1,40 +1,40 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "viiper/internal/codegen/scanner" -) - -func main() { - projectRoot, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to get working directory: %v\n", err) - os.Exit(1) - } - - serverFile := filepath.Join(projectRoot, "internal", "cmd", "server.go") - routes, err := scanner.ScanRoutes(serverFile) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to scan routes: %v\n", err) - os.Exit(1) - } - - handlerPkg := filepath.Join(projectRoot, "internal", "server", "api", "handler") - enrichedRoutes, err := scanner.EnrichRoutesWithHandlerInfo(routes, handlerPkg) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to enrich routes: %v\n", err) - os.Exit(1) - } - - output, err := json.MarshalIndent(enrichedRoutes, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "failed to marshal JSON: %v\n", err) - os.Exit(1) - } - - fmt.Println(string(output)) -} +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func main() { + projectRoot, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get working directory: %v\n", err) + os.Exit(1) + } + + serverFile := filepath.Join(projectRoot, "internal", "cmd", "server.go") + routes, err := scanner.ScanRoutes(serverFile) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to scan routes: %v\n", err) + os.Exit(1) + } + + handlerPkg := filepath.Join(projectRoot, "internal", "server", "api", "handler") + enrichedRoutes, err := scanner.EnrichRoutesWithHandlerInfo(routes, handlerPkg) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to enrich routes: %v\n", err) + os.Exit(1) + } + + output, err := json.MarshalIndent(enrichedRoutes, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal JSON: %v\n", err) + os.Exit(1) + } + + fmt.Println(string(output)) +} diff --git a/internal/codegen/common/constants_filter.go b/internal/codegen/common/constants_filter.go new file mode 100644 index 00000000..907a91f7 --- /dev/null +++ b/internal/codegen/common/constants_filter.go @@ -0,0 +1,27 @@ +package common + +import "strconv" + +func IsIntegerConst(value interface{}, goType string) bool { + base, _, _ := NormalizeGoType(goType) + switch base { + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "byte": + return true + case "string", "bool", "char", "float32", "float64": + return false + } + + switch v := value.(type) { + case int, int64, uint64: + return true + case string: + if _, err := strconv.ParseInt(v, 0, 64); err == nil { + return true + } + if _, err := strconv.ParseUint(v, 0, 64); err == nil { + return true + } + } + + return false +} diff --git a/internal/codegen/common/enums.go b/internal/codegen/common/enums.go new file mode 100644 index 00000000..6c773de5 --- /dev/null +++ b/internal/codegen/common/enums.go @@ -0,0 +1,38 @@ +package common + +import ( + "sort" + "strings" +) + +// SanitizeLeadingDigit prefixes names that start with a digit with "Num" +// to keep identifiers valid in target languages. +func SanitizeLeadingDigit(name string) string { + if name == "" { + return "" + } + if name[0] >= '0' && name[0] <= '9' { + return "Num" + name + } + return name +} + +// TrimPrefixAndSanitize splits a constant full name into its enum prefix and member, +// and sanitizes the member (e.g., leading digits -> "Num..."). +// Example: "Key_1" => ("Key_", "Num1"), "ModifierShift" => ("Modifier", "Shift"). +func TrimPrefixAndSanitize(full string) (prefix, member string) { + prefix = ExtractPrefix(full) + member = strings.TrimPrefix(full, prefix) + member = SanitizeLeadingDigit(member) + return +} + +// SortedStringKeys returns the sorted keys of a map[string]any. +func SortedStringKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/codegen/common/fileheader.go b/internal/codegen/common/fileheader.go new file mode 100644 index 00000000..aecef2fe --- /dev/null +++ b/internal/codegen/common/fileheader.go @@ -0,0 +1,10 @@ +package common + +import "fmt" + +// FileHeader returns a standard two-line autogenerated header for generated files. +// commentPrefix is the line comment token for the target language (e.g., "//"). +// langLabel is a human readable language label to include (e.g., "TypeScript", "C#"). +func FileHeader(commentPrefix, langLabel string) string { + return fmt.Sprintf("%s Auto-generated VIIPER %s Client Library\n%s DO NOT EDIT - This file is generated from the VIIPER server codebase\n\n", commentPrefix, langLabel, commentPrefix) +} diff --git a/internal/codegen/common/gotypes.go b/internal/codegen/common/gotypes.go new file mode 100644 index 00000000..eb7c5d0b --- /dev/null +++ b/internal/codegen/common/gotypes.go @@ -0,0 +1,19 @@ +package common + +import "strings" + +// NormalizeGoType strips pointer and slice prefixes from a Go type string +// and reports whether the original type was a slice or pointer. +// Examples: "*MyType" -> ("MyType", false, true), "[]uint8" -> ("uint8", true, false) +func NormalizeGoType(goType string) (base string, isSlice bool, isPointer bool) { + base = goType + if strings.HasPrefix(base, "*") { + base = strings.TrimPrefix(base, "*") + isPointer = true + } + if strings.HasPrefix(base, "[]") { + base = strings.TrimPrefix(base, "[]") + isSlice = true + } + return +} diff --git a/viiper/internal/codegen/common/license.go b/internal/codegen/common/license.go similarity index 97% rename from viiper/internal/codegen/common/license.go rename to internal/codegen/common/license.go index 6bb5f0d9..ee932414 100644 --- a/viiper/internal/codegen/common/license.go +++ b/internal/codegen/common/license.go @@ -1,46 +1,46 @@ -package common - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "time" -) - -const mitLicenseTemplate = `MIT License - -Copyright (c) %d Peter Repukat - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -` - -func GenerateLicense(logger *slog.Logger, outputDir string) error { - licensePath := filepath.Join(outputDir, "LICENSE.txt") - - currentYear := time.Now().Year() - licenseText := fmt.Sprintf(mitLicenseTemplate, currentYear) - - if err := os.WriteFile(licensePath, []byte(licenseText), 0644); err != nil { - return fmt.Errorf("write LICENSE.txt: %w", err) - } - - logger.Debug("Generated LICENSE.txt", "path", licensePath) - return nil -} +package common + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "time" +) + +const mitLicenseTemplate = `MIT License + +Copyright (c) %d Peter Repukat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` + +func GenerateLicense(logger *slog.Logger, outputDir string) error { + licensePath := filepath.Join(outputDir, "LICENSE.txt") + + currentYear := time.Now().Year() + licenseText := fmt.Sprintf(mitLicenseTemplate, currentYear) + + if err := os.WriteFile(licensePath, []byte(licenseText), 0644); err != nil { + return fmt.Errorf("write LICENSE.txt: %w", err) + } + + logger.Debug("Generated LICENSE.txt", "path", licensePath) + return nil +} diff --git a/internal/codegen/common/naming.go b/internal/codegen/common/naming.go new file mode 100644 index 00000000..aa371f7d --- /dev/null +++ b/internal/codegen/common/naming.go @@ -0,0 +1,122 @@ +package common + +import ( + "strings" + "unicode" +) + +func ToPascalCase(s string) string { + if s == "" { + return "" + } + + words := strings.FieldsFunc(s, func(r rune) bool { + return r == '_' || r == '-' || unicode.IsSpace(r) + }) + + var result strings.Builder + for _, word := range words { + if len(word) > 0 { + result.WriteString(strings.ToUpper(string(word[0]))) + if len(word) > 1 { + result.WriteString(strings.ToLower(word[1:])) + } + } + } + + return result.String() +} + +func ToTypeName(s string) string { + if s == "" { + return "" + } + + parts := strings.Split(s, ".") + base := parts[len(parts)-1] + if strings.ContainsAny(base, "_- ") { + return ToPascalCase(base) + } + + runes := []rune(base) + if len(runes) > 0 && unicode.IsUpper(runes[0]) { + return base + } + + return ToPascalCase(base) +} + +func ToCamelCase(s string) string { + pascal := ToPascalCase(s) + if len(pascal) == 0 { + return "" + } + return strings.ToLower(string(pascal[0])) + pascal[1:] +} + +func ToSnakeCase(s string) string { + if s == "" { + return "" + } + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); i++ { + r := runes[i] + isUpper := r >= 'A' && r <= 'Z' + + if i > 0 && isUpper { + // Check if previous char is lowercase (e.g., "someWord" -> "some_word") + prevIsLower := runes[i-1] >= 'a' && runes[i-1] <= 'z' + + // Check if next char is lowercase (e.g., "XMLParser" -> "xml_parser", not "x_m_l_parser") + nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + + // Insert underscore if: + // - Previous char is lowercase (camelCase boundary) + // - Current is uppercase and next is lowercase (end of acronym: "XMLParser" at 'P') + if prevIsLower || nextIsLower { + b.WriteByte('_') + } + } + b.WriteRune(r) + } + return strings.ToLower(b.String()) +} + +// ExtractPrefix extracts the common prefix from a constant name for enum grouping +// Examples: "Key_A" -> "Key_", "ModifierShift" -> "Modifier", "LED1" -> "LED" +func ExtractPrefix(name string) string { + if idx := strings.IndexRune(name, '_'); idx >= 0 { + return name[:idx+1] + } + + if len(name) > 1 && isUpper(name[0]) { + runEnd := 1 + for runEnd < len(name) && isUpper(name[runEnd]) { + runEnd++ + } + if runEnd < len(name) && isLower(name[runEnd]) && runEnd > 1 { + return name[:runEnd-1] + } + if runEnd > 1 { + return name[:runEnd] + } + } + + for i := 1; i < len(name); i++ { + if name[i] >= '0' && name[i] <= '9' { + return name[:i] + } + if isUpper(name[i]) && isLower(name[i-1]) { + return name[:i] + } + } + + if (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= 'a' && name[0] <= 'z') { + return name + } + return "" +} + +func isUpper(b byte) bool { return b >= 'A' && b <= 'Z' } +func isLower(b byte) bool { return b >= 'a' && b <= 'z' } diff --git a/internal/codegen/common/readme.go b/internal/codegen/common/readme.go new file mode 100644 index 00000000..777c0754 --- /dev/null +++ b/internal/codegen/common/readme.go @@ -0,0 +1,38 @@ +package common + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const readmeTemplate = `# VIIPER Client Library + +This is an automatically generated client library for [VIIPER](https://github.com/Alia5/VIIPER) - **Virtual** **I**nput over **IP** **E**mulato**R** + +## Documentation + +- **Project Repository**: https://github.com/Alia5/VIIPER +- **Documentation**: https://alia5.github.io/VIIPER/ + +## About VIIPER + +VIIPER creates virtual USB input devices using the USBIP protocol. +These virtual devices appear as real hardware to the operating system and applications, allowing you to emulate controllers, keyboards, and other input devices without physical hardware. + +## License + +MIT License - See LICENSE.txt for details. +` + +func GenerateReadme(logger *slog.Logger, outputDir string) error { + readmePath := filepath.Join(outputDir, "README.md") + + if err := os.WriteFile(readmePath, []byte(readmeTemplate), 0644); err != nil { + return fmt.Errorf("write README.md: %w", err) + } + + logger.Debug("Generated README.md", "path", readmePath) + return nil +} diff --git a/internal/codegen/common/version.go b/internal/codegen/common/version.go new file mode 100644 index 00000000..1a01b388 --- /dev/null +++ b/internal/codegen/common/version.go @@ -0,0 +1,46 @@ +package common + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is set via ldflags at build time: -ldflags "-X viiper/internal/codegen/common.Version=x.y.z" +var Version = "" + +// GetVersion returns the version string that was set at build time via ldflags. +// Returns "0.0.1-dev" if Version is empty (development builds only). +// For production releases, Version MUST be set via: go build -ldflags "-X viiper/internal/codegen/common.Version=x.y.z" +func GetVersion() (string, error) { + if Version == "" { + return "0.0.1-dev", nil + } + + version := strings.TrimPrefix(Version, "v") + baseVersion := strings.SplitN(version, "-", 2)[0] + if !strings.Contains(baseVersion, ".") { + return "", fmt.Errorf("invalid version format: %s (expected x.y.z)", Version) + } + + return version, nil +} + +// ParseVersion extracts major, minor, patch from version string like "1.2.3" or "1.2.3-dirty" +// Returns major, minor, patch as integers. +func ParseVersion(version string) (major, minor, patch int) { + parts := strings.SplitN(version, "-", 2) + version = parts[0] + + nums := strings.Split(version, ".") + if len(nums) >= 1 { + major, _ = strconv.Atoi(nums[0]) + } + if len(nums) >= 2 { + minor, _ = strconv.Atoi(nums[1]) + } + if len(nums) >= 3 { + patch, _ = strconv.Atoi(nums[2]) + } + return +} diff --git a/internal/codegen/common/wiresize.go b/internal/codegen/common/wiresize.go new file mode 100644 index 00000000..d0fb6879 --- /dev/null +++ b/internal/codegen/common/wiresize.go @@ -0,0 +1,115 @@ +package common + +import ( + "sort" + "strconv" + "strings" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +// WireTypeSize returns the size in bytes of a wire protocol type. +func WireTypeSize(wireType string) int { + switch wireType { + case "u8", "i8": + return 1 + case "u16", "i16": + return 2 + case "u32", "i32": + return 4 + case "u64", "i64": + return 8 + default: + return 1 + } +} + +// CalculateOutputSize computes the exact size in bytes of a device's output (s2c) message. +// Returns 0 if the tag is nil or device has no output. +// For variable-length fields (e.g., "u8*count"), returns 0 to indicate dynamic size. +func CalculateOutputSize(tag *scanner.WireTag) int { + if tag == nil { + return 0 + } + + total := 0 + for _, field := range tag.Fields { + wireType := field.Type + if idx := strings.Index(wireType, "*"); idx >= 0 { + baseType := wireType[:idx] + countToken := wireType[idx+1:] + if n, err := strconv.Atoi(countToken); err == nil { + total += WireTypeSize(baseType) * n + continue + } + return 0 + } + total += WireTypeSize(wireType) + } + + return total +} + +// GetWireTag returns the wire tag for a device and direction from metadata. +// Direction can be "input"/"c2s" or "output"/"s2c". +func GetWireTag(md *meta.Metadata, deviceName, direction string) *scanner.WireTag { + if md.WireTags == nil { + return nil + } + dir := direction + switch direction { + case "input": + dir = "c2s" + case "output": + dir = "s2c" + } + return md.WireTags.GetTag(deviceName, dir) +} + +// GetWireFields returns the wire fields for a device and direction from metadata. +func GetWireFields(md *meta.Metadata, deviceName, direction string) []scanner.WireField { + tag := GetWireTag(md, deviceName, direction) + if tag == nil { + return nil + } + return tag.Fields +} + +// HasWireTag returns true if a wire tag exists for the device and direction. +func HasWireTag(md *meta.Metadata, deviceName, direction string) bool { + return GetWireTag(md, deviceName, direction) != nil +} + +// ExtractPathParams parses a route pattern like "bus/{id}/list" and returns +// the parameter names in order (e.g., ["id"]). +func ExtractPathParams(path string) []string { + var params []string + for _, part := range strings.Split(path, "/") { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + params = append(params, part[1:len(part)-1]) + } + } + return params +} + +// MapEntry is used for sorted iteration over map entries in templates. +type MapEntry struct { + Key string + Value any +} + +// SortedMapEntries returns map entries sorted by key for deterministic output. +func SortedMapEntries(entries map[string]interface{}) []MapEntry { + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + + result := make([]MapEntry, 0, len(keys)) + for _, k := range keys { + result = append(result, MapEntry{Key: k, Value: entries[k]}) + } + return result +} diff --git a/internal/codegen/generator/cpp/auth.go b/internal/codegen/generator/cpp/auth.go new file mode 100644 index 00000000..7241bdda --- /dev/null +++ b/internal/codegen/generator/cpp/auth.go @@ -0,0 +1,371 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const authHeaderTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include "socket.hpp" +#include "../error.hpp" +#include +#include +#include +#include +#include + +#if __has_include() +#define VIIPER_HAS_OPENSSL 1 +#include +#include +#include +#else +#define VIIPER_HAS_OPENSSL 0 +#endif + +namespace viiper { +namespace detail { + +class EncryptedSocket; +Result> perform_handshake(Socket&& socket, const std::string& password); + +} // namespace detail +} // namespace viiper + +#include "auth_impl.hpp" +` + +func generateAuthHeader(logger *slog.Logger, detailDir string) error { + logger.Debug("Generating detail/auth.hpp") + outputFile := filepath.Join(detailDir, "auth.hpp") + + if err := os.WriteFile(outputFile, []byte(authHeaderTemplate), 0644); err != nil { + return fmt.Errorf("write detail/auth.hpp: %w", err) + } + + logger.Info("Generated detail/auth.hpp", "file", outputFile) + + return generateAuthImpl(logger, detailDir) +} + +const authImplTemplate = `// Auto-generated VIIPER C++ Client Library - Authentication Implementation +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "socket.hpp" +#include "../error.hpp" + +namespace viiper { +namespace detail { + +// ============================================================================ +// Crypto Constants +// ============================================================================ + +constexpr const char* HANDSHAKE_MAGIC = "eVI1\x00"; +constexpr size_t NONCE_SIZE = 32; +constexpr const char* AUTH_CONTEXT = "VIIPER-Auth-v1"; +constexpr const char* SESSION_CONTEXT = "VIIPER-Session-v1"; +constexpr const char* PBKDF2_SALT = "VIIPER-Key-v1"; +constexpr uint32_t PBKDF2_ITERATIONS = 100000; + +// ============================================================================ +// OpenSSL-based Crypto Utilities +// ============================================================================ + +// PBKDF2-HMAC-SHA256 using OpenSSL +inline void pbkdf2_hmac_sha256(const uint8_t* password, size_t password_len, + const uint8_t* salt, size_t salt_len, + uint32_t iterations, uint8_t* out, size_t out_len) { + PKCS5_PBKDF2_HMAC(reinterpret_cast(password), static_cast(password_len), + salt, static_cast(salt_len), + static_cast(iterations), + EVP_sha256(), + static_cast(out_len), out); +} + +// HMAC-SHA256 using OpenSSL +inline void hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data, size_t data_len, uint8_t* out) { + unsigned int len = 32; + HMAC(EVP_sha256(), key, static_cast(key_len), data, data_len, out, &len); +} + +// SHA-256 using OpenSSL +inline void sha256(const uint8_t* data, size_t len, uint8_t* out) { + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr); + EVP_DigestUpdate(ctx, data, len); + EVP_DigestFinal_ex(ctx, out, nullptr); + EVP_MD_CTX_free(ctx); +} + +// ============================================================================ +// ChaCha20-Poly1305 AEAD using OpenSSL +// ============================================================================ + +class ChaCha20Poly1305 { +private: + uint8_t key_[32]; + +public: + ChaCha20Poly1305(const uint8_t key[32]) { + std::memcpy(key_, key, 32); + } + + void encrypt(const uint8_t nonce[12], const uint8_t* plaintext, size_t pt_len, + uint8_t* ciphertext, uint8_t tag[16]) { + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + EVP_EncryptInit_ex(ctx, EVP_chacha20_poly1305(), nullptr, key_, nonce); + + int len; + EVP_EncryptUpdate(ctx, ciphertext, &len, plaintext, static_cast(pt_len)); + + int ciphertext_len = len; + EVP_EncryptFinal_ex(ctx, ciphertext + len, &len); + ciphertext_len += len; + + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, 16, tag); + EVP_CIPHER_CTX_free(ctx); + } + + bool decrypt(const uint8_t nonce[12], const uint8_t* ciphertext, size_t ct_len, + const uint8_t tag[16], uint8_t* plaintext) { + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + EVP_DecryptInit_ex(ctx, EVP_chacha20_poly1305(), nullptr, key_, nonce); + + int len; + EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, static_cast(ct_len)); + + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, 16, const_cast(tag)); + + int ret = EVP_DecryptFinal_ex(ctx, plaintext + len, &len); + EVP_CIPHER_CTX_free(ctx); + + return ret > 0; + } +}; + +// ============================================================================ +// Encrypted Socket Wrapper +// ============================================================================ + +class EncryptedSocket { +private: + Socket socket_; + ChaCha20Poly1305 cipher_; + uint64_t send_counter_ = 0; + std::vector recv_buffer_; + +public: + EncryptedSocket(Socket&& socket, const std::array& session_key) + : socket_(std::move(socket)), cipher_(session_key.data()) {} + + + Result send(const std::string& data) { + return send(reinterpret_cast(data.data()), data.size()); + } + + Result send(const uint8_t* data, size_t size) { + uint8_t nonce[12] = {0}; + for (int i = 0; i < 8; ++i) { + nonce[4 + i] = (send_counter_ >> (56 - i * 8)) & 0xff; + } + send_counter_++; + + std::vector ciphertext(size); + uint8_t tag[16]; + cipher_.encrypt(nonce, data, size, ciphertext.data(), tag); + + uint32_t packet_len = static_cast(12 + ciphertext.size() + 16); + uint8_t len_bytes[4] = { + static_cast((packet_len >> 24) & 0xff), + static_cast((packet_len >> 16) & 0xff), + static_cast((packet_len >> 8) & 0xff), + static_cast(packet_len & 0xff) + }; + + std::string packet; + packet.append(reinterpret_cast(len_bytes), 4); + packet.append(reinterpret_cast(nonce), 12); + packet.append(reinterpret_cast(ciphertext.data()), ciphertext.size()); + packet.append(reinterpret_cast(tag), 16); + + return socket_.send(packet); + } + + Result recv(uint8_t* buffer, size_t size) { + std::vector len_buf(4); + auto read_result = socket_.recv_exact(len_buf.data(), 4); + if (read_result.is_error()) { + if (read_result.error().message == "connection closed") { + return 0; // Return 0 bytes on EOF + } + return read_result.error(); + } + + uint32_t packet_len = (len_buf[0] << 24) | (len_buf[1] << 16) | + (len_buf[2] << 8) | len_buf[3]; + + if (packet_len > 2 * 1024 * 1024) { + return Error("Packet too large"); + } + + std::vector packet(packet_len); + read_result = socket_.recv_exact(packet.data(), packet_len); + if (read_result.is_error()) return read_result.error(); + + if (packet_len < 28) return Error("Packet too small"); + + const uint8_t* nonce = packet.data(); + const uint8_t* ciphertext = packet.data() + 12; + size_t ct_len = packet_len - 12 - 16; + const uint8_t* tag = packet.data() + 12 + ct_len; + + std::vector plaintext(ct_len); + if (!cipher_.decrypt(nonce, ciphertext, ct_len, tag, plaintext.data())) { + return Error("Decryption failed"); + } + + size_t to_copy = (ct_len < size) ? ct_len : size; + std::memcpy(buffer, plaintext.data(), to_copy); + return to_copy; + } + + Result recv_line() { + std::vector len_buf(4); + auto read_result = socket_.recv_exact(len_buf.data(), 4); + if (read_result.is_error()) { + return read_result.error(); + } + + uint32_t packet_len = (len_buf[0] << 24) | (len_buf[1] << 16) | + (len_buf[2] << 8) | len_buf[3]; + + if (packet_len > 2 * 1024 * 1024) { + return Error("Packet too large"); + } + + std::vector packet(packet_len); + read_result = socket_.recv_exact(packet.data(), packet_len); + if (read_result.is_error()) return read_result.error(); + + if (packet_len < 28) return Error("Packet too small"); + + const uint8_t* nonce = packet.data(); + const uint8_t* ciphertext = packet.data() + 12; + size_t ct_len = packet_len - 12 - 16; + const uint8_t* tag = packet.data() + 12 + ct_len; + + std::vector plaintext(ct_len); + if (!cipher_.decrypt(nonce, ciphertext, ct_len, tag, plaintext.data())) { + return Error("Decryption failed"); + } + + return std::string(reinterpret_cast(plaintext.data()), plaintext.size()); + } + + Socket& get_socket() { return socket_; } + + bool is_valid() const { return socket_.is_valid(); } + + void force_close() { socket_.force_close(); } +}; + +// ============================================================================ +// Main Handshake Function +// ============================================================================ + +inline Result> perform_handshake(Socket&& socket, const std::string& password) { + if (password.empty()) { + return Error("Password cannot be empty"); + } + + std::array key; + pbkdf2_hmac_sha256( + reinterpret_cast(password.data()), password.size(), + reinterpret_cast(PBKDF2_SALT), std::strlen(PBKDF2_SALT), + PBKDF2_ITERATIONS, key.data(), 32 + ); + + std::array client_nonce; + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(0, 255); + for (auto& byte : client_nonce) byte = static_cast(dis(gen)); + + std::array auth_tag; + std::vector auth_data; + auth_data.insert(auth_data.end(), AUTH_CONTEXT, AUTH_CONTEXT + std::strlen(AUTH_CONTEXT)); + auth_data.insert(auth_data.end(), client_nonce.begin(), client_nonce.end()); + hmac_sha256(key.data(), 32, auth_data.data(), auth_data.size(), auth_tag.data()); + + std::string handshake; + handshake.append(HANDSHAKE_MAGIC, 5); + handshake.append(reinterpret_cast(client_nonce.data()), NONCE_SIZE); + handshake.append(reinterpret_cast(auth_tag.data()), 32); + + auto send_result = socket.send(handshake); + if (send_result.is_error()) return send_result.error(); + + std::vector response(3 + NONCE_SIZE); + auto recv_result = socket.recv_exact(response.data(), response.size()); + if (recv_result.is_error()) return recv_result.error(); + + if (response[0] != 'O' || response[1] != 'K' || response[2] != '\0') { + // Try to read error message + auto error_data = socket.recv_line(); + if (error_data.is_error()) { + return Error("Invalid handshake response"); + } + return Error("Authentication failed: " + error_data.value()); + } + + std::array server_nonce; + std::memcpy(server_nonce.data(), response.data() + 3, NONCE_SIZE); + + std::vector session_data; + session_data.insert(session_data.end(), key.begin(), key.end()); + session_data.insert(session_data.end(), server_nonce.begin(), server_nonce.end()); + session_data.insert(session_data.end(), client_nonce.begin(), client_nonce.end()); + session_data.insert(session_data.end(), SESSION_CONTEXT, SESSION_CONTEXT + std::strlen(SESSION_CONTEXT)); + + std::array session_key; + sha256(session_data.data(), session_data.size(), session_key.data()); + + auto encrypted = std::make_unique(std::move(socket), session_key); + return encrypted; +} + +} // namespace detail +} // namespace viiper +` + +func generateAuthImpl(logger *slog.Logger, detailDir string) error { + logger.Debug("Generating detail/auth_impl.hpp") + outputFile := filepath.Join(detailDir, "auth_impl.hpp") + + if err := os.WriteFile(outputFile, []byte(authImplTemplate), 0644); err != nil { + return fmt.Errorf("write detail/auth_impl.hpp: %w", err) + } + + logger.Info("Generated detail/auth_impl.hpp (FULL implementation)", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/client.go b/internal/codegen/generator/cpp/client.go new file mode 100644 index 00000000..4a993208 --- /dev/null +++ b/internal/codegen/generator/cpp/client.go @@ -0,0 +1,197 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const clientTemplate = `{{.Header}} +#pragma once + +#include "config.hpp" +#include "error.hpp" +#include "types.hpp" +#include "device.hpp" +#include "detail/socket.hpp" +#include "detail/json.hpp" +#include "detail/auth.hpp" +#include +#include +#include +#include + +namespace viiper { + +// ============================================================================ +// VIIPER Management API Client (thread-safe) +// ============================================================================ + +class ViiperClient { +public: + ViiperClient(std::string host, std::uint16_t port = 3242, std::string password = "") + : host_(std::move(host)), port_(port), password_(std::move(password)) {} + + ~ViiperClient() = default; + + ViiperClient(const ViiperClient&) = delete; + ViiperClient& operator=(const ViiperClient&) = delete; + ViiperClient(ViiperClient&&) = delete; + ViiperClient& operator=(ViiperClient&&) = delete; + + [[nodiscard]] const std::string& host() const noexcept { return host_; } + [[nodiscard]] std::uint16_t port() const noexcept { return port_; } + [[nodiscard]] const std::string& password() const noexcept { return password_; } + + // ======================================================================== + // Management API Methods (all return Result) + // ======================================================================== +{{range .Routes}}{{if eq .Method "Register"}} + // {{.Handler}}: {{.Path}} + Result<{{responseCppType .ResponseDTO}}{{if eq (responseCppType .ResponseDTO) ""}}void{{end}}> {{camelcase .Handler}}({{$params := pathParams .Path}}{{range $i, $p := $params}}{{if $i}}, {{end}}{{pathParamType $p}} {{$p}}{{end}}{{$payloadType := payloadCppType .Payload}}{{if ne $payloadType ""}}{{if $params}}, {{end}}{{$payloadType}} payload{{end}}) { + {{$path := .Path}}{{if $params}}std::string path = format_path("{{$path}}", { {{range $i, $p := $params}}{{if $i}}, {{end}}{ "{{$p}}", {{formatPathParamValue $p}} }{{end}} });{{else}}const std::string path = "{{$path}}";{{end}} + {{if eq .Payload.Kind "json"}}const std::string payload_str = payload.to_json().dump();{{else if eq .Payload.Kind "numeric"}}const std::string payload_str = {{if .Payload.Required}}std::to_string(payload){{else}}payload.has_value() ? std::to_string(*payload) : ""{{end}};{{else if eq .Payload.Kind "string"}}const std::string& payload_str = payload;{{else}}const std::string payload_str;{{end}} + auto response = do_request(path, payload_str); + if (response.is_error()) return response.error(); + {{if .ResponseDTO}}return {{responseCppType .ResponseDTO}}::from_json(response.value());{{else}}return Result();{{end}} + } +{{end}}{{end}} + + // ======================================================================== + // Device Stream Connection + // ======================================================================== + + /// Connect to an existing device's stream for sending input and receiving output + [[nodiscard]] Result> connectDevice( + std::uint32_t bus_id, + const std::string& dev_id + ) { + detail::Socket socket; + auto conn_result = socket.connect(host_, port_); + if (conn_result.is_error()) return conn_result.error(); + + std::string handshake = "bus/" + std::to_string(bus_id) + "/" + dev_id + '\0'; + + if (!password_.empty()) { + auto handshake_result = detail::perform_handshake(std::move(socket), password_); + if (handshake_result.is_error()) return handshake_result.error(); + + auto encrypted_socket = std::move(handshake_result.value()); + auto send_result = encrypted_socket->send(handshake); + if (send_result.is_error()) return send_result.error(); + + return std::unique_ptr(new ViiperDevice(std::move(encrypted_socket))); + } else { + auto send_result = socket.send(handshake); + if (send_result.is_error()) return send_result.error(); + + return std::unique_ptr(new ViiperDevice(std::move(socket))); + } + } + + /// Create a device and connect to its stream in one step + [[nodiscard]] Result>> addDeviceAndConnect( + std::uint32_t bus_id, + const DeviceCreateRequest& request + ) { + auto device_result = busdeviceadd(bus_id, request); + if (device_result.is_error()) return device_result.error(); + + auto& device_info = device_result.value(); + auto connect_result = connectDevice(bus_id, device_info.devid); + if (connect_result.is_error()) return connect_result.error(); + + return std::make_pair(std::move(device_info), std::move(connect_result.value())); + } + +private: + Result do_request(const std::string& path, const std::string& payload) { + std::lock_guard lock(request_mutex_); + + detail::Socket socket; + auto connect_result = socket.connect(host_, port_); + if (connect_result.is_error()) return connect_result.error(); + + std::string request = path; + if (!payload.empty()) { + request += " " + payload; + } + request += '\0'; + + if (!password_.empty()) { + auto handshake_result = detail::perform_handshake(std::move(socket), password_); + if (handshake_result.is_error()) return handshake_result.error(); + + auto encrypted_socket = std::move(handshake_result.value()); + auto send_result = encrypted_socket->send(request); + if (send_result.is_error()) return send_result.error(); + + auto recv_result = encrypted_socket->recv_line(); + if (recv_result.is_error()) return recv_result.error(); + + return detail::parse_json_response(recv_result.value()); + } else { + auto send_result = socket.send(request); + if (send_result.is_error()) return send_result.error(); + + auto recv_result = socket.recv_line(); + if (recv_result.is_error()) return recv_result.error(); + + return detail::parse_json_response(recv_result.value()); + } + } + + static std::string format_path(const std::string& pattern, + std::initializer_list> params) { + std::string result = pattern; + for (const auto& [name, value] : params) { + std::string placeholder = "{" + name + "}"; + std::size_t pos = result.find(placeholder); + if (pos != std::string::npos) { + result.replace(pos, placeholder.length(), value); + } + } + return result; + } + + std::string host_; + std::uint16_t port_; + std::string password_; + mutable std::mutex request_mutex_; +}; + +} // namespace viiper +` + +func generateClient(logger *slog.Logger, includeDir string, md *meta.Metadata) error { + logger.Debug("Generating client.hpp") + outputFile := filepath.Join(includeDir, "client.hpp") + + tmpl := template.Must(template.New("client").Funcs(tplFuncs(md)).Parse(clientTemplate)) + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create client.hpp: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Header string + Routes []scanner.RouteInfo + }{ + Header: writeFileHeader(), + Routes: md.Routes, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute client template: %w", err) + } + + logger.Info("Generated client.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/config.go b/internal/codegen/generator/cpp/config.go new file mode 100644 index 00000000..4adc670a --- /dev/null +++ b/internal/codegen/generator/cpp/config.go @@ -0,0 +1,71 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const configTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +// ============================================================================ +// JSON Parser Configuration +// ============================================================================ +// +// VIIPER C++ Client Library requires a JSON library for parsing API responses. +// You must define VIIPER_JSON_INCLUDE before including viiper.hpp +// +// Option 1: Use nlohmann::json (recommended) +// #define VIIPER_JSON_INCLUDE +// #define VIIPER_JSON_NAMESPACE nlohmann +// #define VIIPER_JSON_TYPE json +// #include +// +// Option 2: Use custom JSON library +// Define VIIPER_JSON_INCLUDE, VIIPER_JSON_NAMESPACE, and VIIPER_JSON_TYPE +// Your JSON type must support: +// - parse(const std::string&) -> JsonType +// - dump() -> std::string +// - operator[](const std::string&) -> JsonType +// - contains(const std::string&) -> bool +// - is_number(), is_string(), is_array(), is_object() -> bool +// - get() -> T +// - size() -> std::size_t (for arrays) +// +// ============================================================================ + +#ifndef VIIPER_JSON_INCLUDE +#error "VIIPER_JSON_INCLUDE must be defined before including viiper.hpp (e.g., #define VIIPER_JSON_INCLUDE )" +#endif + +#ifndef VIIPER_JSON_NAMESPACE +#error "VIIPER_JSON_NAMESPACE must be defined before including viiper.hpp (e.g., #define VIIPER_JSON_NAMESPACE nlohmann)" +#endif + +#ifndef VIIPER_JSON_TYPE +#error "VIIPER_JSON_TYPE must be defined before including viiper.hpp (e.g., #define VIIPER_JSON_TYPE json)" +#endif + +#include VIIPER_JSON_INCLUDE + +namespace viiper { +namespace json_ns = VIIPER_JSON_NAMESPACE; +using json_type = json_ns::VIIPER_JSON_TYPE; +} // namespace viiper +` + +func generateConfig(logger *slog.Logger, includeDir string) error { + logger.Debug("Generating config.hpp") + outputFile := filepath.Join(includeDir, "config.hpp") + + if err := os.WriteFile(outputFile, []byte(configTemplate), 0644); err != nil { + return fmt.Errorf("write config.hpp: %w", err) + } + + logger.Info("Generated config.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/device.go b/internal/codegen/generator/cpp/device.go new file mode 100644 index 00000000..09715c09 --- /dev/null +++ b/internal/codegen/generator/cpp/device.go @@ -0,0 +1,205 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const deviceTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include "config.hpp" +#include "error.hpp" +#include "detail/socket.hpp" +#include "detail/auth_impl.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace viiper { + +template +concept DeviceInput = requires(T input) { + { input.to_bytes() } -> std::convertible_to>; +}; + +// ============================================================================ +// Device Stream Connection (thread-safe) +// ============================================================================ + +class ViiperDevice { +public: + using OutputCallback = std::function; + using DisconnectCallback = std::function; + using ErrorCallback = std::function; + + ~ViiperDevice() { + stop(); + } + + ViiperDevice(const ViiperDevice&) = delete; + ViiperDevice& operator=(const ViiperDevice&) = delete; + ViiperDevice(ViiperDevice&&) = delete; + ViiperDevice& operator=(ViiperDevice&&) = delete; + + // ======================================================================== + // Input (Client -> Device) + // ======================================================================== + + template + Result send(const T& input) { + std::lock_guard lock(send_mutex_); + auto bytes = input.to_bytes(); + return std::visit([&](auto& sock) -> Result { + if constexpr (std::is_same_v, std::unique_ptr>) { + return sock->send(bytes.data(), bytes.size()); + } else { + return sock.send(bytes.data(), bytes.size()); + } + }, socket_); + } + + Result send_raw(const std::uint8_t* data, std::size_t size) { + std::lock_guard lock(send_mutex_); + return std::visit([&](auto& sock) -> Result { + if constexpr (std::is_same_v, std::unique_ptr>) { + return sock->send(data, size); + } else { + return sock.send(data, size); + } + }, socket_); + } + + // ======================================================================== + // Output (Device -> Client, async) + // ======================================================================== + + Result on_output(std::size_t buffer_size, OutputCallback callback) { + std::lock_guard lock(callback_mutex_); + + if (output_thread_.joinable()) { + return Error("output callback already registered"); + } + + output_callback_ = std::move(callback); + output_buffer_size_ = buffer_size; + running_ = true; + + output_thread_ = std::thread([this]() { + auto buffer = std::make_unique(output_buffer_size_); + + while (running_) { + auto recv_result = std::visit([&](auto& sock) -> Result { + if constexpr (std::is_same_v, std::unique_ptr>) { + return sock->recv(buffer.get(), output_buffer_size_); + } else { + return sock.recv(buffer.get(), output_buffer_size_); + } + }, socket_); + + if (recv_result.is_error()) { + if (error_callback_) { + error_callback_(recv_result.error()); + } + running_ = false; + break; + } + + auto bytes_read = recv_result.value(); + if (bytes_read == 0) { + running_ = false; + break; + } + + std::lock_guard lock(callback_mutex_); + if (output_callback_) { + output_callback_(buffer.get(), bytes_read); + } + } + + std::lock_guard lock(callback_mutex_); + if (disconnect_callback_) { + disconnect_callback_(); + } + }); + + return Result(); + } + + void on_disconnect(DisconnectCallback callback) { + std::lock_guard lock(callback_mutex_); + disconnect_callback_ = std::move(callback); + } + + void on_error(ErrorCallback callback) { + std::lock_guard lock(callback_mutex_); + error_callback_ = std::move(callback); + } + + void stop() { + running_ = false; + std::visit([](auto& sock) { + if constexpr (std::is_same_v, std::unique_ptr>) { + sock->force_close(); + } else { + sock.force_close(); + } + }, socket_); + if (output_thread_.joinable()) { + output_thread_.join(); + } + } + + [[nodiscard]] bool is_connected() const noexcept { + return running_.load() && std::visit([](const auto& sock) -> bool { + if constexpr (std::is_same_v, std::unique_ptr>) { + return sock->is_valid(); + } else { + return sock.is_valid(); + } + }, socket_); + } + + explicit ViiperDevice(detail::Socket socket) + : socket_(std::move(socket)), running_(false), output_buffer_size_(0) {} + + explicit ViiperDevice(std::unique_ptr encrypted_socket) + : socket_(std::move(encrypted_socket)), running_(false), output_buffer_size_(0) {} + +private: + std::variant> socket_; + std::atomic running_; + std::size_t output_buffer_size_; + OutputCallback output_callback_; + DisconnectCallback disconnect_callback_; + ErrorCallback error_callback_; + std::thread output_thread_; + std::mutex send_mutex_; + std::mutex callback_mutex_; +}; + +} // namespace viiper +` + +func generateDevice(logger *slog.Logger, includeDir string, _ *meta.Metadata) error { + logger.Debug("Generating device.hpp") + outputFile := filepath.Join(includeDir, "device.hpp") + + if err := os.WriteFile(outputFile, []byte(deviceTemplate), 0644); err != nil { + return fmt.Errorf("write device.hpp: %w", err) + } + + logger.Info("Generated device.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go new file mode 100644 index 00000000..c37a57c3 --- /dev/null +++ b/internal/codegen/generator/cpp/device_header.go @@ -0,0 +1,439 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const deviceHeaderTemplate = `{{.Header}} +#pragma once + +#include "../error.hpp" +#include "../detail/json.hpp" +#include +#include +{{- if or .HasMaps .HasFixedWireArrays}} +#include +{{- end}} +{{- if or .HasMaps .HasDeviceSpecific}} +#include +#include +#include +{{- end}} +{{- if .HasMaps}} +#include +#include +{{- end}} + +namespace viiper { +namespace {{camelcase .DeviceName}} { + +// ============================================================================ +// Constants +// ============================================================================ +{{if gt .OutputSize 0}} +constexpr std::size_t OUTPUT_SIZE = {{.OutputSize}}; +{{end}} +{{- range .Constants}} +constexpr std::uint64_t {{.Name}} = {{.Value}}; +{{- end}} + +{{if .HasDeviceSpecific}} +// ============================================================================ +// Device-specific typed helpers +// ============================================================================ +{{range .DeviceStructs}} +struct {{.Name}} { +{{- range .Fields}} + {{fieldcpptype .}} {{.Name}}{}; +{{- end}} + + static {{.Name}} from_map(const json_type& j) { + {{.Name}} result; +{{- range .Fields}} +{{- if and .Optional (eq .TypeKind "map")}} + if (j.contains("{{.JSONName}}") && !j["{{.JSONName}}"].is_null()) { + result.{{.Name}} = j["{{.JSONName}}"]; + } else { + result.{{.Name}} = std::nullopt; + } +{{- else if .Optional}} + result.{{.Name}} = detail::get_optional_field<{{fieldcpptype . | unwrapOptional}}>(j, "{{.JSONName}}"); +{{- else if eq .TypeKind "slice"}} + result.{{.Name}} = detail::get_array<{{cpptype .Type | sliceElementType}}>(j, "{{.JSONName}}"); +{{- else if eq .TypeKind "map"}} + if (j.contains("{{.JSONName}}")) { + result.{{.Name}} = j["{{.JSONName}}"]; + } +{{- else if isCustomType .Type}} + if (j.contains("{{.JSONName}}")) { + result.{{.Name}} = {{cpptype .Type}}::from_json(j["{{.JSONName}}"]); + } +{{- else}} + result.{{.Name}} = j.value("{{.JSONName}}", {{cpptype .Type}}{}); +{{- end}} +{{- end}} + return result; + } + + [[nodiscard]] json_type to_map() const { + json_type j; +{{- range .Fields}} +{{- if .Optional}} + if ({{.Name}}.has_value()) { + j["{{.JSONName}}"] = {{.Name}}.value(); + } +{{- else if eq .TypeKind "slice"}} + { + json_type arr = json_type::array(); + for (const auto& item : {{.Name}}) { + {{- if isCustomType .Type}} + arr.push_back(item.to_json()); + {{- else}} + arr.push_back(item); + {{- end}} + } + j["{{.JSONName}}"] = std::move(arr); + } +{{- else}} + j["{{.JSONName}}"] = {{.Name}}; +{{- end}} +{{- end}} + return j; + } +}; + +{{end}} +{{end}} +{{range .Maps}} +{{- if and (isByteKeyMap .KeyType) (hasCharLiteralKeys .Entries)}} +{{- if eq .ValueType "bool"}} +{{- $mapName := toScreamingSnakeCase .Name}} +inline const std::unordered_set {{$mapName}} = { +{{- range $entry := sortedEntries .Entries}} + {{formatKey $entry.Key true}}, +{{- end}} +}; +{{- else}} +{{- $mapName := toScreamingSnakeCase .Name}} +{{- $valueType := .ValueType}} +inline const std::unordered_map {{$mapName}} = { +{{- range $entry := sortedEntries .Entries}} + { {{formatKey $entry.Key true}}, static_cast<{{cpptype $valueType}}>({{formatValue $entry.Value}}) }, +{{- end}} +}; +{{- end}} +{{- else if eq .ValueType "string"}} +{{- $mapName := toScreamingSnakeCase .Name}} +{{- $entries := sortedEntries .Entries}} +inline constexpr std::array, {{len $entries}}> {{$mapName}} = {{"{"}}{{"{"}} +{{- range $i, $e := $entries}} + { {{formatKey $e.Key false}}, "{{$e.Value}}" }{{if not (isLast $i $entries)}},{{end}} +{{- end}} +}{{"}"}}; + +[[nodiscard]] inline std::optional {{camelcase .Name}}(std::uint64_t key) noexcept { + auto it = std::lower_bound({{$mapName}}.begin(), {{$mapName}}.end(), key, + [](const auto& p, std::uint64_t k) { return p.first < k; }); + if (it != {{$mapName}}.end() && it->first == key) { + return it->second; + } + return std::nullopt; +} +{{- else if isNumericMapVal .ValueType}} +{{- $mapName := .Name}} +{{- range $entry := sortedEntries .Entries}} +constexpr std::uint64_t {{$mapName}}_{{$entry.Key}} = {{formatValue $entry.Value}}; +{{- end}} +{{- end}} +{{end}} +{{if .HasInput}} +{{$fields := wireFields .DeviceName "c2s"}} +// ============================================================================ +// Input: Client -> Device +// ============================================================================ + +struct Input { +{{- range $fields}} +{{- if isArrayType .Type}} +{{- if isFixedArrayType .Type}} + std::array<{{cpptype (baseType .Type)}}, {{fixedArrayLen .Type}}> {{camelcase .Name}}{}; +{{- else}} + std::vector<{{cpptype (baseType .Type)}}> {{camelcase .Name}}; +{{- end}} +{{- else if not (isCountField $fields .Name)}} + {{cpptype .Type}} {{camelcase .Name}} = 0; +{{- end}} +{{- end}} + + [[nodiscard]] std::vector to_bytes() const { + std::vector buf; +{{- range $fields}} +{{- if isArrayType .Type}} + {{- $abt := baseType .Type}} + {{- if isFixedArrayType .Type}} + for (std::size_t i = 0; i < static_cast({{fixedArrayLen .Type}}); i++) { + const auto v = {{camelcase .Name}}[i]; + {{- if eq $abt "u8"}} + buf.push_back(static_cast(v)); + {{- else if eq $abt "i8"}} + buf.push_back(static_cast(static_cast(v))); + {{- else if or (eq $abt "u16") (eq $abt "i16")}} + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + {{- else if or (eq $abt "u32") (eq $abt "i32")}} + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + buf.push_back(static_cast((v >> 16) & 0xFF)); + buf.push_back(static_cast((v >> 24) & 0xFF)); + {{- end}} + } + {{- else}} + buf.push_back(static_cast({{camelcase .Name}}.size())); + for (const auto& v : {{camelcase .Name}}) { + {{- if eq $abt "u8"}} + buf.push_back(static_cast(v)); + {{- else if eq $abt "i8"}} + buf.push_back(static_cast(static_cast(v))); + {{- else if or (eq $abt "u16") (eq $abt "i16")}} + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + {{- else if or (eq $abt "u32") (eq $abt "i32")}} + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + buf.push_back(static_cast((v >> 16) & 0xFF)); + buf.push_back(static_cast((v >> 24) & 0xFF)); + {{- end}} + } + {{- end}} +{{- else if not (isCountField $fields .Name)}} +{{- $bt := .Type}} +{{- if eq $bt "u8"}} + buf.push_back({{camelcase .Name}}); +{{- else if eq $bt "i8"}} + buf.push_back(static_cast({{camelcase .Name}})); +{{- else if or (eq $bt "u16") (eq $bt "i16")}} + buf.push_back(static_cast({{camelcase .Name}} & 0xFF)); + buf.push_back(static_cast(({{camelcase .Name}} >> 8) & 0xFF)); +{{- else if or (eq $bt "u32") (eq $bt "i32")}} + buf.push_back(static_cast({{camelcase .Name}} & 0xFF)); + buf.push_back(static_cast(({{camelcase .Name}} >> 8) & 0xFF)); + buf.push_back(static_cast(({{camelcase .Name}} >> 16) & 0xFF)); + buf.push_back(static_cast(({{camelcase .Name}} >> 24) & 0xFF)); +{{- end}} +{{- end}} +{{- end}} + return buf; + } +}; +{{end}} +{{if .HasOutput}} +{{$fields := wireFields .DeviceName "s2c"}} +// ============================================================================ +// Output: Device -> Client +// ============================================================================ + +struct Output { +{{- range $fields}} +{{- if isArrayType .Type}} +{{- if isFixedArrayType .Type}} + std::array<{{cpptype (baseType .Type)}}, {{fixedArrayLen .Type}}> {{camelcase .Name}}{}; +{{- else}} + std::vector<{{cpptype (baseType .Type)}}> {{camelcase .Name}}; +{{- end}} +{{- else}} + {{cpptype .Type}} {{camelcase .Name}} = 0; +{{- end}} +{{- end}} + + static Result from_bytes(const std::uint8_t* data, std::size_t len) { + Output result; + std::size_t offset = 0; +{{- range $fields}} +{{- if isArrayType .Type}} +{{- if isFixedArrayType .Type}} +{{- $bt := baseType .Type}} + for (std::size_t i = 0; i < static_cast({{fixedArrayLen .Type}}); i++) { +{{- if eq $bt "u8"}} + if (offset >= len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = data[offset++]; +{{- else if eq $bt "i8"}} + if (offset >= len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = static_cast(data[offset++]); +{{- else if eq $bt "u16"}} + if (offset + 2 > len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = data[offset] | (static_cast(data[offset + 1]) << 8); + offset += 2; +{{- else if eq $bt "i16"}} + if (offset + 2 > len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = static_cast(data[offset] | (static_cast(data[offset + 1]) << 8)); + offset += 2; +{{- else if eq $bt "u32"}} + if (offset + 4 > len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); + offset += 4; +{{- else if eq $bt "i32"}} + if (offset + 4 > len) return Error("buffer too short"); + result.{{camelcase .Name}}[i] = static_cast(data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24)); + offset += 4; +{{- end}} + } +{{- else}} + return Error("variable-length output arrays are not supported"); +{{- end}} +{{- else if eq .Type "u8"}} + if (offset >= len) return Error("buffer too short"); + result.{{camelcase .Name}} = data[offset++]; +{{- else if eq .Type "i8"}} + if (offset >= len) return Error("buffer too short"); + result.{{camelcase .Name}} = static_cast(data[offset++]); +{{- else if eq .Type "u16"}} + if (offset + 2 > len) return Error("buffer too short"); + result.{{camelcase .Name}} = data[offset] | (static_cast(data[offset + 1]) << 8); + offset += 2; +{{- else if eq .Type "i16"}} + if (offset + 2 > len) return Error("buffer too short"); + result.{{camelcase .Name}} = static_cast(data[offset] | (static_cast(data[offset + 1]) << 8)); + offset += 2; +{{- else if eq .Type "u32"}} + if (offset + 4 > len) return Error("buffer too short"); + result.{{camelcase .Name}} = data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); + offset += 4; +{{- else if eq .Type "i32"}} + if (offset + 4 > len) return Error("buffer too short"); + result.{{camelcase .Name}} = static_cast(data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24)); + offset += 4; +{{- end}} +{{- end}} + (void)offset; // suppress unused warning + return result; + } +}; +{{end}} + +} // namespace {{camelcase .DeviceName}} +} // namespace viiper +` + +func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating device header", "device", deviceName) + outputFile := filepath.Join(devicesDir, deviceName+".hpp") + + devicePkg, ok := md.DevicePackages[deviceName] + if !ok { + return fmt.Errorf("device package %s not found in metadata", deviceName) + } + + hasInput := md.WireTags != nil && md.WireTags.HasDirection(deviceName, "c2s") + hasOutput := md.WireTags != nil && md.WireTags.HasDirection(deviceName, "s2c") + + funcs := tplFuncs(md) + funcs["isLast"] = func(i int, entries []common.MapEntry) bool { + return i == len(entries)-1 + } + + tmpl := template.Must(template.New("device").Funcs(funcs).Parse(deviceHeaderTemplate)) + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create device header: %w", err) + } + defer f.Close() //nolint:errcheck + + hasMaps := false + for _, m := range devicePkg.Maps { + // Include byte-key maps (like CharToKey, ShiftChars) and string-value maps + if isByteKeyMap(m.KeyType) || m.ValueType == "string" { + hasMaps = true + break + } + } + + // Calculate OUTPUT_SIZE from s2c wire tag + outputSize := 0 + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(deviceName, "s2c"); s2cTag != nil { + outputSize = common.CalculateOutputSize(s2cTag) + } + } + + hasFixedWireArrays := false + if md.WireTags != nil { + if c2sTag := md.WireTags.GetTag(deviceName, "c2s"); c2sTag != nil { + for _, f := range c2sTag.Fields { + if idx := strings.Index(f.Type, "*"); idx >= 0 { + if _, err := strconv.Atoi(f.Type[idx+1:]); err == nil { + hasFixedWireArrays = true + break + } + } + } + } + if !hasFixedWireArrays { + if s2cTag := md.WireTags.GetTag(deviceName, "s2c"); s2cTag != nil { + for _, f := range s2cTag.Fields { + if idx := strings.Index(f.Type, "*"); idx >= 0 { + if _, err := strconv.Atoi(f.Type[idx+1:]); err == nil { + hasFixedWireArrays = true + break + } + } + } + } + } + } + + constants := make([]scanner.ConstantInfo, 0, len(devicePkg.Constants)) + for _, c := range devicePkg.Constants { + if !common.IsIntegerConst(c.Value, c.Type) { + continue + } + constants = append(constants, c) + } + + data := struct { + Header string + DeviceName string + Constants []scanner.ConstantInfo + DeviceStructs []scanner.DTOSchema + Maps []scanner.MapInfo + HasInput bool + HasOutput bool + HasMaps bool + HasDeviceSpecific bool + HasFixedWireArrays bool + OutputSize int + }{ + Header: writeFileHeader(), + DeviceName: deviceName, + Constants: constants, + DeviceStructs: md.DeviceStructs[deviceName], + Maps: devicePkg.Maps, + HasInput: hasInput, + HasOutput: hasOutput, + HasMaps: hasMaps, + HasDeviceSpecific: len(md.DeviceStructs[deviceName]) > 0, + HasFixedWireArrays: hasFixedWireArrays, + OutputSize: outputSize, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute device template: %w", err) + } + + logger.Info("Generated device header", "device", deviceName, "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/error.go b/internal/codegen/generator/cpp/error.go new file mode 100644 index 00000000..5efd38b6 --- /dev/null +++ b/internal/codegen/generator/cpp/error.go @@ -0,0 +1,117 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const errorTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include +#include +#include + +namespace viiper { + +// ============================================================================ +// Error (simplified, generic like Rust client library) +// ============================================================================ + +struct Error { + std::string message; + + Error() = default; + explicit Error(std::string msg) : message(std::move(msg)) {} + + [[nodiscard]] bool ok() const noexcept { return message.empty(); } + [[nodiscard]] explicit operator bool() const noexcept { return !ok(); } + [[nodiscard]] const std::string& to_string() const noexcept { return message; } +}; + +// ============================================================================ +// Result Type (Either value or error) +// ============================================================================ + +template +class Result { +public: + Result(T value) : data_(std::move(value)) {} + Result(Error error) : data_(std::move(error)) {} + + [[nodiscard]] bool ok() const noexcept { return std::holds_alternative(data_); } + [[nodiscard]] bool is_error() const noexcept { return std::holds_alternative(data_); } + [[nodiscard]] explicit operator bool() const noexcept { return ok(); } + + [[nodiscard]] T& value() & { return std::get(data_); } + [[nodiscard]] const T& value() const& { return std::get(data_); } + [[nodiscard]] T&& value() && { return std::get(std::move(data_)); } + + [[nodiscard]] Error& error() & { return std::get(data_); } + [[nodiscard]] const Error& error() const& { return std::get(data_); } + [[nodiscard]] Error&& error() && { return std::get(std::move(data_)); } + + [[nodiscard]] T value_or(T default_value) const& { + return ok() ? value() : std::move(default_value); + } + + [[nodiscard]] T* operator->() { return ok() ? &std::get(data_) : nullptr; } + [[nodiscard]] const T* operator->() const { return ok() ? &std::get(data_) : nullptr; } + [[nodiscard]] T& operator*() & { return value(); } + [[nodiscard]] const T& operator*() const& { return value(); } + +private: + std::variant data_; +}; + +template<> +class Result { +public: + Result() = default; + Result(Error error) : error_(std::move(error)) {} + + [[nodiscard]] bool ok() const noexcept { return !error_.has_value(); } + [[nodiscard]] bool is_error() const noexcept { return error_.has_value(); } + [[nodiscard]] explicit operator bool() const noexcept { return ok(); } + + [[nodiscard]] Error& error() & { return *error_; } + [[nodiscard]] const Error& error() const& { return *error_; } + +private: + std::optional error_; +}; + +// ============================================================================ +// RFC 7807 Problem JSON +// ============================================================================ + +struct ProblemJson { + int status = 0; + std::string title; + std::string detail; + + [[nodiscard]] bool is_error() const noexcept { return status >= 400; } + [[nodiscard]] std::string to_string() const { + return std::to_string(status) + " " + title + (detail.empty() ? "" : ": " + detail); + } + [[nodiscard]] Error to_error() const { return Error(to_string()); } +}; + +} // namespace viiper +` + +func generateError(logger *slog.Logger, includeDir string) error { + logger.Debug("Generating error.hpp") + outputFile := filepath.Join(includeDir, "error.hpp") + + if err := os.WriteFile(outputFile, []byte(errorTemplate), 0644); err != nil { + return fmt.Errorf("write error.hpp: %w", err) + } + + logger.Info("Generated error.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/gen.go b/internal/codegen/generator/cpp/gen.go new file mode 100644 index 00000000..b7305141 --- /dev/null +++ b/internal/codegen/generator/cpp/gen.go @@ -0,0 +1,83 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { + version, err := common.GetVersion() + if err != nil { + return fmt.Errorf("get version: %w", err) + } + major, minor, patch := common.ParseVersion(version) + logger.Info("Using version", "version", version, "major", major, "minor", minor, "patch", patch) + + includeDir := filepath.Join(outputDir, "include", "viiper") + detailDir := filepath.Join(includeDir, "detail") + devicesDir := filepath.Join(includeDir, "devices") + + for _, dir := range []string{includeDir, detailDir, devicesDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + } + + if err := generateConfig(logger, includeDir); err != nil { + return err + } + + if err := generateError(logger, includeDir); err != nil { + return err + } + + if err := generateTypes(logger, includeDir, md); err != nil { + return err + } + + if err := generateSocket(logger, detailDir); err != nil { + return err + } + + if err := generateJSON(logger, detailDir); err != nil { + return err + } + + if err := generateAuthHeader(logger, detailDir); err != nil { + return err + } + + if err := generateClient(logger, includeDir, md); err != nil { + return err + } + + if err := generateDevice(logger, includeDir, md); err != nil { + return err + } + + for deviceName := range md.DevicePackages { + if err := generateDeviceHeader(logger, devicesDir, deviceName, md); err != nil { + return err + } + } + + if err := generateMainHeader(logger, includeDir, md, major, minor, patch); err != nil { + return err + } + + if err := common.GenerateLicense(logger, outputDir); err != nil { + return err + } + + if err := common.GenerateReadme(logger, outputDir); err != nil { + return err + } + + logger.Info("Generated C++ client library", "dir", outputDir) + return nil +} diff --git a/internal/codegen/generator/cpp/helpers.go b/internal/codegen/generator/cpp/helpers.go new file mode 100644 index 00000000..e7da2e34 --- /dev/null +++ b/internal/codegen/generator/cpp/helpers.go @@ -0,0 +1,259 @@ +package cpp + +import ( + "fmt" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func tplFuncs(md *meta.Metadata) template.FuncMap { + return template.FuncMap{ + "pascalcase": common.ToTypeName, + "camelcase": common.ToCamelCase, + "snakecase": common.ToSnakeCase, + "toScreamingSnakeCase": func(s string) string { return strings.ToUpper(common.ToSnakeCase(s)) }, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "cpptype": cppType, + "fieldcpptype": fieldCppType, + "unwrapOptional": func(t string) string { return strings.TrimSuffix(strings.TrimPrefix(t, "std::optional<"), ">") }, + "sliceElementType": func(t string) string { + return strings.TrimSuffix(strings.TrimPrefix(t, "std::vector<"), ">") + }, + "isCustomType": isCustomType, + "hasWireTag": func(device, dir string) bool { return common.HasWireTag(md, device, dir) }, + "wireFields": func(device, dir string) []scanner.WireField { return common.GetWireFields(md, device, dir) }, + "isArrayType": func(t string) bool { return strings.Contains(t, "*") }, + "isFixedArrayType": func(t string) bool { + idx := strings.Index(t, "*") + if idx < 0 { + return false + } + _, err := strconv.Atoi(t[idx+1:]) + return err == nil + }, + "fixedArrayLen": func(t string) int { + idx := strings.Index(t, "*") + if idx < 0 { + return 0 + } + n, err := strconv.Atoi(t[idx+1:]) + if err != nil { + return 0 + } + return n + }, + "baseType": func(t string) string { return strings.Split(t, "*")[0] }, + "arrayCountField": func(t string) string { + parts := strings.Split(t, "*") + if len(parts) == 2 { + return parts[1] + } + return "" + }, + "isCountField": func(fields []scanner.WireField, fieldName string) bool { + for _, f := range fields { + if strings.Contains(f.Type, "*") { + parts := strings.Split(f.Type, "*") + if len(parts) == 2 && parts[1] == fieldName { + return true + } + } + } + return false + }, + "pathParams": common.ExtractPathParams, + "pathParamType": pathParamType, + "formatPathParamValue": formatPathParamValue, + "payloadCppType": func(pi scanner.PayloadInfo) string { return payloadCppType(pi) }, + "responseCppType": func(name string) string { return common.ToTypeName(name) }, + "isByteKeyMap": isByteKeyMap, + "hasCharLiteralKeys": hasCharLiteralKeys, + "isNumericMapVal": func(vt string) bool { return vt != "string" && vt != "bool" }, + "sortedEntries": common.SortedMapEntries, + "formatKey": formatKey, + "formatValue": formatValue, + } +} + +func fieldCppType(field scanner.FieldInfo) string { + t := cppType(field.Type) + if field.Optional { + if !strings.HasPrefix(t, "std::optional<") { + t = "std::optional<" + t + ">" + } + } + return t +} + +func cppType(goType string) string { + base, isSlice, isPointer := common.NormalizeGoType(goType) + if base == "time.Time" { + if isSlice { + return "std::vector" + } + if isPointer { + return "std::optional" + } + return "std::string" + } + if strings.HasPrefix(base, "map[") { + return "json_type" + } + + cppBase := goBaseToCpp(base) + if isSlice { + return "std::vector<" + cppBase + ">" + } + if isPointer { + return "std::optional<" + cppBase + ">" + } + return cppBase +} + +func goBaseToCpp(base string) string { + switch base { + case "u8", "uint8", "byte": + return "std::uint8_t" + case "i8", "int8": + return "std::int8_t" + case "u16", "uint16": + return "std::uint16_t" + case "i16", "int16": + return "std::int16_t" + case "u32", "uint32": + return "std::uint32_t" + case "i32", "int32", "int": + return "std::int32_t" + case "u64", "uint64": + return "std::uint64_t" + case "i64", "int64": + return "std::int64_t" + case "float32": + return "float" + case "float64": + return "double" + case "bool": + return "bool" + case "string": + return "std::string" + default: + return common.ToTypeName(base) + } +} + +func writeFileHeader() string { + return common.FileHeader("//", "C++") +} + +func pathParamType(paramName string) string { + lower := strings.ToLower(paramName) + if strings.Contains(lower, "deviceid") || strings.Contains(lower, "devid") { + return "const std::string&" + } + return "std::uint32_t" +} + +func formatPathParamValue(paramName string) string { + lower := strings.ToLower(paramName) + if strings.Contains(lower, "deviceid") || strings.Contains(lower, "devid") { + return paramName + } + return "std::to_string(" + paramName + ")" +} + +func payloadCppType(pi scanner.PayloadInfo) string { + switch pi.Kind { + case scanner.PayloadJSON: + if pi.RawType != "" { + return "const " + common.ToTypeName(pi.RawType) + "&" + } + return "const std::string&" + case scanner.PayloadNumeric: + if pi.Required { + return "std::uint32_t" + } + return "std::optional" + case scanner.PayloadString: + return "const std::string&" + default: + return "" + } +} + +func isByteKeyMap(keyType string) bool { + switch keyType { + case "byte", "uint8", "int8", "rune", "char": + return true + default: + return false + } +} + +func hasCharLiteralKeys(entries map[string]interface{}) bool { + for key := range entries { + if len(key) == 1 || (len(key) == 2 && key[0] == '\\') { + return true + } + } + return false +} + +func isCustomType(goType string) bool { + base, _, _ := common.NormalizeGoType(goType) + if strings.HasPrefix(base, "map[") { + return false + } + switch base { + case "uint8", "byte", "uint16", "uint32", "uint64", + "int8", "int16", "int32", "int64", "int", + "float32", "float64", "bool", "string", + "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "time.Time": + return false + default: + return true + } +} + +func formatKey(key string, isByteKey bool) string { + if isByteKey && (len(key) == 1 || (len(key) == 2 && key[0] == '\\')) { + if len(key) == 2 && key[0] == '\\' { + switch key[1] { + case 'n': + return "static_cast(0x0A)" + case 'r': + return "static_cast(0x0D)" + case 't': + return "static_cast(0x09)" + case '\\': + return "static_cast(0x5C)" + case '\'': + return "static_cast(0x27)" + } + } + return fmt.Sprintf("static_cast(0x%02X)", key[0]) + } + return key +} + +func formatValue(value interface{}) string { + switch v := value.(type) { + case string: + if len(v) > 0 && v[0] >= 'A' && v[0] <= 'Z' { + return v + } + return v + case bool: + if v { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/internal/codegen/generator/cpp/json.go b/internal/codegen/generator/cpp/json.go new file mode 100644 index 00000000..11a7c04f --- /dev/null +++ b/internal/codegen/generator/cpp/json.go @@ -0,0 +1,116 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const jsonTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include "../config.hpp" +#include "../error.hpp" +#include +#include +#include +#include + +namespace viiper { +namespace detail { + +// ============================================================================ +// JSON parsing helpers +// ============================================================================ + +inline ProblemJson parse_problem_json(const json_type& j) { + ProblemJson problem; + if (j.contains("status") && j["status"].is_number()) { + problem.status = j["status"].template get(); + } + if (j.contains("title") && j["title"].is_string()) { + problem.title = j["title"].template get(); + } + if (j.contains("detail") && j["detail"].is_string()) { + problem.detail = j["detail"].template get(); + } + return problem; +} + +inline bool is_problem_json(const json_type& j) { + return j.is_object() && j.contains("status") && j["status"].is_number() && + j.contains("title") && j["title"].is_string(); +} + +inline Result parse_json_response(const std::string& response) { + if (response.empty()) { + return Error("empty response"); + } + + try { + auto j = json_type::parse(response); + if (is_problem_json(j)) { + auto problem = parse_problem_json(j); + if (problem.is_error()) { + return problem.to_error(); + } + } + return j; + } catch (const std::exception& e) { + return Error(std::string("parse error: ") + e.what()); + } +} + +template +inline std::optional get_optional_field(const json_type& j, const std::string& key) { + if (j.contains(key) && !j[key].is_null()) { + return j[key].template get(); + } + return std::nullopt; +} + +template +struct has_from_json : std::false_type {}; + +template +struct has_from_json()))>> : std::true_type {}; + +template +inline std::vector get_array(const json_type& j, const std::string& key) { + if (!j.contains(key) || !j[key].is_array()) { + return {}; + } + + std::vector result; + const auto& arr = j[key]; + result.reserve(arr.size()); + + for (const auto& item : arr) { + if constexpr (has_from_json::value) { + result.push_back(T::from_json(item)); + } else { + result.push_back(item.template get()); + } + } + + return result; +} + +} // namespace detail +} // namespace viiper +` + +func generateJSON(logger *slog.Logger, detailDir string) error { + logger.Debug("Generating detail/json.hpp") + outputFile := filepath.Join(detailDir, "json.hpp") + + if err := os.WriteFile(outputFile, []byte(jsonTemplate), 0644); err != nil { + return fmt.Errorf("write json.hpp: %w", err) + } + + logger.Info("Generated json.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/main_header.go b/internal/codegen/generator/cpp/main_header.go new file mode 100644 index 00000000..4ff8c6f9 --- /dev/null +++ b/internal/codegen/generator/cpp/main_header.go @@ -0,0 +1,101 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const mainHeaderTemplate = `{{.Header}} +#pragma once + +// ============================================================================ +// VIIPER C++ Client Library - Header-Only Library +// ============================================================================ +// +// Version: {{.Major}}.{{.Minor}}.{{.Patch}} +// +// Before including this file, define your JSON library configuration: +// #define VIIPER_JSON_INCLUDE +// #define VIIPER_JSON_NAMESPACE your_namespace +// #define VIIPER_JSON_TYPE your_json_type +// +// See config.hpp for JSON library requirements. +// +// Example with nlohmann::json: +// #define VIIPER_JSON_INCLUDE +// #define VIIPER_JSON_NAMESPACE nlohmann +// #define VIIPER_JSON_TYPE json +// #include +// +// ============================================================================ + +#include "config.hpp" +#include "error.hpp" +#include "types.hpp" +#include "client.hpp" +#include "device.hpp" + +// Device-specific headers +{{range .Devices}} +#include "devices/{{.}}.hpp" +{{- end}} + +namespace viiper { + +// Version information +constexpr int VERSION_MAJOR = {{.Major}}; +constexpr int VERSION_MINOR = {{.Minor}}; +constexpr int VERSION_PATCH = {{.Patch}}; + +inline std::string version() { + return std::to_string(VERSION_MAJOR) + "." + + std::to_string(VERSION_MINOR) + "." + + std::to_string(VERSION_PATCH); +} + +} // namespace viiper +` + +func generateMainHeader(logger *slog.Logger, includeDir string, md *meta.Metadata, major, minor, patch int) error { + logger.Debug("Generating viiper.hpp") + outputFile := filepath.Join(includeDir, "viiper.hpp") + + tmpl := template.Must(template.New("main").Funcs(tplFuncs(md)).Parse(mainHeaderTemplate)) + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create viiper.hpp: %w", err) + } + defer f.Close() //nolint:errcheck + + devices := make([]string, 0, len(md.DevicePackages)) + for deviceName := range md.DevicePackages { + devices = append(devices, deviceName) + } + + data := struct { + Header string + Major int + Minor int + Patch int + Devices []string + }{ + Header: writeFileHeader(), + Major: major, + Minor: minor, + Patch: patch, + Devices: devices, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute main header template: %w", err) + } + + logger.Info("Generated viiper.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/socket.go b/internal/codegen/generator/cpp/socket.go new file mode 100644 index 00000000..35aa035d --- /dev/null +++ b/internal/codegen/generator/cpp/socket.go @@ -0,0 +1,372 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const socketTemplate = `// Auto-generated VIIPER C++ Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include "../error.hpp" +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#pragma comment(lib, "Ws2_32.lib") +#else +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace viiper { +namespace detail { + +// ============================================================================ +// Windows Socket Initialization +// ============================================================================ + +#ifdef _WIN32 +class WsaInitializer { +public: + static Result ensure_initialized() { + static WsaInitializer instance; + return instance.init_result_; + } + +private: + WsaInitializer() { + WSADATA wsa_data; + if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) { + init_result_ = Error("WSAStartup failed"); + } + } + + ~WsaInitializer() { + if (init_result_.ok()) { + WSACleanup(); + } + } + + WsaInitializer(const WsaInitializer&) = delete; + WsaInitializer& operator=(const WsaInitializer&) = delete; + + Result init_result_; +}; +#endif + +// ============================================================================ +// Cross-platform socket wrapper (thread-safe) +// ============================================================================ + +class Socket { +public: + Socket() = default; + ~Socket() { close(); } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + + Socket(Socket&& other) noexcept { + std::scoped_lock lock(other.send_mutex_, other.recv_mutex_); + fd_ = other.fd_; + timeout_ms_ = other.timeout_ms_; + other.fd_ = invalid_socket(); + } + + Socket& operator=(Socket&& other) noexcept { + if (this != &other) { + std::scoped_lock lock(send_mutex_, recv_mutex_, other.send_mutex_, other.recv_mutex_); + close_internal(); + fd_ = other.fd_; + timeout_ms_ = other.timeout_ms_; + other.fd_ = invalid_socket(); + } + return *this; + } + + /// Set socket timeout for send/recv operations. Pass 0 to disable timeout. + Result set_timeout(std::chrono::milliseconds timeout) { + std::scoped_lock lock(send_mutex_, recv_mutex_); + timeout_ms_ = static_cast(timeout.count()); + + if (!is_valid_internal()) { + return Result(); // Will apply on next connect + } + + return apply_timeout_internal(); + } + + Result connect(const std::string& host, std::uint16_t port) { + std::scoped_lock lock(send_mutex_, recv_mutex_); + +#ifdef _WIN32 + auto init_result = WsaInitializer::ensure_initialized(); + if (init_result.is_error()) return init_result.error(); +#endif + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + + addrinfo* result = nullptr; + const std::string port_str = std::to_string(port); + + if (::getaddrinfo(host.c_str(), port_str.c_str(), &hints, &result) != 0) { + return Error("failed to resolve host: " + host); + } + + std::unique_ptr result_guard(result, ::freeaddrinfo); + + for (addrinfo* ptr = result; ptr != nullptr; ptr = ptr->ai_next) { + auto sock = ::socket(ptr->ai_family, ptr->ai_socktype, ptr->ai_protocol); + if (sock == invalid_socket()) continue; + + if (::connect(sock, ptr->ai_addr, static_cast(ptr->ai_addrlen)) == 0) { + fd_ = sock; + + int flag = 1; + ::setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&flag), sizeof(flag)); + + if (timeout_ms_ > 0) { + auto timeout_result = apply_timeout_internal(); + if (timeout_result.is_error()) { + close_internal(); + return timeout_result.error(); + } + } + return Result(); + } + + close_socket(sock); + } + + return Error("connection failed: " + host + ":" + port_str); + } + + Result send(const void* data, std::size_t size) { + std::lock_guard lock(send_mutex_); + + if (!is_valid_internal()) { + return Error("socket not connected"); + } + + std::size_t sent = 0; + const auto* ptr = static_cast(data); + + while (sent < size) { + auto result = ::send(fd_, ptr + sent, static_cast(size - sent), 0); + if (result <= 0) { +#ifdef _WIN32 + int err = WSAGetLastError(); + if (err == WSAETIMEDOUT) return Error("send timeout"); +#else + if (errno == EAGAIN || errno == EWOULDBLOCK) return Error("send timeout"); +#endif + return Error("send failed"); + } + sent += static_cast(result); + } + + return Result(); + } + + Result send(const std::string& str) { + return send(str.data(), str.size()); + } + + Result recv(void* buffer, std::size_t size) { + std::lock_guard lock(recv_mutex_); + + if (!is_valid_internal()) { + return Error("socket not connected"); + } + + auto result = ::recv(fd_, static_cast(buffer), static_cast(size), 0); + if (result < 0) { +#ifdef _WIN32 + int err = WSAGetLastError(); + if (err == WSAETIMEDOUT) return Error("receive timeout"); +#else + if (errno == EAGAIN || errno == EWOULDBLOCK) return Error("receive timeout"); +#endif + return Error("receive failed"); + } + return static_cast(result); + } + + Result recv_exact(void* buffer, std::size_t size) { + std::lock_guard lock(recv_mutex_); + + if (!is_valid_internal()) { + return Error("socket not connected"); + } + + std::size_t received = 0; + auto* ptr = static_cast(buffer); + + while (received < size) { + auto result = ::recv(fd_, ptr + received, static_cast(size - received), 0); + if (result < 0) { +#ifdef _WIN32 + int err = WSAGetLastError(); + if (err == WSAETIMEDOUT) return Error("receive timeout"); +#else + if (errno == EAGAIN || errno == EWOULDBLOCK) return Error("receive timeout"); +#endif + return Error("receive failed"); + } + if (result == 0) { + return Error("connection closed"); + } + received += static_cast(result); + } + + return Result(); + } + + Result recv_line() { + std::lock_guard lock(recv_mutex_); + + if (!is_valid_internal()) { + return Error("socket not connected"); + } + + std::string line; + char ch; + + while (true) { + auto result = ::recv(fd_, &ch, 1, 0); + if (result < 0) { +#ifdef _WIN32 + int err = WSAGetLastError(); + if (err == WSAETIMEDOUT) return Error("receive timeout"); +#else + if (errno == EAGAIN || errno == EWOULDBLOCK) return Error("receive timeout"); +#endif + return Error("receive failed"); + } + if (result == 0) { + break; + } + if (ch == '\0' || ch == '\n') { + break; + } + line += ch; + } + + return line; + } + + void close() { + std::scoped_lock lock(send_mutex_, recv_mutex_); + close_internal(); + } + + /// Force close the socket without locking. Use with caution - only safe + /// when you need to interrupt blocking operations from another thread. + void force_close() noexcept { + if (fd_ != invalid_socket()) { + close_socket(fd_); + fd_ = invalid_socket(); + } + } + + [[nodiscard]] bool is_valid() const noexcept { + // Just check fd_ without locking - atomic read on most platforms + return fd_ != invalid_socket(); + } + +private: + void close_internal() { + if (is_valid_internal()) { + close_socket(fd_); + fd_ = invalid_socket(); + } + } + + [[nodiscard]] bool is_valid_internal() const noexcept { + return fd_ != invalid_socket(); + } + + Result apply_timeout_internal() { +#ifdef _WIN32 + DWORD tv = static_cast(timeout_ms_); + if (setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&tv), sizeof(tv)) < 0) { + return Error("failed to set receive timeout"); + } + if (setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&tv), sizeof(tv)) < 0) { + return Error("failed to set send timeout"); + } +#else + struct timeval tv; + tv.tv_sec = timeout_ms_ / 1000; + tv.tv_usec = (timeout_ms_ % 1000) * 1000; + if (setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + return Error("failed to set receive timeout"); + } + if (setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + return Error("failed to set send timeout"); + } +#endif + return Result(); + } + +#ifdef _WIN32 + using socket_t = SOCKET; + static constexpr socket_t invalid_socket() { return INVALID_SOCKET; } + + static void close_socket(socket_t sock) { + ::closesocket(sock); + } +#else + using socket_t = int; + static constexpr socket_t invalid_socket() { return -1; } + + static void close_socket(socket_t sock) { + ::close(sock); + } +#endif + + socket_t fd_ = invalid_socket(); + int timeout_ms_ = 0; + mutable std::mutex send_mutex_; + mutable std::mutex recv_mutex_; +}; + +} // namespace detail +} // namespace viiper +` + +func generateSocket(logger *slog.Logger, detailDir string) error { + logger.Debug("Generating detail/socket.hpp") + outputFile := filepath.Join(detailDir, "socket.hpp") + + if err := os.WriteFile(outputFile, []byte(socketTemplate), 0644); err != nil { + return fmt.Errorf("write socket.hpp: %w", err) + } + + logger.Info("Generated socket.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/cpp/types.go b/internal/codegen/generator/cpp/types.go new file mode 100644 index 00000000..ccbdfaf3 --- /dev/null +++ b/internal/codegen/generator/cpp/types.go @@ -0,0 +1,157 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const typesTemplate = `{{.Header}} +#pragma once + +#include "config.hpp" +#include "detail/json.hpp" +#include +#include +#include +#include + +namespace viiper { + +{{range .DTOs}} +struct {{pascalcase .Name}}; +{{end}} + +// ============================================================================ +// Management API DTOs +// ============================================================================ + +{{range .DTOs}} +// {{.Name}} +struct {{pascalcase .Name}} { +{{- range .Fields}} + {{fieldcpptype .}} {{camelcase .Name}}; +{{- end}} + + static {{pascalcase .Name}} from_json(const json_type& j) { + {{pascalcase .Name}} result; +{{- range .Fields}} +{{- if and .Optional (eq .TypeKind "map")}} + if (j.contains("{{.JSONName}}") && !j["{{.JSONName}}"].is_null()) { + result.{{camelcase .Name}} = j["{{.JSONName}}"]; + } else { + result.{{camelcase .Name}} = std::nullopt; + } +{{- else if and .Optional (eq .TypeKind "slice")}} + if (j.contains("{{.JSONName}}") && !j["{{.JSONName}}"].is_null()) { + result.{{camelcase .Name}} = detail::get_array<{{cpptype .Type | sliceElementType}}>(j, "{{.JSONName}}"); + } else { + result.{{camelcase .Name}} = std::nullopt; + } +{{- else if and .Optional (isCustomType .Type)}} + if (j.contains("{{.JSONName}}") && !j["{{.JSONName}}"].is_null()) { + result.{{camelcase .Name}} = {{fieldcpptype . | unwrapOptional}}::from_json(j["{{.JSONName}}"]); + } else { + result.{{camelcase .Name}} = std::nullopt; + } +{{- else if .Optional}} + result.{{camelcase .Name}} = detail::get_optional_field<{{fieldcpptype . | unwrapOptional}}>(j, "{{.JSONName}}"); +{{- else if eq .TypeKind "slice"}} + result.{{camelcase .Name}} = detail::get_array<{{cpptype .Type | sliceElementType}}>(j, "{{.JSONName}}"); +{{- else if eq .TypeKind "map"}} + if (j.contains("{{.JSONName}}")) { + result.{{camelcase .Name}} = j["{{.JSONName}}"]; + } +{{- else if isCustomType .Type}} + if (j.contains("{{.JSONName}}")) { + result.{{camelcase .Name}} = {{cpptype .Type}}::from_json(j["{{.JSONName}}"]); + } +{{- else}} + result.{{camelcase .Name}} = j.value("{{.JSONName}}", {{cpptype .Type}}{}); +{{- end}} +{{- end}} + return result; + } + + [[nodiscard]] json_type to_json() const { + json_type j; +{{- range .Fields}} +{{- if and .Optional (eq .TypeKind "slice")}} + if ({{camelcase .Name}}.has_value()) { + json_type arr = json_type::array(); + for (const auto& item : {{camelcase .Name}}.value()) { + {{- if isCustomType .Type}} + arr.push_back(item.to_json()); + {{- else}} + arr.push_back(item); + {{- end}} + } + j["{{.JSONName}}"] = std::move(arr); + } +{{- else if .Optional}} + if ({{camelcase .Name}}.has_value()) { + {{- if isCustomType .Type}} + j["{{.JSONName}}"] = {{camelcase .Name}}.value().to_json(); + {{- else}} + j["{{.JSONName}}"] = {{camelcase .Name}}.value(); + {{- end}} + } +{{- else if eq .TypeKind "slice"}} + { + json_type arr = json_type::array(); + for (const auto& item : {{camelcase .Name}}) { + {{- if isCustomType .Type}} + arr.push_back(item.to_json()); + {{- else}} + arr.push_back(item); + {{- end}} + } + j["{{.JSONName}}"] = std::move(arr); + } +{{- else}} + j["{{.JSONName}}"] = {{camelcase .Name}}; +{{- end}} +{{- end}} + return j; + } +}; + +{{end}} + +} // namespace viiper +` + +func generateTypes(logger *slog.Logger, includeDir string, md *meta.Metadata) error { + logger.Debug("Generating types.hpp") + outputFile := filepath.Join(includeDir, "types.hpp") + + funcs := tplFuncs(md) + + tmpl := template.Must(template.New("types").Funcs(funcs).Parse(typesTemplate)) + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create types.hpp: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Header string + DTOs []scanner.DTOSchema + }{ + Header: writeFileHeader(), + DTOs: md.DTOs, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute types template: %w", err) + } + + logger.Info("Generated types.hpp", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/csharp/auth.go b/internal/codegen/generator/csharp/auth.go new file mode 100644 index 00000000..2c37f7a1 --- /dev/null +++ b/internal/codegen/generator/csharp/auth.go @@ -0,0 +1,351 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const authHelperTemplate = `// Auto-generated VIIPER C# Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +using System; +using System.IO; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Viiper.Client; + +/// +/// Authentication and encryption utilities for VIIPER client +/// +internal static class ViiperAuth +{ + private const string HandshakeMagic = "eVI1\0"; + private const int NonceSize = 32; + private const string AuthContext = "VIIPER-Auth-v1"; + private const string SessionContext = "VIIPER-Session-v1"; + private const int PBKDF2Iterations = 100000; + private const string PBKDF2Salt = "VIIPER-Key-v1"; + + /// + /// Derive a 32-byte key from password using PBKDF2-SHA256 + /// + public static byte[] DeriveKey(string password) + { + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException("Password cannot be empty", nameof(password)); + } + + using var pbkdf2 = new Rfc2898DeriveBytes( + password, + Encoding.UTF8.GetBytes(PBKDF2Salt), + PBKDF2Iterations, + HashAlgorithmName.SHA256 + ); + return pbkdf2.GetBytes(32); + } + + /// + /// Derive session key from key and nonces using SHA-256 + /// + public static byte[] DeriveSessionKey(byte[] key, byte[] serverNonce, byte[] clientNonce) + { + using var sha256 = SHA256.Create(); + var combined = new byte[key.Length + serverNonce.Length + clientNonce.Length + SessionContext.Length]; + var offset = 0; + + Buffer.BlockCopy(key, 0, combined, offset, key.Length); + offset += key.Length; + + Buffer.BlockCopy(serverNonce, 0, combined, offset, serverNonce.Length); + offset += serverNonce.Length; + + Buffer.BlockCopy(clientNonce, 0, combined, offset, clientNonce.Length); + offset += clientNonce.Length; + + var contextBytes = Encoding.UTF8.GetBytes(SessionContext); + Buffer.BlockCopy(contextBytes, 0, combined, offset, contextBytes.Length); + + return sha256.ComputeHash(combined); + } + + /// + /// Perform authentication handshake with VIIPER server + /// Returns a stream (potentially wrapped with encryption) if handshake succeeds + /// + public static async Task PerformHandshakeAsync( + Stream stream, + string password, + CancellationToken cancellationToken = default) + { + var key = DeriveKey(password); + + var clientNonce = new byte[NonceSize]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(clientNonce); + } + + byte[] authTag; + using (var hmac = new HMACSHA256(key)) + { + var authData = new byte[AuthContext.Length + clientNonce.Length]; + var authContextBytes = Encoding.UTF8.GetBytes(AuthContext); + Buffer.BlockCopy(authContextBytes, 0, authData, 0, authContextBytes.Length); + Buffer.BlockCopy(clientNonce, 0, authData, authContextBytes.Length, clientNonce.Length); + authTag = hmac.ComputeHash(authData); + } + + var handshakeMsg = new byte[HandshakeMagic.Length + clientNonce.Length + authTag.Length]; + var magicBytes = Encoding.UTF8.GetBytes(HandshakeMagic); + Buffer.BlockCopy(magicBytes, 0, handshakeMsg, 0, magicBytes.Length); + Buffer.BlockCopy(clientNonce, 0, handshakeMsg, magicBytes.Length, clientNonce.Length); + Buffer.BlockCopy(authTag, 0, handshakeMsg, magicBytes.Length + clientNonce.Length, authTag.Length); + + await stream.WriteAsync(handshakeMsg, 0, handshakeMsg.Length, cancellationToken); + + var response = new byte[3 + NonceSize]; + await ReadExactlyAsync(stream, response, cancellationToken); + + var prefix = Encoding.UTF8.GetString(response, 0, 3); + if (prefix != "OK\0") + { + var errorBuilder = new StringBuilder(); + errorBuilder.Append(Encoding.UTF8.GetString(response)); + + var buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + errorBuilder.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead)); + } + + var errorResponse = errorBuilder.ToString().Trim(); + try + { + var error = JsonSerializer.Deserialize(errorResponse); + if (error != null && error.RootElement.TryGetProperty("title", out var title)) + { + throw new InvalidOperationException($"Authentication failed: {title.GetString()}"); + } + } + catch (JsonException) + { + // Not JSON, throw raw response + } + throw new InvalidOperationException($"Invalid handshake response: {errorResponse}"); + } + + var serverNonce = new byte[NonceSize]; + Buffer.BlockCopy(response, 3, serverNonce, 0, NonceSize); + + var sessionKey = DeriveSessionKey(key, serverNonce, clientNonce); + + return new EncryptedStream(stream, sessionKey); + } + + /// + /// Read exactly the specified number of bytes from stream + /// + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + int totalRead = 0; + while (totalRead < buffer.Length) + { + int bytesRead = await stream.ReadAsync( + buffer, + totalRead, + buffer.Length - totalRead, + cancellationToken + ); + + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed before receiving full response"); + } + + totalRead += bytesRead; + } + } +} + +/// +/// Encrypted stream wrapper using ChaCha20-Poly1305 +/// Requires .NET 5+ for ChaCha20Poly1305 support +/// +internal class EncryptedStream : Stream +{ + private readonly Stream _innerStream; + private readonly byte[] _sessionKey; + private ulong _sendCounter = 0; + private byte[] _recvBuffer = Array.Empty(); + private int _recvBufferPos = 0; + + public EncryptedStream(Stream innerStream, byte[] sessionKey) + { + _innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream)); + _sessionKey = sessionKey ?? throw new ArgumentNullException(nameof(sessionKey)); + + if (sessionKey.Length != 32) + { + throw new ArgumentException("Session key must be 32 bytes", nameof(sessionKey)); + } + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + WriteAsync(buffer, offset, count).GetAwaiter().GetResult(); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var nonce = new byte[12]; + var counterBytes = BitConverter.GetBytes(_sendCounter); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(counterBytes); // Convert to big-endian + } + Buffer.BlockCopy(counterBytes, 0, nonce, 4, 8); + _sendCounter++; + + // Encrypt with ChaCha20-Poly1305 + var plaintext = new byte[count]; + Buffer.BlockCopy(buffer, offset, plaintext, 0, count); + + var ciphertext = new byte[count]; + var tag = new byte[16]; + + using var chacha = new ChaCha20Poly1305(_sessionKey); + chacha.Encrypt(nonce, plaintext, ciphertext, tag); + + var packet = new byte[nonce.Length + ciphertext.Length + tag.Length]; + Buffer.BlockCopy(nonce, 0, packet, 0, nonce.Length); + Buffer.BlockCopy(ciphertext, 0, packet, nonce.Length, ciphertext.Length); + Buffer.BlockCopy(tag, 0, packet, nonce.Length + ciphertext.Length, tag.Length); + + var lengthBytes = BitConverter.GetBytes((uint)packet.Length); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(lengthBytes); // Convert to big-endian + } + + await _innerStream.WriteAsync(lengthBytes, 0, 4, cancellationToken); + await _innerStream.WriteAsync(packet, 0, packet.Length, cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer, offset, count).GetAwaiter().GetResult(); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (_recvBufferPos >= _recvBuffer.Length) + { + var lengthBytes = new byte[4]; + try + { + await ReadExactlyAsync(_innerStream, lengthBytes, cancellationToken); + } + catch (EndOfStreamException) + { + return 0; + } + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(lengthBytes); + } + var packetLength = BitConverter.ToUInt32(lengthBytes, 0); + + if (packetLength > 2 * 1024 * 1024) // 2MB max + { + throw new InvalidDataException($"Packet too large: {packetLength}"); + } + + var packet = new byte[packetLength]; + await ReadExactlyAsync(_innerStream, packet, cancellationToken); + + var nonce = new byte[12]; + Buffer.BlockCopy(packet, 0, nonce, 0, 12); + + var ciphertextLength = packet.Length - 12 - 16; + var ciphertext = new byte[ciphertextLength]; + Buffer.BlockCopy(packet, 12, ciphertext, 0, ciphertextLength); + + var tag = new byte[16]; + Buffer.BlockCopy(packet, 12 + ciphertextLength, tag, 0, 16); + + var plaintext = new byte[ciphertextLength]; + using var chacha = new ChaCha20Poly1305(_sessionKey); + chacha.Decrypt(nonce, ciphertext, tag, plaintext); + + _recvBuffer = plaintext; + _recvBufferPos = 0; + } + + var bytesToCopy = Math.Min(count, _recvBuffer.Length - _recvBufferPos); + Buffer.BlockCopy(_recvBuffer, _recvBufferPos, buffer, offset, bytesToCopy); + _recvBufferPos += bytesToCopy; + + return bytesToCopy; + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + int totalRead = 0; + while (totalRead < buffer.Length) + { + int bytesRead = await stream.ReadAsync(buffer, totalRead, buffer.Length - totalRead, cancellationToken); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed unexpectedly"); + } + totalRead += bytesRead; + } + } + + public override void Flush() => _innerStream.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _innerStream.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _innerStream?.Dispose(); + } + base.Dispose(disposing); + } +} +` + +func generateAuthHelper(logger *slog.Logger, projectDir string) error { + logger.Debug("Generating ViiperAuth.cs") + outputFile := filepath.Join(projectDir, "ViiperAuth.cs") + + if err := os.WriteFile(outputFile, []byte(authHelperTemplate), 0644); err != nil { + return fmt.Errorf("write ViiperAuth.cs: %w", err) + } + + logger.Info("Generated ViiperAuth.cs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/csharp/client.go b/internal/codegen/generator/csharp/client.go new file mode 100644 index 00000000..63eca613 --- /dev/null +++ b/internal/codegen/generator/csharp/client.go @@ -0,0 +1,238 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const clientTemplate = `{{writeFileHeader}}using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Viiper.Client.Types; + +namespace Viiper.Client; + +/// +/// VIIPER management API client for bus and device control +/// +public class ViiperClient : IDisposable +{ + private readonly string _host; + private readonly int _port; + private readonly string _password; + private bool _disposed; + + /// + /// Creates a new VIIPER client instance + /// + /// VIIPER server hostname or IP address + /// VIIPER API server port (default: 3242) + /// Authentication password (default: "" = no auth). Empty string explicitly means no authentication. + public ViiperClient(string host, int port = 3242, string password = "") + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + _password = password ?? ""; + } +{{range .Routes}}{{if eq .Method "Register"}} + /// + /// {{.Handler}}: {{.Path}} + /// {{if .ResponseDTO}} + /// {{.ResponseDTO}}{{end}} + public async Task<{{if .ResponseDTO}}{{.ResponseDTO}}{{else}}bool{{end}}> {{.Handler}}Async({{generateMethodParams .}}CancellationToken cancellationToken = default) + { + var path = "{{.Path}}"{{range $key, $value := .PathParams}}.Replace("{{lb}}{{$key}}{{rb}}", {{toCamelCase $key}}.ToString()){{end}}; + {{/* Build payload based on classification */}} + {{if eq .Payload.Kind "none"}}string? payload = null;{{else if eq .Payload.Kind "json"}}string? payload = JsonSerializer.Serialize({{payloadParamNameCS .}});{{else if eq .Payload.Kind "numeric"}}{{if .Payload.Required}}string? payload = {{payloadParamNameCS .}}.ToString();{{else}}string? payload = {{payloadParamNameCS .}}?.ToString();{{end}}{{else if eq .Payload.Kind "string"}}string? payload = {{payloadParamNameCS .}};{{end}} + {{if .ResponseDTO}}return await SendRequestAsync<{{.ResponseDTO}}>(path, payload, cancellationToken);{{else}}await SendRequestAsync(path, payload, cancellationToken); + return true;{{end}} + } +{{end}}{{end}} + private async Task SendRequestAsync(string path, string? payload, CancellationToken cancellationToken) + { + using var client = new TcpClient(); + await client.ConnectAsync(_host, _port, cancellationToken); + client.NoDelay = true; + + Stream stream = client.GetStream(); + + if (!string.IsNullOrEmpty(_password)) + { + stream = await ViiperAuth.PerformHandshakeAsync(stream, _password, cancellationToken); + } + + string commandLine = path.ToLowerInvariant(); + if (!string.IsNullOrEmpty(payload)) + { + commandLine += " " + payload; + } + commandLine += "\0"; + + var requestBytes = Encoding.UTF8.GetBytes(commandLine); + await stream.WriteAsync(requestBytes, cancellationToken); + + var responseBuilder = new StringBuilder(); + var buffer = new byte[128]; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken)) > 0) + { + responseBuilder.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead)); + } + + var responseJson = responseBuilder.ToString().TrimEnd('\n'); + // Typed error detection (RFC 7807 style): check for status field prefix + if (responseJson.StartsWith("{\"status\":")) + { + throw new InvalidOperationException($"VIIPER error response: {responseJson}"); + } + var response = JsonSerializer.Deserialize(responseJson) + ?? throw new InvalidOperationException("Failed to deserialize response"); + + return response; + } + + /// + /// Creates a device stream connection for sending input and receiving output + /// + /// Bus ID + /// Device ID + /// Cancellation token + /// ViiperDevice stream wrapper + public async Task ConnectDeviceAsync(uint busId, string devId, CancellationToken cancellationToken = default) + { + var client = new TcpClient(); + await client.ConnectAsync(_host, _port, cancellationToken); + client.NoDelay = true; + Stream stream = client.GetStream(); + + if (!string.IsNullOrEmpty(_password)) + { + stream = await ViiperAuth.PerformHandshakeAsync(stream, _password, cancellationToken); + } + + // Streaming handshake uses null terminator (same framing as management). + var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\0"; + var handshake = Encoding.UTF8.GetBytes(streamPath); + await stream.WriteAsync(handshake, cancellationToken); + return new ViiperDevice(client, stream); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + GC.SuppressFinalize(this); + } +} +` + +func generateClient(logger *slog.Logger, projectDir string, md *meta.Metadata) error { + logger.Debug("Generating ViiperClient management API") + + outputFile := filepath.Join(projectDir, "ViiperClient.cs") + + funcMap := template.FuncMap{ + "toCamelCase": toCamelCase, + "writeFileHeader": writeFileHeader, + "generateMethodParams": generateMethodParams, + "payloadParamNameCS": payloadParamNameCS, + "lb": func() string { return "{" }, + "rb": func() string { return "}" }, + } + + tmpl, err := template.New("client").Funcs(funcMap).Parse(clientTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Routes []scanner.RouteInfo + }{ + Routes: md.Routes, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated ViiperClient", "file", outputFile) + return nil +} + +func generateMethodParams(route scanner.RouteInfo) string { + var params []string + for key := range route.PathParams { + params = append(params, fmt.Sprintf("uint %s", toCamelCase(key))) + } + switch route.Payload.Kind { + case scanner.PayloadJSON: + name := payloadParamNameCS(route) + typeName := route.Payload.RawType + if typeName == "" { + typeName = "object" + } + params = append(params, fmt.Sprintf("%s %s", typeName, name)) + case scanner.PayloadNumeric: + name := payloadParamNameCS(route) + typeName := "uint" + if strings.HasPrefix(route.Payload.RawType, "int") && !strings.HasPrefix(route.Payload.RawType, "uint") { + typeName = "int" + } else if strings.HasPrefix(route.Payload.RawType, "uint") { + typeName = "uint" + } + if !route.Payload.Required { + typeName += "?" + } + params = append(params, fmt.Sprintf("%s %s", typeName, name)) + case scanner.PayloadString: + name := payloadParamNameCS(route) + typeName := "string" + if !route.Payload.Required { + typeName += "?" + } + params = append(params, fmt.Sprintf("%s %s", typeName, name)) + } + if len(params) == 0 { + return "" + } + return strings.Join(params, ", ") + ", " +} + +func payloadParamNameCS(route scanner.RouteInfo) string { + if route.Payload.Kind == scanner.PayloadNone { + return "" + } + hint := route.Payload.ParserHint + if hint == "" { + return "payload" + } + switch route.Payload.Kind { + case scanner.PayloadNumeric: + if strings.Contains(strings.ToLower(hint), "id") || strings.HasPrefix(hint, "uint") || strings.HasPrefix(hint, "int") { + return "id" + } + return "value" + case scanner.PayloadJSON: + if route.Payload.RawType != "" { + return toCamelCase(route.Payload.RawType) + } + return "request" + case scanner.PayloadString: + return "value" + } + return "payload" +} diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go new file mode 100644 index 00000000..4d03134d --- /dev/null +++ b/internal/codegen/generator/csharp/constants.go @@ -0,0 +1,603 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating constants", "device", deviceName) + + deviceConsts, ok := md.DevicePackages[deviceName] + if !ok || deviceConsts == nil { + logger.Warn("No constants or maps found for device", "device", deviceName) + return nil + } + + hasOutputSize := false + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(deviceName, "s2c"); s2cTag != nil { + if common.CalculateOutputSize(s2cTag) > 0 { + hasOutputSize = true + } + } + } + + if len(deviceConsts.Constants) == 0 && len(deviceConsts.Maps) == 0 && !hasOutputSize { + logger.Warn("No constants or maps found for device", "device", deviceName) + return nil + } + + pascalDevice := toPascalCase(deviceName) + outputPath := filepath.Join(deviceDir, pascalDevice+"Constants.cs") + + enumGroups := groupConstantsByPrefix(deviceConsts.Constants) + maps := convertMapsToCSharp(deviceConsts.Maps) + + // Calculate OUTPUT_SIZE if device has s2c wire tag + outputSize := 0 + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(deviceName, "s2c"); s2cTag != nil { + outputSize = common.CalculateOutputSize(s2cTag) + } + } + + data := struct { + Device string + OutputSize int + EnumGroups []enumGroup + Scalars []scalarConstant + Maps []mapData + }{ + Device: pascalDevice, + OutputSize: outputSize, + Scalars: extractScalarConstants(deviceConsts.Constants), + Maps: maps, + } + + for _, eg := range enumGroups { + if shouldGenerateEnum(eg) { + data.EnumGroups = append(data.EnumGroups, eg) + } + } + + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("creating file: %w", err) + } + defer f.Close() //nolint:errcheck + + tmpl := template.Must(template.New("constants").Funcs(template.FuncMap{ + "nullableType": func(typeName string) string { + if typeName == "string" { + return "string?" + } + return typeName + }, + }).Parse(constantsTemplate)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("executing template: %w", err) + } + + logger.Info("Generated constants", "device", deviceName, "path", outputPath) + return nil +} + +type enumGroup struct { + Name string // Enum name (e.g., "ModifierKeys", "Buttons") + IsFlags bool // Whether to use [Flags] attribute + Type string // Underlying type (byte, ushort, uint) + Constants []constantInfo // Enum members +} + +type constantInfo struct { + Name string + Value string + Type string +} + +type scalarConstant struct { + Name string + Value string + Type string +} + +type mapData struct { + Name string + KeyType string + ValueType string + Entries []mapEntry +} + +type mapEntry struct { + Key string + Value string +} + +func groupConstantsByPrefix(constants []scanner.ConstantInfo) []enumGroup { + groups := make(map[string]*enumGroup) + + for _, c := range constants { + if !common.IsIntegerConst(c.Value, c.Type) { + continue + } + + prefix := common.ExtractPrefix(c.Name) + if prefix == "" { + continue + } + + if _, exists := groups[prefix]; !exists { + groups[prefix] = &enumGroup{ + Name: prefix, + Constants: []constantInfo{}, + } + } + + _, name := common.TrimPrefixAndSanitize(c.Name) + groups[prefix].Constants = append(groups[prefix].Constants, constantInfo{ + Name: name, + Value: formatConstValue(c.Value, c.Type), + Type: mapGoConstTypeToCSharp(c.Type), + }) + } + + result := make([]enumGroup, 0, len(groups)) + for _, g := range groups { + g.Type = inferEnumType(g.Constants) + g.IsFlags = isFlags(g.Constants) + result = append(result, *g) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + + return result +} + +func extractScalarConstants(constants []scanner.ConstantInfo) []scalarConstant { + result := make([]scalarConstant, 0) + for _, c := range constants { + if common.IsIntegerConst(c.Value, c.Type) { + continue + } + result = append(result, scalarConstant{ + Name: c.Name, + Value: formatConstValue(c.Value, c.Type), + Type: mapGoConstTypeToCSharp(c.Type), + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +func shouldGenerateEnum(eg enumGroup) bool { + return len(eg.Constants) >= 3 +} + +func inferEnumType(constants []constantInfo) string { + minI := int64(0) + maxI := int64(0) + parsedAny := false + + for _, c := range constants { + if strings.HasPrefix(c.Value, "0x") { + var u uint64 + if n, err := fmt.Sscanf(c.Value, "0x%x", &u); err == nil && n == 1 { + v := int64(u) + if !parsedAny { + minI, maxI = v, v + parsedAny = true + continue + } + if v < minI { + minI = v + } + if v > maxI { + maxI = v + } + } + continue + } + + var s int64 + if n, err := fmt.Sscanf(c.Value, "%d", &s); err == nil && n == 1 { + if !parsedAny { + minI, maxI = s, s + parsedAny = true + continue + } + if s < minI { + minI = s + } + if s > maxI { + maxI = s + } + } + } + + if parsedAny { + if minI < 0 { + switch { + case minI >= -128 && maxI <= 127: + return "sbyte" + case minI >= -32768 && maxI <= 32767: + return "short" + case minI >= -2147483648 && maxI <= 2147483647: + return "int" + default: + return "long" + } + } + + uMax := uint64(maxI) + switch { + case uMax <= 0xFF: + return "byte" + case uMax <= 0xFFFF: + return "ushort" + case uMax <= 0xFFFFFFFF: + return "uint" + default: + return "ulong" + } + } + + best := "uint" + for _, c := range constants { + switch c.Type { + case "ulong": + return "ulong" + case "uint": + best = "uint" + case "ushort": + if best != "uint" { + best = "ushort" + } + case "byte": + if best != "uint" && best != "ushort" { + best = "byte" + } + } + } + return best +} + +func isFlags(constants []constantInfo) bool { + for _, c := range constants { + if strings.HasPrefix(c.Value, "0x") { + var val uint64 + _, err := fmt.Sscanf(c.Value, "0x%x", &val) + if err != nil { + slog.Error("Failed to parse constant value for flags inference", "value", c.Value, "error", err) + continue + } + if val > 0 && (val&(val-1)) == 0 { + return true + } + if val > 0 { + return true + } + } + } + return false +} + +func formatConstValue(value interface{}, goType string) string { + base, _, _ := common.NormalizeGoType(goType) + + switch v := value.(type) { + case int64: + if v < 0 { + return fmt.Sprintf("%d", v) + } + return fmt.Sprintf("0x%X", v) + case uint64: + return fmt.Sprintf("0x%X", v) + case int: + if v < 0 { + return fmt.Sprintf("%d", v) + } + return fmt.Sprintf("0x%X", v) + case float64: + if base == "float32" { + return fmt.Sprintf("%ff", v) + } + return fmt.Sprintf("%f", v) + case string: + if base == "string" { + return fmt.Sprintf("\"%s\"", v) + } + if base == "char" { + if len(v) == 1 { + return fmt.Sprintf("'%s'", v) + } + return fmt.Sprintf("'%s'", v) + } + + if _, err := strconv.ParseInt(v, 0, 64); err == nil { + return v + } + if _, err := strconv.ParseUint(v, 0, 64); err == nil { + return v + } + if _, err := strconv.ParseFloat(v, 64); err == nil { + return v + } + + return v + default: + return fmt.Sprintf("%v", v) + } +} + +func mapGoConstTypeToCSharp(goType string) string { + if strings.Contains(goType, ".") { + parts := strings.Split(goType, ".") + return parts[len(parts)-1] + } + + switch goType { + case "int", "int8", "int16", "int32": + return "int" + case "uint8", "byte": + return "byte" + case "uint16": + return "ushort" + case "uint32", "uint": + return "uint" + case "uint64": + return "ulong" + case "float32": + return "float" + case "float64": + return "double" + case "string": + return "string" + case "char": + return "char" + case "bool": + return "bool" + default: + return "int" + } +} + +func inferValueTypeFromEntries(entries map[string]interface{}) string { + if len(entries) == 0 { + return "" + } + + allBools := true + for _, v := range entries { + str, ok := v.(string) + if !ok || (str != "true" && str != "false") { + allBools = false + break + } + } + if allBools { + return "" + } + + var commonPrefix string + firstValue := true + + for _, v := range entries { + str, ok := v.(string) + if !ok || strings.Contains(str, " ") { + return "" + } + + prefix := common.ExtractPrefix(str) + if prefix == "" { + return "" + } + + if firstValue { + commonPrefix = prefix + firstValue = false + } else if prefix != commonPrefix { + return "" + } + } + + return commonPrefix +} + +func convertMapsToCSharp(maps []scanner.MapInfo) []mapData { + result := make([]mapData, 0, len(maps)) + + for _, m := range maps { + csKeyType := mapGoConstTypeToCSharp(m.KeyType) + csValueType := mapGoConstTypeToCSharp(m.ValueType) + + inferredValueType := inferValueTypeFromEntries(m.Entries) + if inferredValueType != "" { + csValueType = inferredValueType + } + + md := mapData{ + Name: m.Name, + KeyType: csKeyType, + ValueType: csValueType, + Entries: make([]mapEntry, 0, len(m.Entries)), + } + + keys := common.SortedStringKeys(m.Entries) + + for _, k := range keys { + v := m.Entries[k] + + keyStr := formatMapKey(k, m.KeyType) + valueStr := formatMapValue(v, m.ValueType) + + md.Entries = append(md.Entries, mapEntry{ + Key: keyStr, + Value: valueStr, + }) + } + + result = append(result, md) + } + + return result +} + +func formatMapKey(key string, goType string) string { + switch goType { + case "byte", "uint8": + if len(key) == 2 && key[0] == '\\' { + switch key[1] { + case 'n': + return "(byte)'\\n'" + case 'r': + return "(byte)'\\r'" + case 't': + return "(byte)'\\t'" + case '\\': + return "(byte)'\\\\'" + case '\'': + return "(byte)'\\''" + } + } + if len(key) == 1 { + if key[0] >= 32 && key[0] <= 126 { + switch key[0] { + case '\'': + return "(byte)'\\''" + case '\\': + return "(byte)'\\\\'" + } + return fmt.Sprintf("(byte)'%s'", key) + } + return fmt.Sprintf("(byte)0x%02X", key[0]) + } + if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { + if pfx := common.ExtractPrefix(key); pfx != "" { + _, member := common.TrimPrefixAndSanitize(key) + return fmt.Sprintf("(byte)%s.%s", pfx, member) + } + } + return key + case "string": + return fmt.Sprintf("\"%s\"", key) + default: + return key + } +} + +func formatMapValue(value interface{}, goType string) string { + switch goType { + case "byte", "uint8": + if str, ok := value.(string); ok && !strings.Contains(str, " ") { + if pfx := common.ExtractPrefix(str); pfx != "" { + _, member := common.TrimPrefixAndSanitize(str) + return fmt.Sprintf("%s.%s", pfx, member) + } + return str + } + return formatConstValue(value, goType) + case "bool": + if b, ok := value.(bool); ok { + if b { + return "true" + } + return "false" + } + if str, ok := value.(string); ok { + return str + } + return "false" + case "string": + if str, ok := value.(string); ok { + return fmt.Sprintf("\"%s\"", str) + } + return formatConstValue(value, goType) + default: + return formatConstValue(value, goType) + } +} + +const constantsTemplate = `using System; +using System.Collections.Generic; + +namespace Viiper.Client.Devices.{{.Device}}; + +{{if or (gt .OutputSize 0) (gt (len .Scalars) 0)}} +/// +/// Size in bytes of {{.Device}} output (server-to-client) messages. +/// Use this constant to allocate buffers for reading device output. +/// +public static class {{.Device}} +{ +{{if gt .OutputSize 0}} + public const int OutputSize = {{.OutputSize}}; +{{end}} +{{range .Scalars}} public const {{.Type}} {{.Name}} = {{.Value}}; +{{end}} +} + +{{end}} +{{range .EnumGroups}} +/// +/// {{.Name}} constants for {{$.Device}} device. +/// +{{if .IsFlags}}[Flags] +{{end}}public enum {{.Name}} : {{.Type}} +{ +{{range .Constants}} {{.Name}} = {{.Value}}, +{{end}}} + +{{end}} +{{range .Maps}} +/// +/// {{.Name}} lookup map for {{$.Device}} device. +/// +public static class {{.Name}} +{ + private static readonly Dictionary<{{.KeyType}}, {{.ValueType}}> _map = new() + { +{{range .Entries}} { {{.Key}}, {{.Value}} }, +{{end}} }; + + /// + /// Try to get the value for the given key. + /// + public static bool TryGetValue({{.KeyType}} key, out {{.ValueType}} value) + { + return _map.TryGetValue(key, out value); + } + + /// + /// Get the value for the given key, or return the default value if not found. + /// + public static {{nullableType .ValueType}} GetValueOrDefault({{.KeyType}} key, {{nullableType .ValueType}} defaultValue = default) + { + return _map.TryGetValue(key, out var value) ? value : defaultValue; + } + + /// + /// Check if the map contains the given key. + /// + public static bool ContainsKey({{.KeyType}} key) + { + return _map.ContainsKey(key); + } +} + +{{end}} +` diff --git a/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go new file mode 100644 index 00000000..97efb383 --- /dev/null +++ b/internal/codegen/generator/csharp/device.go @@ -0,0 +1,168 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const deviceTemplate = `{{writeFileHeader}}using System.IO; +using System.Net.Sockets; +using System.Threading.Channels; + +namespace Viiper.Client; + +/// +/// Interface for binary serializable input payloads sent to a VIIPER device stream. +/// +public interface IBinarySerializable +{ + void Write(BinaryWriter writer); +} + +/// +/// Represents a live device stream connection allowing input sending and receiving raw output bytes. +/// +public sealed class ViiperDevice : IAsyncDisposable, IDisposable +{ + private readonly TcpClient _client; + private readonly Stream _stream; + private readonly CancellationTokenSource _cts = new(); + private Task? _readLoop; + private bool _disposed; + private Func? _onOutput; + private Action? _onDisconnect; + + /// + /// Callback invoked when output data is available from the device. + /// The callback receives the stream and must read the exact number of bytes expected. + /// + public Func? OnOutput + { + get => _onOutput; + set + { + _onOutput = value; + if (_onOutput != null && _readLoop == null) + { + _readLoop = Task.Run(ReadLoopAsync); + } + } + } + + public Action? OnDisconnect + { + get => _onDisconnect; + set => _onDisconnect = value; + } + + internal ViiperDevice(TcpClient client, Stream stream) + { + _client = client; + _stream = stream; + } + + /// + /// Send a binary-serializable input payload to the device. + /// + public async Task SendAsync(T payload, CancellationToken cancellationToken = default) where T : IBinarySerializable + { + ThrowIfDisposed(); + using var ms = new MemoryStream(); + using (var bw = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + { + payload.Write(bw); + } + var buf = ms.ToArray(); + await _stream.WriteAsync(buf, 0, buf.Length, cancellationToken); + } + + /// + /// Send raw bytes to the device (advanced usage). + /// + public async Task SendRawAsync(byte[] data, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + await _stream.WriteAsync(data, 0, data.Length, cancellationToken); + } + + private async Task ReadLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested && _onOutput != null) + { + await _onOutput(_stream).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // normal during shutdown + } + catch (Exception) + { + // swallow; user can detect via absence of further events + } + _onDisconnect?.Invoke(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(ViiperDevice)); + } + + /// + /// Dispose synchronously. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cts.Cancel(); + try { _readLoop?.Wait(); } catch { } + _stream.Dispose(); + _client.Dispose(); + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Dispose asynchronously awaiting read loop completion. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _cts.Cancel(); + if (_readLoop != null) + try { await _readLoop.ConfigureAwait(false); } catch { } + _stream.Dispose(); + _client.Dispose(); + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} +` + +func generateDevice(logger *slog.Logger, projectDir string, md *meta.Metadata) error { + logger.Debug("Generating ViiperDevice stream wrapper") + outputFile := filepath.Join(projectDir, "ViiperDevice.cs") + tmpl := template.Must(template.New("device").Funcs(template.FuncMap{ + "writeFileHeader": writeFileHeader, + }).Parse(deviceTemplate)) + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create ViiperDevice.cs: %w", err) + } + defer f.Close() //nolint:errcheck + if err := tmpl.Execute(f, md); err != nil { + return fmt.Errorf("execute device template: %w", err) + } + logger.Info("Generated ViiperDevice", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/csharp/device_specific.go b/internal/codegen/generator/csharp/device_specific.go new file mode 100644 index 00000000..5870f1c5 --- /dev/null +++ b/internal/codegen/generator/csharp/device_specific.go @@ -0,0 +1,146 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +type csDeviceSpecificField struct { + Name string + JSONName string + CSType string + Optional bool +} + +type csDeviceSpecificStruct struct { + Name string + Fields []csDeviceSpecificField +} + +const deviceSpecificTemplate = `{{writeFileHeader}}using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Viiper.Client.Devices.{{.Device}}; + +{{range .Structs}} +public class {{.Name}} +{ +{{- range .Fields}} + [JsonPropertyName("{{.JSONName}}")] + public {{.CSType}}{{if .Optional}}?{{end}} {{.Name}} { get; set; }{{if and (not .Optional) (eq .CSType "string")}} = string.Empty;{{end}} +{{end}} + + public Dictionary ToMap() + { + var json = JsonSerializer.Serialize(this); + return JsonSerializer.Deserialize>(json) ?? new Dictionary(); + } + + public static {{.Name}} FromMap(Dictionary map) + { + var json = JsonSerializer.Serialize(map); + return JsonSerializer.Deserialize<{{.Name}}>(json) ?? new {{.Name}}(); + } +} + +{{end}} +` + +func generateDeviceSpecific(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + structs := md.DeviceStructs[deviceName] + if len(structs) == 0 { + return nil + } + + logger.Debug("Generating C# meta helpers", "device", deviceName) + + pascalDevice := toPascalCase(deviceName) + legacyPath := filepath.Join(deviceDir, pascalDevice+"DeviceSpecific.cs") + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove legacy C# device-specific file: %w", err) + } + outputPath := filepath.Join(deviceDir, pascalDevice+"Meta.cs") + + data := struct { + Device string + Structs []csDeviceSpecificStruct + }{ + Device: pascalDevice, + Structs: make([]csDeviceSpecificStruct, 0, len(structs)), + } + + for _, s := range structs { + entry := csDeviceSpecificStruct{ + Name: s.Name, + Fields: make([]csDeviceSpecificField, 0, len(s.Fields)), + } + for _, f := range s.Fields { + entry.Fields = append(entry.Fields, csDeviceSpecificField{ + Name: f.Name, + JSONName: f.JSONName, + CSType: fieldTypeToCSharpForDeviceSpecific(f), + Optional: f.Optional, + }) + } + data.Structs = append(data.Structs, entry) + } + + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + tmpl := template.Must(template.New("deviceSpecificCS").Funcs(template.FuncMap{ + "writeFileHeader": writeFileHeader, + }).Parse(deviceSpecificTemplate)) + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated C# meta helpers", "device", deviceName, "path", outputPath) + return nil +} + +func fieldTypeToCSharpForDeviceSpecific(field scanner.FieldInfo) string { + typeStr := field.Type + typeKind := field.TypeKind + + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + valueType, ok := parseGoMapType(typeStr) + if !ok { + return "Dictionary" + } + csVal := goTypeToCSharp(valueType) + if valueType == "any" || valueType == "interface{}" { + csVal = "object?" + } + return "Dictionary" + } + + if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elemType := strings.TrimPrefix(typeStr, "[]") + return goTypeToCSharp(elemType) + "[]" + } + + if typeStr == "time.Time" { + return "DateTime" + } + + if typeKind == "struct" { + return common.ToTypeName(typeStr) + } + + return goTypeToCSharp(typeStr) +} diff --git a/internal/codegen/generator/csharp/device_types.go b/internal/codegen/generator/csharp/device_types.go new file mode 100644 index 00000000..7d3aba98 --- /dev/null +++ b/internal/codegen/generator/csharp/device_types.go @@ -0,0 +1,206 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func generateDeviceTypes(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating device types", "device", deviceName) + + if md.WireTags == nil { + logger.Warn("No wire tags metadata available") + return nil + } + + c2sTag := md.WireTags.GetTag(deviceName, "c2s") + s2cTag := md.WireTags.GetTag(deviceName, "s2c") + + if c2sTag == nil && s2cTag == nil { + logger.Warn("No wire tags found for device", "device", deviceName) + return nil + } + + pascalDevice := toPascalCase(deviceName) + + if c2sTag != nil { + inputPath := filepath.Join(deviceDir, pascalDevice+"Input.cs") + if err := generateWireClass(inputPath, pascalDevice, "Input", c2sTag); err != nil { + return fmt.Errorf("generating Input: %w", err) + } + logger.Debug("Generated Input class", "device", deviceName, "path", inputPath) + } + + if s2cTag != nil { + outputPath := filepath.Join(deviceDir, pascalDevice+"Output.cs") + if err := generateWireClass(outputPath, pascalDevice, "Output", s2cTag); err != nil { + return fmt.Errorf("generating Output: %w", err) + } + logger.Debug("Generated Output class", "device", deviceName, "path", outputPath) + } + + logger.Info("Generated device types", "device", deviceName) + return nil +} + +func generateWireClass(outputPath, device, className string, tag *scanner.WireTag) error { + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("creating file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Device string + ClassName string + Fields []wireField + }{ + Device: device, + ClassName: className, + } + + for _, field := range tag.Fields { + wf := wireField{Name: toPascalCase(field.Name)} + if idx := strings.Index(field.Type, "*"); idx >= 0 { + wf.IsArray = true + baseType := field.Type[:idx] + wf.CSType = mapGoTypeToCSharp(baseType) + countToken := field.Type[idx+1:] + if n, err := strconv.Atoi(countToken); err == nil { + wf.FixedLen = n + } else { + wf.CountFieldName = toPascalCase(countToken) + } + } else { + wf.CSType = mapGoTypeToCSharp(field.Type) + } + + data.Fields = append(data.Fields, wf) + } + + funcMap := template.FuncMap{ + "readerMethod": getCSharpReaderMethod, + "toCamel": toCamelCase, + } + + tmpl := template.Must(template.New("wireclass").Funcs(funcMap).Parse(wireClassTemplate)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("executing template: %w", err) + } + + return nil +} + +type wireField struct { + Name string + CSType string + IsArray bool + CountFieldName string + FixedLen int +} + +func mapGoTypeToCSharp(goType string) string { + switch goType { + case "u8": + return "byte" + case "u16": + return "ushort" + case "u32": + return "uint" + case "u64": + return "ulong" + case "i8": + return "sbyte" + case "i16": + return "short" + case "i32": + return "int" + case "i64": + return "long" + default: + return "byte" + } +} + +func getCSharpReaderMethod(csType string) string { + switch csType { + case "byte": + return "Byte" + case "sbyte": + return "SByte" + case "ushort": + return "UInt16" + case "short": + return "Int16" + case "uint": + return "UInt32" + case "int": + return "Int32" + case "ulong": + return "UInt64" + case "long": + return "Int64" + default: + return "Byte" + } +} + +const wireClassTemplate = `using System; +using System.IO; + +namespace Viiper.Client.Devices.{{.Device}}; + +/// +/// Wire protocol {{.ClassName}} message for {{.Device}} device. +/// +public class {{.Device}}{{.ClassName}} : IBinarySerializable +{ +{{range .Fields}}{{if and .IsArray (gt .FixedLen 0)}} public {{.CSType}}[] {{.Name}} { get; set; } = new {{.CSType}}[{{.FixedLen}}]; +{{else}} public required {{.CSType}}{{if .IsArray}}[]{{end}} {{.Name}} { get; set; } +{{end}}{{end}} + public void Write(BinaryWriter writer) + { +{{range .Fields}}{{if .IsArray}}{{if gt .FixedLen 0}} for (int i = 0; i < {{.FixedLen}}; i++) + { + writer.Write(({{.Name}} != null && i < {{.Name}}.Length) ? {{.Name}}[i] : default({{.CSType}})); + } +{{else}} for (int i = 0; i < {{.CountFieldName}}; i++) + { + writer.Write({{.Name}}[i]); + } +{{end}}{{else}} writer.Write({{.Name}}); +{{end}}{{end}} } + + /// + /// Read from binary stream (for receiving output from server). + /// + public static {{.Device}}{{.ClassName}} Read(BinaryReader reader) + { + {{range .Fields}}{{if .IsArray}}{{if gt .FixedLen 0}} var {{toCamel .Name}} = new {{.CSType}}[{{.FixedLen}}]; + for (int i = 0; i < {{.FixedLen}}; i++) + { + {{toCamel .Name}}[i] = reader.Read{{readerMethod .CSType}}(); + } + {{else}} var {{toCamel .Name}} = new {{.CSType}}[{{toCamel .CountFieldName}}]; + for (int i = 0; i < {{toCamel .CountFieldName}}; i++) + { + {{toCamel .Name}}[i] = reader.Read{{readerMethod .CSType}}(); + } + {{end}}{{else}} var {{toCamel .Name}} = reader.Read{{readerMethod .CSType}}(); + {{end}}{{end}} + + return new {{.Device}}{{.ClassName}} + { + {{range .Fields}} {{.Name}} = {{toCamel .Name}}, + {{end}} }; + } +} +` diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go new file mode 100644 index 00000000..32056c8b --- /dev/null +++ b/internal/codegen/generator/csharp/gen.go @@ -0,0 +1,77 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { + projectDir := filepath.Join(outputDir, "Viiper.Client") + typesDir := filepath.Join(projectDir, "Types") + devicesDir := filepath.Join(projectDir, "Devices") + examplesDir := filepath.Join(outputDir, "examples") + + for _, dir := range []string{projectDir, typesDir, devicesDir, examplesDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + } + + version, err := common.GetVersion() + if err != nil { + return fmt.Errorf("get version: %w", err) + } + + if err := generateProject(logger, projectDir, md, version); err != nil { + return err + } + + if err := generateTypes(logger, typesDir, md); err != nil { + return err + } + + if err := generateClient(logger, projectDir, md); err != nil { + return err + } + if err := generateAuthHelper(logger, projectDir); err != nil { + return err + } + if err := generateDevice(logger, projectDir, md); err != nil { + return err + } + + for deviceName := range md.DevicePackages { + deviceDir := filepath.Join(devicesDir, toPascalCase(deviceName)) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + return fmt.Errorf("create device directory %s: %w", deviceDir, err) + } + + if err := generateDeviceTypes(logger, deviceDir, deviceName, md); err != nil { + return err + } + + if err := generateConstants(logger, deviceDir, deviceName, md); err != nil { + return err + } + + if err := generateDeviceSpecific(logger, deviceDir, deviceName, md); err != nil { + return err + } + } + + if err := common.GenerateLicense(logger, outputDir); err != nil { + return err + } + + if err := common.GenerateReadme(logger, outputDir); err != nil { + return err + } + + logger.Info("Generated C# client library", "dir", outputDir) + return nil +} diff --git a/internal/codegen/generator/csharp/helpers.go b/internal/codegen/generator/csharp/helpers.go new file mode 100644 index 00000000..168b2171 --- /dev/null +++ b/internal/codegen/generator/csharp/helpers.go @@ -0,0 +1,70 @@ +package csharp + +import ( + "strings" + + "github.com/Alia5/VIIPER/internal/codegen/common" +) + +func toPascalCase(s string) string { + return common.ToPascalCase(s) +} + +func toCamelCase(s string) string { + return common.ToCamelCase(s) +} + +func goTypeToCSharp(goType string) string { + base, _, _ := common.NormalizeGoType(goType) + if base == "any" || base == "interface{}" { + return "object" + } + + switch base { + case "uint8": + return "byte" + case "uint16": + return "ushort" + case "uint32": + return "uint" + case "uint64": + return "ulong" + case "int8": + return "sbyte" + case "int16": + return "short" + case "int32", "int": + return "int" + case "int64": + return "long" + case "float32": + return "float" + case "float64": + return "double" + case "bool": + return "bool" + case "string": + return "string" + case "byte": + return "byte" + default: + return common.ToTypeName(base) + } +} + +func parseGoMapType(typeStr string) (string, bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", false + } + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false + } + return valueType, true +} + +func writeFileHeader() string { return common.FileHeader("//", "C#") } diff --git a/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go new file mode 100644 index 00000000..a55e52b2 --- /dev/null +++ b/internal/codegen/generator/csharp/project.go @@ -0,0 +1,67 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const projectTemplate = ` + + + net10.0 + Viiper.Client + enable + latest + enable + + Viiper.Client + {{.Version}} + Peter Repukat + VIIPER Client Library for C# + MIT + https://github.com/Alia5/VIIPER + https://github.com/Alia5/VIIPER + git + viiper;usbip;virtual-device;input-emulation;hid + README.md + + + + + + + +` + +func generateProject(logger *slog.Logger, projectDir string, _ *meta.Metadata, version string) error { + logger.Debug("Generating Viiper.Client.csproj") + + tmpl, err := template.New("csproj").Parse(projectTemplate) + if err != nil { + return fmt.Errorf("parse csproj template: %w", err) + } + + f, err := os.Create(filepath.Join(projectDir, "Viiper.Client.csproj")) + if err != nil { + return fmt.Errorf("create csproj file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Version string + }{ + Version: version, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute csproj template: %w", err) + } + + logger.Info("Generated Viiper.Client.csproj", "version", version) + return nil +} diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go new file mode 100644 index 00000000..5d42fabf --- /dev/null +++ b/internal/codegen/generator/csharp/types.go @@ -0,0 +1,100 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const dtoTemplate = `{{writeFileHeader}}using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Viiper.Client.Types; + +{{range .DTOs}} +/// +/// {{.Name}} DTO +/// +public class {{.Name}} +{ +{{- range .Fields}} + [JsonPropertyName("{{.JSONName}}")] + public {{if not .Optional}}required {{end}}{{fieldTypeToCSharp .}}{{if .Optional}}?{{end}} {{.Name}} { get; set; } +{{end}} +} + +{{end}} +` + +func generateTypes(logger *slog.Logger, typesDir string, md *meta.Metadata) error { + logger.Debug("Generating management API DTO types") + + outputFile := filepath.Join(typesDir, "ManagementDtos.cs") + + funcMap := template.FuncMap{ + "toPascalCase": toPascalCase, + "fieldTypeToCSharp": fieldTypeToCSharp, + "writeFileHeader": writeFileHeader, + } + + tmpl, err := template.New("dtos").Funcs(funcMap).Parse(dtoTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + DTOs interface{} + }{ + DTOs: md.DTOs, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated DTO types", "file", outputFile) + return nil +} + +func fieldTypeToCSharp(field interface{}) string { + v := reflect.ValueOf(field) + typeStr := v.FieldByName("Type").String() + typeKind := v.FieldByName("TypeKind").String() + + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + valueType, ok := parseGoMapType(typeStr) + if !ok { + return "Dictionary" + } + csVal := goTypeToCSharp(valueType) + if valueType == "any" || valueType == "interface{}" { + csVal = "object?" + } + return "Dictionary" + } + + if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elemType := strings.TrimPrefix(typeStr, "[]") + csharpElemType := goTypeToCSharp(elemType) + return csharpElemType + "[]" + } + + if typeKind == "struct" { + return common.ToTypeName(typeStr) + } + + return goTypeToCSharp(typeStr) +} diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go new file mode 100644 index 00000000..c7a10dc6 --- /dev/null +++ b/internal/codegen/generator/generator.go @@ -0,0 +1,185 @@ +package generator + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/generator/cpp" + "github.com/Alia5/VIIPER/internal/codegen/generator/csharp" + "github.com/Alia5/VIIPER/internal/codegen/generator/rust" + "github.com/Alia5/VIIPER/internal/codegen/generator/typescript" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +type Generator struct { + outputDir string + logger *slog.Logger +} + +type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Metadata) error + +var generators = map[string]LanguageGenerator{ + "cpp": cpp.Generate, + "csharp": csharp.Generate, + "rust": rust.Generate, + "typescript": typescript.Generate, +} + +func New(outputDir string, logger *slog.Logger) *Generator { + return &Generator{ + outputDir: outputDir, + logger: logger, + } +} + +func (g *Generator) GenAll() error { + for lang := range generators { + if err := g.GenerateLang(lang); err != nil { + return fmt.Errorf("generate %s client library: %w", lang, err) + } + } + return nil +} + +func (g *Generator) GenerateLang(lang string) error { + gen, ok := generators[lang] + if !ok { + var supported []string + for k := range generators { + supported = append(supported, k) + } + return fmt.Errorf("unsupported language '%s' (supported: %v)", lang, supported) + } + + g.logger.Info("Generating client library", "language", lang) + + md, err := g.ScanAll() + if err != nil { + return err + } + + outputPath := filepath.Join(g.outputDir, lang) + if err := os.MkdirAll(outputPath, 0o755); err != nil { + return fmt.Errorf("failed to create %s output directory: %w", lang, err) + } + + if err := gen(g.logger, outputPath, md); err != nil { + return err + } + + g.logger.Info("Client library generation complete", "language", lang, "output", outputPath) + return nil +} + +func (g *Generator) ScanAll() (*meta.Metadata, error) { + requiredPaths := []string{"internal/cmd", "viipertypes", "device"} + for _, path := range requiredPaths { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, fmt.Errorf("codegen requires VIIPER source code and must be run from the viiper module directory: missing '%s'", path) + } + } + + g.logger.Info("Scanning codebase for metadata") + + md := &meta.Metadata{ + DevicePackages: make(map[string]*scanner.DeviceConstants), + DeviceStructs: make(map[string][]scanner.DTOSchema), + CTypeNames: make(map[string]string), + } + + g.logger.Debug("Scanning API routes") + routes, err := scanner.ScanRoutesInPackage("internal/cmd") + if err != nil { + return nil, fmt.Errorf("failed to scan routes: %w", err) + } + md.Routes = routes + g.logger.Info("Found API routes", "count", len(routes)) + + g.logger.Debug("Scanning DTOs") + dtos, err := scanner.ScanDTOsInPackage("viipertypes") + if err != nil { + return nil, fmt.Errorf("failed to scan DTOs: %w", err) + } + md.DTOs = dtos + g.logger.Info("Found DTOs", "count", len(dtos)) + + md.CTypeNames = make(map[string]string) + for _, dto := range dtos { + if dto.Name == "Device" { + md.CTypeNames["Device"] = "device_info" + } + } + + g.logger.Debug("Discovering device packages") + deviceBaseDir := "device" + devicePaths, err := discoverDevicePackagePaths(deviceBaseDir) + if err != nil { + return nil, fmt.Errorf("failed to read device directory: %w", err) + } + + for _, devicePath := range devicePaths { + deviceName := filepath.Base(devicePath) + + g.logger.Debug("Scanning device package", "device", deviceName) + deviceConsts, err := scanner.ScanDeviceConstants(devicePath) + if err != nil { + g.logger.Warn("Failed to scan device package", "device", deviceName, "error", err) + continue + } + + md.DevicePackages[deviceName] = deviceConsts + + deviceStructs, err := scanner.ScanDeviceStructs(devicePath) + if err != nil { + g.logger.Warn("Failed to scan device structs", "device", deviceName, "error", err) + } else { + md.DeviceStructs[deviceName] = deviceStructs + } + + g.logger.Info("Scanned device package", + "device", deviceName, + "constants", len(deviceConsts.Constants), + "maps", len(deviceConsts.Maps), + "json_structs", len(md.DeviceStructs[deviceName])) + } + + g.logger.Debug("Scanning viiper:wire tags") + wireTags, err := scanner.ScanWireTags(devicePaths) + if err != nil { + return nil, fmt.Errorf("failed to scan wire tags: %w", err) + } + md.WireTags = wireTags + g.logger.Info("Scanned wire tags", "devices", len(wireTags.Tags)) + + g.logger.Debug("Enriching routes with handler arg info") + enriched, err := scanner.EnrichRoutesWithHandlerInfo(md.Routes, "internal/server/api/handler") + if err != nil { + return nil, fmt.Errorf("failed to enrich routes: %w", err) + } + md.Routes = enriched + + return md, nil +} + +func discoverDevicePackagePaths(deviceBaseDir string) ([]string, error) { + entries, err := os.ReadDir(deviceBaseDir) + if err != nil { + return nil, err + } + + devicePaths := make([]string, 0, len(entries)) + for _, entry := range entries { + // Go's internal directory contains implementation-only helpers, not a + // virtual device API that client libraries should expose. + if !entry.IsDir() || entry.Name() == "internal" { + continue + } + + devicePaths = append(devicePaths, filepath.Join(deviceBaseDir, entry.Name())) + } + + return devicePaths, nil +} diff --git a/internal/codegen/generator/generator_test.go b/internal/codegen/generator/generator_test.go new file mode 100644 index 00000000..ca08f6af --- /dev/null +++ b/internal/codegen/generator/generator_test.go @@ -0,0 +1,33 @@ +package generator + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDiscoverDevicePackagePathsSkipsInternalPackages(t *testing.T) { + deviceBaseDir := t.TempDir() + for _, name := range []string{"dualsense", "internal", "xbox360"} { + if err := os.Mkdir(filepath.Join(deviceBaseDir, name), 0o755); err != nil { + t.Fatalf("create test directory %q: %v", name, err) + } + } + if err := os.WriteFile(filepath.Join(deviceBaseDir, "report.go"), nil, 0o644); err != nil { + t.Fatalf("create test file: %v", err) + } + + got, err := discoverDevicePackagePaths(deviceBaseDir) + if err != nil { + t.Fatalf("discover device packages: %v", err) + } + + want := []string{ + filepath.Join(deviceBaseDir, "dualsense"), + filepath.Join(deviceBaseDir, "xbox360"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("discovered packages = %v, want %v", got, want) + } +} diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go new file mode 100644 index 00000000..426bb7a7 --- /dev/null +++ b/internal/codegen/generator/rust/async_client.go @@ -0,0 +1,395 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const asyncClientTemplate = `{{.Header}} +use crate::error::{ProblemJson, ViiperError}; +use crate::types::*; +use std::net::SocketAddr; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Stream wrapper that can be either plain or encrypted +#[cfg(feature = "async")] +pub enum AsyncStreamWrapper { + Plain(TcpStream), + Encrypted(crate::auth::AsyncEncryptedStream), +} + +/// Read-half wrapper that can be either plain or encrypted +#[cfg(feature = "async")] +pub enum AsyncReadWrapper { + Plain(tokio::net::tcp::OwnedReadHalf), + Encrypted(crate::auth::AsyncEncryptedRead), +} + +/// Write-half wrapper that can be either plain or encrypted +#[cfg(feature = "async")] +pub enum AsyncWriteWrapper { + Plain(tokio::net::tcp::OwnedWriteHalf), + Encrypted(crate::auth::AsyncEncryptedWrite), +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncStreamWrapper { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncStreamWrapper::Plain(s) => std::pin::Pin::new(s).poll_read(cx, buf), + AsyncStreamWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_read(cx, buf), + } + } +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncReadWrapper { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncReadWrapper::Plain(s) => std::pin::Pin::new(s).poll_read(cx, buf), + AsyncReadWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_read(cx, buf), + } + } +} + +#[cfg(feature = "async")] +impl AsyncWrite for AsyncStreamWrapper { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match &mut *self { + AsyncStreamWrapper::Plain(s) => std::pin::Pin::new(s).poll_write(cx, buf), + AsyncStreamWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncStreamWrapper::Plain(s) => std::pin::Pin::new(s).poll_flush(cx), + AsyncStreamWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncStreamWrapper::Plain(s) => std::pin::Pin::new(s).poll_shutdown(cx), + AsyncStreamWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_shutdown(cx), + } + } +} + +#[cfg(feature = "async")] +impl AsyncWrite for AsyncWriteWrapper { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match &mut *self { + AsyncWriteWrapper::Plain(s) => std::pin::Pin::new(s).poll_write(cx, buf), + AsyncWriteWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncWriteWrapper::Plain(s) => std::pin::Pin::new(s).poll_flush(cx), + AsyncWriteWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match &mut *self { + AsyncWriteWrapper::Plain(s) => std::pin::Pin::new(s).poll_shutdown(cx), + AsyncWriteWrapper::Encrypted(s) => std::pin::Pin::new(s).poll_shutdown(cx), + } + } +} + +/// VIIPER management API client (asynchronous). +#[cfg(feature = "async")] +pub struct AsyncViiperClient { + addr: SocketAddr, + password: Option, +} + +#[cfg(feature = "async")] +impl AsyncViiperClient { + /// Create a new async VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr, password: None } + } + + /// Create a new async VIIPER client with password authentication. + /// Empty password string explicitly means no authentication. + pub fn new_with_password(addr: SocketAddr, password: String) -> Self { + let password = if password.is_empty() { None } else { Some(password) }; + Self { addr, password } + } + + async fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let tcp_stream = TcpStream::connect(self.addr).await?; + tcp_stream.set_nodelay(true)?; + + let mut stream = if let Some(ref pwd) = self.password { + AsyncStreamWrapper::Encrypted(crate::auth::perform_handshake_async(tcp_stream, pwd).await?) + } else { + AsyncStreamWrapper::Plain(tcp_stream) + }; + + stream.write_all(path.as_bytes()).await?; + if let Some(p) = payload { + stream.write_all(b" ").await?; + stream.write_all(p.as_bytes()).await?; + } + stream.write_all(b"\0").await?; + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await?; + + let response = String::from_utf8(buf) + .map_err(|_| ViiperError::UnexpectedResponse("invalid UTF-8".into()))? + .trim_end_matches('\n') + .to_string(); + + if response.starts_with("{\"status\":") { + let problem: ProblemJson = serde_json::from_str(&response)?; + return Err(ViiperError::Protocol(problem)); + } + + serde_json::from_str(&response).map_err(Into::into) + } +{{range .Routes}}{{if eq .Method "Register"}} + /// {{.Handler}}: {{.Path}}{{if .ResponseDTO}} -> {{.ResponseDTO}}{{end}} + pub async fn {{toSnakeCase .Handler}}(&self{{generateMethodParamsRust .}}) -> Result<{{if .ResponseDTO}}{{.ResponseDTO}}{{else}}(){{end}}, ViiperError> { + let path = {{generatePathRust .}}; + {{generatePayloadRust .}} + {{if .ResponseDTO}}self.do_request(&path, payload.as_deref()).await{{else}}self.do_request::(&path, payload.as_deref()).await?; + Ok(()){{end}} + } +{{end}}{{end}} + /// Connect to a device stream for sending input and receiving output. + pub async fn connect_device(&self, bus_id: u32, dev_id: &str) -> Result { + AsyncDeviceStream::connect(self.addr, bus_id, dev_id, self.password.as_deref()).await + } +} + +/// An async connected device stream for bidirectional communication. +#[cfg(feature = "async")] +pub struct AsyncDeviceStream { + read_stream: std::sync::Arc>, + write_stream: std::sync::Arc>, + cancel_token: Option, + disconnect_callback: std::sync::Mutex>>, +} + +#[cfg(feature = "async")] +impl AsyncDeviceStream { + pub async fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str, password: Option<&str>) -> Result { + let tcp_stream = TcpStream::connect(addr).await?; + tcp_stream.set_nodelay(true)?; + + let (read_stream, mut write_stream) = if let Some(pwd) = password { + let encrypted = crate::auth::perform_handshake_async(tcp_stream, pwd).await?; + let (read_half, write_half) = encrypted.into_split(); + (AsyncReadWrapper::Encrypted(read_half), AsyncWriteWrapper::Encrypted(write_half)) + } else { + let (read_half, write_half) = tcp_stream.into_split(); + (AsyncReadWrapper::Plain(read_half), AsyncWriteWrapper::Plain(write_half)) + }; + + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + write_stream.write_all(handshake.as_bytes()).await?; + + Ok(Self { + read_stream: std::sync::Arc::new(tokio::sync::Mutex::new(read_stream)), + write_stream: std::sync::Arc::new(tokio::sync::Mutex::new(write_stream)), + cancel_token: None, + disconnect_callback: std::sync::Mutex::new(None), + }) + } + + /// Send a device input to the device. + pub async fn send( + &self, + input: &T, + ) -> Result<(), ViiperError> { + let bytes = input.to_bytes(); + let mut stream = self.write_stream.lock().await; + stream.write_all(&bytes).await?; + Ok(()) + } + + /// Send a device input to the device with a timeout. + /// + /// # Arguments + /// * ` + "`" + `input` + "`" + ` - The device input to send + /// * ` + "`" + `timeout` + "`" + ` - Timeout duration for the operation + pub async fn send_timeout( + &self, + input: &T, + timeout: std::time::Duration, + ) -> Result<(), ViiperError> { + let bytes = input.to_bytes(); + let mut stream = self.write_stream.lock().await; + tokio::time::timeout(timeout, stream.write_all(&bytes)) + .await + .map_err(|_| ViiperError::Timeout)? + .map_err(Into::into) + } + + /// Register a callback to receive device output asynchronously. + /// The callback receives a shared reference to the read half and must read the exact number of bytes expected. + /// The callback will be invoked repeatedly on a tokio task until it returns an error. + /// Only one callback can be registered at a time. + pub fn on_output(&mut self, callback: F) -> Result<(), ViiperError> + where + F: Fn(std::sync::Arc>) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + if self.cancel_token.is_some() { + return Err(ViiperError::UnexpectedResponse("Output callback already registered".into())); + } + + let stream = self.read_stream.clone(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let cancel_clone = cancel_token.clone(); + let Ok(mut guard) = self.disconnect_callback.lock() else { + return Err(ViiperError::UnexpectedResponse("Disconnect callback mutex poisoned".into())); + }; + let disconnect = guard.take(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel_clone.cancelled() => break, + result = callback(stream.clone()) => { + match result { + Ok(()) => continue, + Err(_) => break, + } + } + } + } + if let Some(cb) = disconnect { + cb(); + } + }); + + self.cancel_token = Some(cancel_token); + Ok(()) + } + + pub fn on_disconnect(&mut self, callback: F) -> Result<(), ViiperError> + where + F: FnOnce() + Send + 'static, + { + let Ok(mut guard) = self.disconnect_callback.lock() else { + return Err(ViiperError::UnexpectedResponse("Disconnect callback mutex poisoned".into())); + }; + *guard = Some(Box::new(callback)); + Ok(()) + } + + /// Send raw bytes to the device. + pub async fn send_raw(&self, data: &[u8]) -> Result<(), ViiperError> { + let mut stream = self.write_stream.lock().await; + stream.write_all(data).await?; + Ok(()) + } + + /// Read raw bytes from the device. + pub async fn read_raw(&self, buf: &mut [u8]) -> Result { + let mut stream = self.read_stream.lock().await; + stream.read(buf).await.map_err(Into::into) + } + + /// Read exact number of bytes from the device. + pub async fn read_exact(&self, buf: &mut [u8]) -> Result<(), ViiperError> { + let mut stream = self.read_stream.lock().await; + stream.read_exact(buf).await?; + Ok(()) + } +} + +#[cfg(feature = "async")] +impl Drop for AsyncDeviceStream { + fn drop(&mut self) { + if let Some(token) = &self.cancel_token { + token.cancel(); + } + } +} +` + +func generateAsyncClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error { + logger.Debug("Generating async_client.rs (async API)") + outputFile := filepath.Join(srcDir, "async_client.rs") + + funcMap := template.FuncMap{ + "toSnakeCase": common.ToSnakeCase, + "generateMethodParamsRust": generateMethodParamsRust, + "generatePathRust": generatePathRust, + "generatePayloadRust": generatePayloadRust, + } + + tmpl, err := template.New("asyncclient").Funcs(funcMap).Parse(asyncClientTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Header string + Routes []scanner.RouteInfo + }{ + Header: writeFileHeaderRust(), + Routes: md.Routes, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated async_client.rs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/auth.go b/internal/codegen/generator/rust/auth.go new file mode 100644 index 00000000..1d4af7fc --- /dev/null +++ b/internal/codegen/generator/rust/auth.go @@ -0,0 +1,533 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const authModuleTemplate = `// This file is auto-generated by VIIPER codegen. DO NOT EDIT. + +use crate::error::ViiperError; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; +use hmac::{Hmac, Mac}; +use pbkdf2::pbkdf2_hmac; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::net::TcpStream; + +#[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; +#[cfg(feature = "async")] +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +#[cfg(feature = "async")] +use tokio::net::TcpStream as AsyncTcpStream; + +const HANDSHAKE_MAGIC: &[u8] = b"eVI1\x00"; +const NONCE_SIZE: usize = 32; +const AUTH_CONTEXT: &[u8] = b"VIIPER-Auth-v1"; +const SESSION_CONTEXT: &[u8] = b"VIIPER-Session-v1"; +const PBKDF2_ITERATIONS: u32 = 100_000; +const PBKDF2_SALT: &[u8] = b"VIIPER-Key-v1"; + +/// Derive a 32-byte key from password using PBKDF2-SHA256 +fn derive_key(password: &str) -> Result<[u8; 32], ViiperError> { + if password.is_empty() { + return Err(ViiperError::UnexpectedResponse("Password cannot be empty".into())); + } + let mut key = [0u8; 32]; + pbkdf2_hmac::(password.as_bytes(), PBKDF2_SALT, PBKDF2_ITERATIONS, &mut key); + Ok(key) +} + +/// Derive session key from key and nonces using SHA-256 +fn derive_session_key(key: &[u8], server_nonce: &[u8], client_nonce: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(key); + hasher.update(server_nonce); + hasher.update(client_nonce); + hasher.update(SESSION_CONTEXT); + hasher.finalize().into() +} + +/// Perform authentication handshake with VIIPER server (synchronous) +pub fn perform_handshake(mut stream: TcpStream, password: &str) -> Result { + let key = derive_key(password)?; + let mut client_nonce = [0u8; NONCE_SIZE]; + rand::thread_rng().fill_bytes(&mut client_nonce); + + let mut mac = as KeyInit>::new_from_slice(&key) + .map_err(|_| ViiperError::UnexpectedResponse("Invalid key length".into()))?; + mac.update(AUTH_CONTEXT); + mac.update(&client_nonce); + let auth_tag = mac.finalize().into_bytes(); + + let mut handshake_msg = Vec::with_capacity(HANDSHAKE_MAGIC.len() + NONCE_SIZE + 32); + handshake_msg.extend_from_slice(HANDSHAKE_MAGIC); + handshake_msg.extend_from_slice(&client_nonce); + handshake_msg.extend_from_slice(&auth_tag); + + stream.write_all(&handshake_msg)?; + + let mut response = vec![0u8; 3 + NONCE_SIZE]; + stream.read_exact(&mut response)?; + + if &response[0..3] != b"OK\x00" { + let mut error_buf = Vec::new(); + let _ = stream.read_to_end(&mut error_buf); + let full_response = [response, error_buf].concat(); + let error_str = String::from_utf8_lossy(&full_response); + + if let Ok(problem) = serde_json::from_str::(&error_str) { + return Err(ViiperError::Protocol(problem)); + } + return Err(ViiperError::UnexpectedResponse(format!("Invalid handshake response: {}", error_str))); + } + + let server_nonce = &response[3..]; + + let session_key = derive_session_key(&key, server_nonce, &client_nonce); + + Ok(EncryptedStream::new(stream, session_key)?) +} + +/// Perform authentication handshake with VIIPER server (asynchronous) +#[cfg(feature = "async")] +pub async fn perform_handshake_async(mut stream: AsyncTcpStream, password: &str) -> Result { + let key = derive_key(password)?; + + let mut client_nonce = [0u8; NONCE_SIZE]; + rand::thread_rng().fill_bytes(&mut client_nonce); + + let mut mac = as KeyInit>::new_from_slice(&key) + .map_err(|_| ViiperError::UnexpectedResponse("Invalid key length".into()))?; + mac.update(AUTH_CONTEXT); + mac.update(&client_nonce); + let auth_tag = mac.finalize().into_bytes(); + + let mut handshake_msg = Vec::with_capacity(HANDSHAKE_MAGIC.len() + NONCE_SIZE + 32); + handshake_msg.extend_from_slice(HANDSHAKE_MAGIC); + handshake_msg.extend_from_slice(&client_nonce); + handshake_msg.extend_from_slice(&auth_tag); + + stream.write_all(&handshake_msg).await?; + + let mut response = vec![0u8; 3 + NONCE_SIZE]; + stream.read_exact(&mut response).await?; + + if &response[0..3] != b"OK\x00" { + let mut error_buf = Vec::new(); + let _ = stream.read_to_end(&mut error_buf).await; + let full_response = [response, error_buf].concat(); + let error_str = String::from_utf8_lossy(&full_response); + + if let Ok(problem) = serde_json::from_str::(&error_str) { + return Err(ViiperError::Protocol(problem)); + } + return Err(ViiperError::UnexpectedResponse(format!("Invalid handshake response: {}", error_str))); + } + + let server_nonce = &response[3..]; + + let session_key = derive_session_key(&key, server_nonce, &client_nonce); + + Ok(AsyncEncryptedStream::new(stream, session_key)) +} + +/// Encrypted stream wrapper using ChaCha20-Poly1305 (synchronous) +/// Read and write paths are independently locked to avoid blocking writes +/// while a read thread is waiting for output. +pub struct EncryptedStream { + read: std::sync::Arc>, + write: std::sync::Arc>, +} + +struct EncryptedReadState { + stream: TcpStream, + cipher: ChaCha20Poly1305, + recv_buffer: Vec, +} + +struct EncryptedWriteState { + stream: TcpStream, + cipher: ChaCha20Poly1305, + send_counter: u64, +} + +impl EncryptedStream { + fn new(inner: TcpStream, session_key: [u8; 32]) -> Result { + let read_stream = inner.try_clone()?; + let read_cipher = ChaCha20Poly1305::new(&session_key.into()); + let write_cipher = ChaCha20Poly1305::new(&session_key.into()); + Ok(Self { + read: std::sync::Arc::new(std::sync::Mutex::new(EncryptedReadState { + stream: read_stream, + cipher: read_cipher, + recv_buffer: Vec::new(), + })), + write: std::sync::Arc::new(std::sync::Mutex::new(EncryptedWriteState { + stream: inner, + cipher: write_cipher, + send_counter: 0, + })), + }) + } + + pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + let read = self.read.lock().unwrap(); + let write = self.write.lock().unwrap(); + read.stream.set_nodelay(nodelay)?; + write.stream.set_nodelay(nodelay) + } + + pub fn try_clone(&self) -> std::io::Result { + Ok(Self { + read: std::sync::Arc::clone(&self.read), + write: std::sync::Arc::clone(&self.write), + }) + } + + pub fn shutdown(&self, how: std::net::Shutdown) -> std::io::Result<()> { + let read = self.read.lock().unwrap(); + let write = self.write.lock().unwrap(); + let _ = read.stream.shutdown(how); + write.stream.shutdown(how) + } +} + +impl Read for EncryptedStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut inner = self.read.lock().unwrap(); + + if inner.recv_buffer.is_empty() { + let mut first_byte = [0u8; 1]; + let n = inner.stream.read(&mut first_byte)?; + if n == 0 { + return Ok(0); + } + + let mut len_buf = [0u8; 4]; + len_buf[0] = first_byte[0]; + inner.stream.read_exact(&mut len_buf[1..])?; + let packet_len = u32::from_be_bytes(len_buf) as usize; + + if packet_len > 2 * 1024 * 1024 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Packet too large")); + } + + let mut packet = vec![0u8; packet_len]; + inner.stream.read_exact(&mut packet)?; + + let nonce = Nonce::from_slice(&packet[0..12]); + let ciphertext_and_tag = &packet[12..]; + + let plaintext = inner.cipher.decrypt(nonce, ciphertext_and_tag) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "Decryption failed"))?; + + inner.recv_buffer = plaintext; + } + + let to_copy = buf.len().min(inner.recv_buffer.len()); + buf[..to_copy].copy_from_slice(&inner.recv_buffer[..to_copy]); + inner.recv_buffer.drain(..to_copy); + Ok(to_copy) + } +} + +impl Write for EncryptedStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut inner = self.write.lock().unwrap(); + + let mut nonce_bytes = [0u8; 12]; + nonce_bytes[4..].copy_from_slice(&inner.send_counter.to_be_bytes()); + inner.send_counter += 1; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = inner.cipher.encrypt(nonce, buf) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::Other, "Encryption failed"))?; + + let packet = [&nonce_bytes[..], ciphertext.as_slice()].concat(); + let len_buf = (packet.len() as u32).to_be_bytes(); + + inner.stream.write_all(&len_buf)?; + inner.stream.write_all(&packet)?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut inner = self.write.lock().unwrap(); + inner.stream.flush() + } +} + +/// Encrypted stream wrapper using ChaCha20-Poly1305 (asynchronous) +#[cfg(feature = "async")] +pub struct AsyncEncryptedStream { + read: AsyncEncryptedRead, + write: AsyncEncryptedWrite, +} + +#[cfg(feature = "async")] +pub struct AsyncEncryptedRead { + inner: tokio::net::tcp::OwnedReadHalf, + cipher: ChaCha20Poly1305, + recv_buffer: Vec, + read_state: ReadState, +} + +#[cfg(feature = "async")] +pub struct AsyncEncryptedWrite { + inner: tokio::net::tcp::OwnedWriteHalf, + cipher: ChaCha20Poly1305, + send_counter: u64, +} + +#[cfg(feature = "async")] +enum ReadState { + ReadingLength { buf: [u8; 4], pos: usize }, + ReadingPacket { expected_len: usize, buf: Vec, pos: usize }, + Ready, +} + +#[cfg(feature = "async")] +impl AsyncEncryptedStream { + fn new(inner: AsyncTcpStream, session_key: [u8; 32]) -> Self { + let (read_half, write_half) = inner.into_split(); + let read_cipher = ChaCha20Poly1305::new(&session_key.into()); + let write_cipher = ChaCha20Poly1305::new(&session_key.into()); + Self { + read: AsyncEncryptedRead { + inner: read_half, + cipher: read_cipher, + recv_buffer: Vec::new(), + read_state: ReadState::ReadingLength { buf: [0; 4], pos: 0 }, + }, + write: AsyncEncryptedWrite { + inner: write_half, + cipher: write_cipher, + send_counter: 0, + }, + } + } + + pub fn into_split(self) -> (AsyncEncryptedRead, AsyncEncryptedWrite) { + (self.read, self.write) + } +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncEncryptedRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.recv_buffer.is_empty() { + let to_copy = buf.remaining().min(self.recv_buffer.len()); + buf.put_slice(&self.recv_buffer[..to_copy]); + self.recv_buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + loop { + let state = std::mem::replace(&mut self.read_state, ReadState::Ready); + match state { + ReadState::ReadingLength { buf: mut len_buf, pos } => { + let mut read_buf = ReadBuf::new(&mut len_buf[pos..]); + + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => { + let bytes_read = read_buf.filled().len(); + if bytes_read == 0 { + if pos == 0 { + return Poll::Ready(Ok(())); // Normal EOF + } else { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Connection closed while reading length" + ))); + } + } + let new_pos = pos + bytes_read; + if new_pos < 4 { + self.read_state = ReadState::ReadingLength { buf: len_buf, pos: new_pos }; + } else { + // We have all 4 bytes + let packet_len = u32::from_be_bytes(len_buf) as usize; + if packet_len > 2 * 1024 * 1024 { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Packet too large" + ))); + } + self.read_state = ReadState::ReadingPacket { + expected_len: packet_len, + buf: vec![0u8; packet_len], + pos: 0, + }; + } + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => { + self.read_state = ReadState::ReadingLength { buf: len_buf, pos }; + return Poll::Pending; + } + } + } + ReadState::ReadingPacket { expected_len, buf: mut packet_buf, pos } => { + let mut read_buf = ReadBuf::new(&mut packet_buf[pos..]); + + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => { + let bytes_read = read_buf.filled().len(); + if bytes_read == 0 { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Connection closed while reading packet" + ))); + } + let new_pos = pos + bytes_read; + if new_pos < expected_len { + self.read_state = ReadState::ReadingPacket { + expected_len, + buf: packet_buf, + pos: new_pos, + }; + } else { + let nonce = Nonce::from_slice(&packet_buf[0..12]); + let ciphertext_and_tag = &packet_buf[12..]; + + match self.cipher.decrypt(nonce, ciphertext_and_tag) { + Ok(plaintext) => { + self.recv_buffer = plaintext; + self.read_state = ReadState::ReadingLength { buf: [0; 4], pos: 0 }; + + let to_copy = buf.remaining().min(self.recv_buffer.len()); + buf.put_slice(&self.recv_buffer[..to_copy]); + self.recv_buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + Err(_) => { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Decryption failed" + ))); + } + } + } + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => { + self.read_state = ReadState::ReadingPacket { + expected_len, + buf: packet_buf, + pos, + }; + return Poll::Pending; + } + } + } + ReadState::Ready => { + self.read_state = ReadState::ReadingLength { buf: [0; 4], pos: 0 }; + } + } + } + } +} + +#[cfg(feature = "async")] +impl AsyncWrite for AsyncEncryptedWrite { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let mut nonce_bytes = [0u8; 12]; + nonce_bytes[4..].copy_from_slice(&self.send_counter.to_be_bytes()); + self.send_counter += 1; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = self.cipher.encrypt(nonce, buf) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::Other, "Encryption failed"))?; + + let packet = [&nonce_bytes[..], ciphertext.as_slice()].concat(); + let len_buf = (packet.len() as u32).to_be_bytes(); + + let full_packet = [&len_buf[..], &packet].concat(); + + match Pin::new(&mut self.inner).poll_write(cx, &full_packet) { + Poll::Ready(Ok(n)) if n >= full_packet.len() => Poll::Ready(Ok(buf.len())), + Poll::Ready(Ok(_)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "Failed to write complete packet" + ))), + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncEncryptedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.read).poll_read(cx, buf) + } +} + +#[cfg(feature = "async")] +impl AsyncWrite for AsyncEncryptedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.write).poll_write(cx, buf) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.write).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.write).poll_shutdown(cx) + } +} +` + +func generateAuth(logger *slog.Logger, srcDir string) error { + logger.Debug("Generating auth.rs") + outputFile := filepath.Join(srcDir, "auth.rs") + + if err := os.WriteFile(outputFile, []byte(authModuleTemplate), 0644); err != nil { + return fmt.Errorf("write auth.rs: %w", err) + } + + logger.Info("Generated auth.rs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/client.go b/internal/codegen/generator/rust/client.go new file mode 100644 index 00000000..12699949 --- /dev/null +++ b/internal/codegen/generator/rust/client.go @@ -0,0 +1,268 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const clientTemplate = `{{.Header}} +use crate::error::{ProblemJson, ViiperError}; +use crate::types::*; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream, Shutdown}; + +/// Stream wrapper that can be either plain or encrypted +enum StreamWrapper { + Plain(TcpStream), + Encrypted(crate::auth::EncryptedStream), +} + +impl StreamWrapper { + fn try_clone(&self) -> std::io::Result { + match self { + StreamWrapper::Plain(s) => Ok(StreamWrapper::Plain(s.try_clone()?)), + StreamWrapper::Encrypted(s) => Ok(StreamWrapper::Encrypted(s.try_clone()?)), + } + } + + fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { + match self { + StreamWrapper::Plain(s) => s.shutdown(how), + StreamWrapper::Encrypted(s) => s.shutdown(how), + } + } +} + +impl Read for StreamWrapper { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + StreamWrapper::Plain(s) => s.read(buf), + StreamWrapper::Encrypted(s) => s.read(buf), + } + } +} + +impl Write for StreamWrapper { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + StreamWrapper::Plain(s) => s.write(buf), + StreamWrapper::Encrypted(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + StreamWrapper::Plain(s) => s.flush(), + StreamWrapper::Encrypted(s) => s.flush(), + } + } +} + +/// VIIPER management API client (synchronous). +pub struct ViiperClient { + addr: SocketAddr, + password: Option, +} + +impl ViiperClient { + /// Create a new VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr, password: None } + } + + /// Create a new VIIPER client with password authentication. + /// Empty password string explicitly means no authentication. + pub fn new_with_password(addr: SocketAddr, password: String) -> Self { + let password = if password.is_empty() { None } else { Some(password) }; + Self { addr, password } + } + + fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let tcp_stream = TcpStream::connect(self.addr)?; + tcp_stream.set_nodelay(true)?; + + let mut stream = if let Some(ref pwd) = self.password { + StreamWrapper::Encrypted(crate::auth::perform_handshake(tcp_stream, pwd)?) + } else { + StreamWrapper::Plain(tcp_stream) + }; + + stream.write_all(path.as_bytes())?; + if let Some(p) = payload { + stream.write_all(b" ")?; + stream.write_all(p.as_bytes())?; + } + stream.write_all(b"\0")?; + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf)?; + + let response = String::from_utf8(buf) + .map_err(|_| ViiperError::UnexpectedResponse("invalid UTF-8".into()))? + .trim_end_matches('\n') + .to_string(); + + if response.starts_with("{\"status\":") { + let problem: ProblemJson = serde_json::from_str(&response)?; + return Err(ViiperError::Protocol(problem)); + } + + serde_json::from_str(&response).map_err(Into::into) + } +{{range .Routes}}{{if eq .Method "Register"}} + /// {{.Handler}}: {{.Path}}{{if .ResponseDTO}} -> {{.ResponseDTO}}{{end}} + pub fn {{toSnakeCase .Handler}}(&self{{generateMethodParamsRust .}}) -> Result<{{if .ResponseDTO}}{{.ResponseDTO}}{{else}}(){{end}}, ViiperError> { + let path = {{generatePathRust .}}; + {{generatePayloadRust .}} + {{if .ResponseDTO}}self.do_request(&path, payload.as_deref()){{else}}self.do_request::(&path, payload.as_deref())?; + Ok(()){{end}} + } +{{end}}{{end}} + /// Connect to a device stream for sending input and receiving output. + pub fn connect_device(&self, bus_id: u32, dev_id: &str) -> Result { + DeviceStream::connect(self.addr, bus_id, dev_id, self.password.as_deref()) + } +} + +/// A connected device stream for bidirectional communication. +pub struct DeviceStream { + stream: StreamWrapper, + output_thread: Option>, + disconnect_callback: Option>, +} + +impl DeviceStream { + pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str, password: Option<&str>) -> Result { + let tcp_stream = TcpStream::connect(addr)?; + tcp_stream.set_nodelay(true)?; + + let mut stream = if let Some(pwd) = password { + StreamWrapper::Encrypted(crate::auth::perform_handshake(tcp_stream, pwd)?) + } else { + StreamWrapper::Plain(tcp_stream) + }; + + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.write_all(handshake.as_bytes())?; + Ok(Self { + stream, + output_thread: None, + disconnect_callback: None, + }) + } + + /// Send a device input to the device. + pub fn send(&mut self, input: &T) -> Result<(), ViiperError> { + let bytes = input.to_bytes(); + self.stream.write_all(&bytes)?; + Ok(()) + } + + /// Register a callback to receive device output asynchronously. + /// The callback receives a BufRead reader and must read the exact number of bytes expected. + /// The callback will be invoked repeatedly on a background thread until it returns an error. + /// Only one callback can be registered at a time. + pub fn on_output(&mut self, mut callback: F) -> Result<(), ViiperError> + where + F: FnMut(&mut dyn std::io::BufRead) -> std::io::Result<()> + Send + 'static, + { + if self.output_thread.is_some() { + return Err(ViiperError::UnexpectedResponse("Output callback already registered".into())); + } + + let stream = self.stream.try_clone()?; + let disconnect = self.disconnect_callback.take(); + let handle = std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(stream); + while callback(&mut reader).is_ok() {} + if let Some(on_disconnect) = disconnect { + on_disconnect(); + } + }); + self.output_thread = Some(handle); + Ok(()) + } + + pub fn on_disconnect(&mut self, callback: F) -> Result<(), ViiperError> + where + F: FnOnce() + Send + 'static, + { + self.disconnect_callback = Some(Box::new(callback)); + Ok(()) + } + + /// Send raw bytes to the device. + pub fn send_raw(&mut self, data: &[u8]) -> Result<(), ViiperError> { + self.stream.write_all(data)?; + Ok(()) + } + + /// Read raw bytes from the device. + pub fn read_raw(&mut self, buf: &mut [u8]) -> Result { + self.stream.read(buf).map_err(Into::into) + } + + /// Read exact number of bytes from the device. + pub fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), ViiperError> { + self.stream.read_exact(buf).map_err(Into::into) + } +} + +impl Drop for DeviceStream { + fn drop(&mut self) { + let _ = self.stream.shutdown(std::net::Shutdown::Both); + if let Some(handle) = self.output_thread.take() { + let _ = handle.join(); + } + } +} +` + +func generateClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error { + logger.Debug("Generating client.rs (sync API)") + outputFile := filepath.Join(srcDir, "client.rs") + + funcMap := template.FuncMap{ + "toSnakeCase": common.ToSnakeCase, + "generateMethodParamsRust": generateMethodParamsRust, + "generatePathRust": generatePathRust, + "generatePayloadRust": generatePayloadRust, + } + + tmpl, err := template.New("client").Funcs(funcMap).Parse(clientTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Header string + Routes []scanner.RouteInfo + }{ + Header: writeFileHeaderRust(), + Routes: md.Routes, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated client.rs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go new file mode 100644 index 00000000..b769df11 --- /dev/null +++ b/internal/codegen/generator/rust/constants.go @@ -0,0 +1,262 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const constantsTemplate = `{{.Header}} +{{if .HasMaps}}use std::collections::{{"{"}}{{if .HasHashMap}}HashMap{{end}}{{if and .HasHashMap .HasHashSet}}, {{end}}{{if .HasHashSet}}HashSet{{end}}{{"}"}}; + +{{end}}{{range .Constants}}pub const {{toScreamingSnakeCase .Name}}: {{.RustType}} = {{.Value}}; +{{end}} +{{range .Maps}}{{if eq .ValueType "bool"}}lazy_static::lazy_static! { + pub static ref {{toScreamingSnakeCase .Name}}: HashSet<{{.KeyRustType}}> = { + let mut s = HashSet::new(); +{{range .Entries}} s.insert({{.Key}}); +{{end}} s + }; +} +{{else}}lazy_static::lazy_static! { + pub static ref {{toScreamingSnakeCase .Name}}: HashMap<{{.KeyRustType}}, {{.ValueRustType}}> = { + let mut m = HashMap::new(); +{{range .Entries}} m.insert({{.Key}}, {{.Value}}); +{{end}} m + }; +} +{{end}} +{{end}}` + +type rustConstant struct { + Name string + RustType string + Value string +} + +type rustMapEntry struct { + Key string + Value string +} + +type rustMapInfo struct { + Name string + KeyRustType string + ValueRustType string + ValueType string + Entries []rustMapEntry +} + +type constantsData struct { + Header string + DeviceName string + Constants []rustConstant + Maps []rustMapInfo + HasMaps bool + HasHashMap bool + HasHashSet bool +} + +func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating Rust device constants", "device", deviceName) + + devicePkg, ok := md.DevicePackages[deviceName] + if !ok { + return nil + } + + var constants []rustConstant + + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(deviceName, "s2c"); s2cTag != nil { + outputSize := common.CalculateOutputSize(s2cTag) + if outputSize > 0 { + constants = append(constants, rustConstant{ + Name: "OUTPUT_SIZE", + RustType: "usize", + Value: fmt.Sprintf("%d", outputSize), + }) + } + } + } + + for _, c := range devicePkg.Constants { + if !common.IsIntegerConst(c.Value, c.Type) { + continue + } + rustType := constGoTypeToRust(c.Type) + value := formatConstValue(c.Value, c.Type) + constants = append(constants, rustConstant{ + Name: c.Name, + RustType: rustType, + Value: value, + }) + } + + var maps []rustMapInfo + for _, m := range devicePkg.Maps { + mapInfo := rustMapInfo{ + Name: m.Name, + KeyRustType: goTypeToRust(m.KeyType), + ValueRustType: goTypeToRust(m.ValueType), + ValueType: m.ValueType, + } + + keys := common.SortedStringKeys(m.Entries) + for _, k := range keys { + v := m.Entries[k] + key := formatMapKeyRust(k, m.KeyType) + value := formatMapValueRust(v, m.ValueType) + mapInfo.Entries = append(mapInfo.Entries, rustMapEntry{Key: key, Value: value}) + } + + if len(mapInfo.Entries) > 0 { + maps = append(maps, mapInfo) + } + } + + hasHashMap := false + hasHashSet := false + for _, m := range maps { + if m.ValueType == "bool" { + hasHashSet = true + } else { + hasHashMap = true + } + } + + data := constantsData{ + Header: writeFileHeaderRust(), + DeviceName: deviceName, + Constants: constants, + Maps: maps, + HasMaps: len(maps) > 0, + HasHashMap: hasHashMap, + HasHashSet: hasHashSet, + } + + funcMap := template.FuncMap{ + "toScreamingSnakeCase": toScreamingSnakeCase, + } + tmpl, err := template.New("constants").Funcs(funcMap).Parse(constantsTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + outputPath := filepath.Join(deviceDir, "constants.rs") + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated device constants", "file", outputPath) + return nil +} + +func constGoTypeToRust(goType string) string { + base, _, _ := common.NormalizeGoType(goType) + if base == "string" { + return "&'static str" + } + return goTypeToRust(goType) +} + +func formatConstValue(val interface{}, goType string) string { + base, _, _ := common.NormalizeGoType(goType) + + switch base { + case "string": + return fmt.Sprintf(`"%v"`, val) + case "float32", "float64": + var f float64 + switch v := val.(type) { + case float64: + f = v + case int64: + f = float64(v) + case uint64: + f = float64(v) + case string: + parsed, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Sprintf("%v", val) + } + f = parsed + default: + return fmt.Sprintf("%v", val) + } + + s := strconv.FormatFloat(f, 'f', -1, 64) + if !strings.ContainsAny(s, ".eE") { + s += ".0" + } + return s + default: + return fmt.Sprintf("%v", val) + } +} + +func formatMapKeyRust(key string, goType string) string { + switch goType { + case "byte", "uint8": + if len(key) == 2 && key[0] == '\\' { + switch key[1] { + case 'n': + return "0x0A" + case 'r': + return "0x0D" + case 't': + return "0x09" + case '\\': + return "0x5C" + case '\'': + return "0x27" + } + } + if len(key) >= 1 { + return fmt.Sprintf("%d", key[0]) + } + return key + case "string": + return fmt.Sprintf("\"%s\".to_string()", key) + default: + return key + } +} + +func formatMapValueRust(value interface{}, goType string) string { + switch goType { + case "byte", "uint8": + if str, ok := value.(string); ok { + return toScreamingSnakeCase(str) + } + return fmt.Sprintf("%v", value) + case "bool": + if b, ok := value.(bool); ok { + return fmt.Sprintf("%t", b) + } + if str, ok := value.(string); ok { + return str + } + return "false" + case "string": + if str, ok := value.(string); ok { + return fmt.Sprintf("\"%s\".to_string()", str) + } + return fmt.Sprintf("\"%v\".to_string()", value) + default: + return fmt.Sprintf("%v", value) + } +} diff --git a/internal/codegen/generator/rust/device_specific.go b/internal/codegen/generator/rust/device_specific.go new file mode 100644 index 00000000..55668600 --- /dev/null +++ b/internal/codegen/generator/rust/device_specific.go @@ -0,0 +1,142 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +type rustDeviceSpecificField struct { + JSONName string + RustName string + RustType string + Optional bool +} + +type rustDeviceSpecificStruct struct { + Name string + Fields []rustDeviceSpecificField +} + +const deviceSpecificTemplate = `{{.Header}} +use std::collections::HashMap; + +{{range .Structs}} +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct {{.Name}} { +{{- range .Fields}} + {{if .Optional}}#[serde(skip_serializing_if = "Option::is_none")] + {{end}}#[serde(rename = "{{.JSONName}}")] + pub {{.RustName}}: {{.RustType}}, +{{- end}} +} + +impl {{.Name}} { + pub fn to_map(&self) -> HashMap { + match serde_json::to_value(self) { + Ok(serde_json::Value::Object(obj)) => obj.into_iter().collect(), + _ => HashMap::new(), + } + } + + pub fn from_map(map: &HashMap) -> Result { + let obj: serde_json::Map = map.clone().into_iter().collect(); + serde_json::from_value(serde_json::Value::Object(obj)) + } +} + +{{end}} +` + +func generateDeviceSpecific(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + structs := md.DeviceStructs[deviceName] + if len(structs) == 0 { + return nil + } + + logger.Debug("Generating Rust meta helpers", "device", deviceName) + + legacyPath := filepath.Join(deviceDir, "device_specific.rs") + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove legacy Rust device-specific file: %w", err) + } + outputPath := filepath.Join(deviceDir, "meta.rs") + data := struct { + Header string + Structs []rustDeviceSpecificStruct + }{ + Header: writeFileHeaderRust(), + Structs: make([]rustDeviceSpecificStruct, 0, len(structs)), + } + + for _, s := range structs { + entry := rustDeviceSpecificStruct{ + Name: s.Name, + Fields: make([]rustDeviceSpecificField, 0, len(s.Fields)), + } + for _, f := range s.Fields { + rustName := common.ToSnakeCase(f.Name) + if isRustKeyword(rustName) { + rustName = "r#" + rustName + } + rustType := fieldTypeToRustForDeviceSpecific(f) + entry.Fields = append(entry.Fields, rustDeviceSpecificField{ + JSONName: f.JSONName, + RustName: rustName, + RustType: rustType, + Optional: f.Optional, + }) + } + data.Structs = append(data.Structs, entry) + } + + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + tmpl := template.Must(template.New("deviceSpecificRust").Parse(deviceSpecificTemplate)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated Rust meta helpers", "device", deviceName, "path", outputPath) + return nil +} + +func fieldTypeToRustForDeviceSpecific(field scanner.FieldInfo) string { + typeStr := field.Type + typeKind := field.TypeKind + optional := field.Optional + + var rustType string + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + valueType, ok := parseGoMapType(typeStr) + if ok { + rustType = fmt.Sprintf("std::collections::HashMap", goTypeToRust(valueType)) + } else { + rustType = "std::collections::HashMap" + } + } else if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + rustType = fmt.Sprintf("Vec<%s>", goTypeToRust(elem)) + } else if typeStr == "time.Time" { + rustType = "String" + } else { + rustType = goTypeToRust(typeStr) + } + + if optional && !strings.HasPrefix(rustType, "Option<") { + rustType = fmt.Sprintf("Option<%s>", rustType) + } + + return rustType +} diff --git a/internal/codegen/generator/rust/device_types.go b/internal/codegen/generator/rust/device_types.go new file mode 100644 index 00000000..1be95815 --- /dev/null +++ b/internal/codegen/generator/rust/device_types.go @@ -0,0 +1,232 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const deviceInputTemplate = `{{.Header}} +use crate::wire::DeviceInput; + +#[derive(Debug, Clone)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{end}}} + +impl Default for {{.StructName}} { + fn default() -> Self { + Self { +{{range .Fields}} {{.RustName}}: {{defaultExpr .}}, +{{end}} } + } +} + +impl DeviceInput for {{.StructName}} { + fn to_bytes(&self) -> Vec { + let mut buf = Vec::new(); +{{range .Fields}}{{if .IsArray}} + for item in &self.{{.RustName}} { + buf.extend_from_slice(&item.to_le_bytes()); + } +{{else}} buf.extend_from_slice(&self.{{.RustName}}.to_le_bytes()); +{{end}}{{end}} buf + } +} +` + +const deviceOutputTemplate = `{{.Header}} +use crate::wire::DeviceOutput; + +#[derive(Debug, Clone)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{end}}} + +impl Default for {{.StructName}} { + fn default() -> Self { + Self { +{{range .Fields}} {{.RustName}}: {{defaultExpr .}}, +{{end}} } + } +} + +impl DeviceOutput for {{.StructName}} { + fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; +{{range .Fields}}{{if .IsArray}}{{if gt .FixedLen 0}} if offset + (std::mem::size_of::<{{.ElementType}}>() * {{.FixedLen}}) > buf.len() { + return Err(crate::error::ViiperError::UnexpectedResponse( + "buffer too short".into() + )); + } + let mut {{.RustName}} = [{{.ElementType}}::default(); {{.FixedLen}}]; + for i in 0..{{.FixedLen}} { + let bytes = &buf[offset..offset + std::mem::size_of::<{{.ElementType}}>()]; + {{.RustName}}[i] = {{.ElementType}}::from_le_bytes(bytes.try_into().unwrap()); + offset += std::mem::size_of::<{{.ElementType}}>(); + } +{{else}} + let elem_size = std::mem::size_of::<{{.ElementType}}>(); + let mut {{.RustName}} = Vec::new(); + while offset + elem_size <= buf.len() { + let bytes = &buf[offset..offset + elem_size]; + let value = {{.ElementType}}::from_le_bytes(bytes.try_into().unwrap()); + {{.RustName}}.push(value); + offset += elem_size; + } +{{end}}{{else}} if offset + std::mem::size_of::<{{.RustType}}>() > buf.len() { + return Err(crate::error::ViiperError::UnexpectedResponse( + "buffer too short".into() + )); + } + let {{.RustName}} = {{.RustType}}::from_le_bytes( + buf[offset..offset + std::mem::size_of::<{{.RustType}}>()] + .try_into() + .unwrap() + ); + offset += std::mem::size_of::<{{.RustType}}>(); +{{end}}{{end}} let _ = offset; // Suppress unused warning for last field + Ok(Self { +{{range .Fields}} {{.RustName}}, +{{end}} }) + } +} +` + +type rustWireField struct { + Name string + RustName string + RustType string + ElementType string + IsArray bool + CountName string + FixedLen int +} + +type deviceTypeData struct { + Header string + DeviceName string + StructName string + Fields []rustWireField +} + +func generateDeviceTypes(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating Rust device types", "device", deviceName) + + if md.WireTags == nil { + return nil + } + + pascalDevice := common.ToPascalCase(deviceName) + + c2sTag := md.WireTags.GetTag(deviceName, "c2s") + if c2sTag != nil { + path := filepath.Join(deviceDir, "input.rs") + if err := generateDeviceWireStruct(path, pascalDevice, "Input", c2sTag, deviceInputTemplate); err != nil { + return err + } + } + + s2cTag := md.WireTags.GetTag(deviceName, "s2c") + if s2cTag != nil { + path := filepath.Join(deviceDir, "output.rs") + if err := generateDeviceWireStruct(path, pascalDevice, "Output", s2cTag, deviceOutputTemplate); err != nil { + return err + } + } + + return nil +} + +func generateDeviceWireStruct(outputPath, deviceName, className string, tag *scanner.WireTag, tmplStr string) error { + var fields []rustWireField + + for _, field := range tag.Fields { + rustName := common.ToSnakeCase(field.Name) + wireType := field.Type + baseType := wireType + countToken := "" + isArray := strings.Contains(wireType, "*") + fixedLen := 0 + var countName string + var elemType string + + if isArray { + idx := strings.Index(wireType, "*") + baseType = wireType[:idx] + countToken = wireType[idx+1:] + elemType = wireTypeToRust(baseType) + if n, err := strconv.Atoi(countToken); err == nil { + fixedLen = n + } else { + countName = countToken + } + } + + rustType := wireTypeToRust(baseType) + if isArray { + if fixedLen > 0 { + rustType = fmt.Sprintf("[%s; %d]", elemType, fixedLen) + } else { + rustType = fmt.Sprintf("Vec<%s>", elemType) + } + } + + fields = append(fields, rustWireField{ + Name: field.Name, + RustName: rustName, + RustType: rustType, + ElementType: elemType, + IsArray: isArray, + CountName: countName, + FixedLen: fixedLen, + }) + } + + data := deviceTypeData{ + Header: writeFileHeaderRust(), + DeviceName: deviceName, + StructName: deviceName + className, + Fields: fields, + } + + funcMap := template.FuncMap{ + "defaultExpr": rustDefaultExpr, + } + tmpl, err := template.New("devicewire").Funcs(funcMap).Parse(tmplStr) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} + +func rustDefaultExpr(field rustWireField) string { + if field.IsArray { + if field.FixedLen > 0 { + return fmt.Sprintf("[%s::default(); %d]", field.ElementType, field.FixedLen) + } + + return "Vec::new()" + } + + return fmt.Sprintf("%s::default()", field.RustType) +} diff --git a/internal/codegen/generator/rust/error.go b/internal/codegen/generator/rust/error.go new file mode 100644 index 00000000..669d8320 --- /dev/null +++ b/internal/codegen/generator/rust/error.go @@ -0,0 +1,67 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const errorTemplate = ` +use std::fmt; + +/// Errors that can occur when using the VIIPER client +#[derive(Debug, thiserror::Error)] +pub enum ViiperError { + /// Network or I/O errors + #[error("transport error: {0}")] + Io(#[from] std::io::Error), + + /// RFC 7807 Problem+JSON response from server + #[error("{0}")] + Protocol(#[from] ProblemJson), + + /// Failed to parse JSON response + #[error("parse error: {0}")] + Parse(#[from] serde_json::Error), + + /// Unexpected response format + #[error("unexpected response: {0}")] + UnexpectedResponse(String), + + /// Operation timed out + #[cfg(feature = "async")] + #[error("operation timed out")] + Timeout, +} + +/// RFC 7807 Problem Details for HTTP APIs +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ProblemJson { + pub status: u16, + pub title: String, + pub detail: String, +} + +impl fmt::Display for ProblemJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}: {}", self.status, self.title, self.detail) + } +} + +impl std::error::Error for ProblemJson {} +` + +func generateError(logger *slog.Logger, srcDir string) error { + logger.Debug("Generating error.rs") + outputFile := filepath.Join(srcDir, "error.rs") + + content := writeFileHeaderRust() + errorTemplate + + if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil { + return fmt.Errorf("write error.rs: %w", err) + } + + logger.Info("Generated error.rs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/gen.go b/internal/codegen/generator/rust/gen.go new file mode 100644 index 00000000..5dded7f6 --- /dev/null +++ b/internal/codegen/generator/rust/gen.go @@ -0,0 +1,189 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { + projectDir := outputDir + srcDir := filepath.Join(projectDir, "src") + devicesDir := filepath.Join(srcDir, "devices") + + for _, dir := range []string{projectDir, srcDir, devicesDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + } + + version, err := common.GetVersion() + if err != nil { + return fmt.Errorf("get version: %w", err) + } + + if err := generateProject(logger, projectDir, version); err != nil { + return err + } + + if err := generateGitignore(logger, projectDir); err != nil { + return err + } + + if err := generateError(logger, srcDir); err != nil { + return err + } + + if err := generateWireModule(logger, srcDir); err != nil { + return err + } + + if err := generateTypes(logger, srcDir, md); err != nil { + return err + } + + if err := generateClient(logger, srcDir, md); err != nil { + return err + } + + if err := generateAsyncClient(logger, srcDir, md); err != nil { + return err + } + + if err := generateAuth(logger, srcDir); err != nil { + return err + } + + for deviceName := range md.DevicePackages { + deviceDir := filepath.Join(devicesDir, deviceName) + if err := os.MkdirAll(deviceDir, 0o755); err != nil { + return fmt.Errorf("create device directory %s: %w", deviceDir, err) + } + + if err := generateDeviceTypes(logger, deviceDir, deviceName, md); err != nil { + return err + } + + if err := generateConstants(logger, deviceDir, deviceName, md); err != nil { + return err + } + + if err := generateDeviceSpecific(logger, deviceDir, deviceName, md); err != nil { + return err + } + + if err := generateDeviceModFile(logger, deviceDir, deviceName, md); err != nil { + return err + } + } + + if err := generateDevicesModFile(logger, devicesDir, md); err != nil { + return err + } + + if err := generateLibFile(logger, srcDir, md); err != nil { + return err + } + + if err := common.GenerateLicense(logger, projectDir); err != nil { + return err + } + + if err := common.GenerateReadme(logger, projectDir); err != nil { + return err + } + + logger.Info("Generated Rust client library", "dir", projectDir) + return nil +} + +func generateLibFile(logger *slog.Logger, srcDir string, _ *meta.Metadata) error { + logger.Debug("Generating lib.rs") + outputFile := filepath.Join(srcDir, "lib.rs") + + content := writeFileHeaderRust() + ` +pub mod error; +pub mod wire; +pub mod types; +pub mod client; +pub mod auth; + +#[cfg(feature = "async")] +pub mod async_client; + +pub mod devices; + +pub use error::{ViiperError, ProblemJson}; +pub use wire::{DeviceInput, DeviceOutput}; +pub use types::*; +pub use client::{ViiperClient, DeviceStream}; + +#[cfg(feature = "async")] +pub use async_client::{AsyncViiperClient, AsyncDeviceStream}; +` + + if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil { + return fmt.Errorf("write lib.rs: %w", err) + } + + logger.Info("Generated lib.rs", "file", outputFile) + return nil +} + +func generateDevicesModFile(logger *slog.Logger, devicesDir string, md *meta.Metadata) error { + logger.Debug("Generating devices/mod.rs") + outputFile := filepath.Join(devicesDir, "mod.rs") + + content := writeFileHeaderRust() + for deviceName := range md.DevicePackages { + content += fmt.Sprintf("pub mod %s;\n", deviceName) + } + + if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil { + return fmt.Errorf("write devices/mod.rs: %w", err) + } + + logger.Info("Generated devices/mod.rs", "file", outputFile) + return nil +} + +func generateDeviceModFile(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating device mod.rs", "device", deviceName) + outputFile := filepath.Join(deviceDir, "mod.rs") + + content := writeFileHeaderRust() + + hasInput := md.WireTags != nil && md.WireTags.GetTag(deviceName, "c2s") != nil + hasOutput := md.WireTags != nil && md.WireTags.GetTag(deviceName, "s2c") != nil + hasConstants := md.DevicePackages[deviceName] != nil && + (len(md.DevicePackages[deviceName].Constants) > 0 || len(md.DevicePackages[deviceName].Maps) > 0) + hasDeviceSpecific := len(md.DeviceStructs[deviceName]) > 0 + + if hasInput { + content += "pub mod input;\n" + content += "pub use input::*;\n\n" + } + if hasOutput { + content += "pub mod output;\n" + content += "pub use output::*;\n\n" + } + if hasConstants { + content += "pub mod constants;\n" + content += "pub use constants::*;\n\n" + } + if hasDeviceSpecific { + content += "pub mod meta;\n" + content += "pub use meta::*;\n\n" + } + + if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil { + return fmt.Errorf("write device mod.rs: %w", err) + } + + logger.Info("Generated device mod.rs", "device", deviceName, "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go new file mode 100644 index 00000000..a82dd304 --- /dev/null +++ b/internal/codegen/generator/rust/helpers.go @@ -0,0 +1,171 @@ +package rust + +import ( + "fmt" + "strings" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func goTypeToRust(goType string) string { + base, isSlice, isPointer := common.NormalizeGoType(goType) + if base == "any" || base == "interface{}" { + return "serde_json::Value" + } + + var rustType string + switch base { + case "bool": + rustType = "bool" + case "string": + rustType = "String" + case "int", "int32": + rustType = "i32" + case "int8": + rustType = "i8" + case "int16": + rustType = "i16" + case "int64": + rustType = "i64" + case "uint", "uint32": + rustType = "u32" + case "uint8", "byte": + rustType = "u8" + case "uint16": + rustType = "u16" + case "uint64": + rustType = "u64" + case "float32": + rustType = "f32" + case "float64": + rustType = "f64" + default: + rustType = common.ToTypeName(base) + } + + if isSlice { + rustType = fmt.Sprintf("Vec<%s>", rustType) + } + if isPointer { + rustType = fmt.Sprintf("Option<%s>", rustType) + } + + return rustType +} + +func parseGoMapType(typeStr string) (string, bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", false + } + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false + } + return valueType, true +} + +func wireTypeToRust(wireType string) string { + baseType := strings.TrimSuffix(wireType, "*") + if strings.Contains(wireType, "*") { + idx := strings.Index(wireType, "*") + baseType = wireType[:idx] + } + + switch baseType { + case "u8": + return "u8" + case "i8": + return "i8" + case "u16": + return "u16" + case "i16": + return "i16" + case "u32": + return "u32" + case "i32": + return "i32" + case "u64": + return "u64" + case "i64": + return "i64" + default: + return "u8" + } +} + +func writeFileHeaderRust() string { + return "// This file is auto-generated by VIIPER codegen. DO NOT EDIT.\n" +} + +func toScreamingSnakeCase(s string) string { + return strings.ToUpper(common.ToSnakeCase(s)) +} + +func generateMethodParamsRust(route scanner.RouteInfo) string { + var params []string + + for key := range route.PathParams { + params = append(params, fmt.Sprintf("%s: u32", common.ToSnakeCase(key))) + } + + switch route.Payload.Kind { + case scanner.PayloadJSON: + paramName := common.ToSnakeCase(route.Payload.ParserHint) + params = append(params, fmt.Sprintf("%s: &%s", paramName, route.Payload.ParserHint)) + case scanner.PayloadNumeric: + paramName := common.ToSnakeCase(route.Payload.ParserHint) + params = append(params, fmt.Sprintf("%s: Option", paramName)) + case scanner.PayloadString: + params = append(params, fmt.Sprintf("%s: Option<&str>", common.ToSnakeCase(route.Payload.ParserHint))) + } + + if len(params) == 0 { + return "" + } + return ", " + strings.Join(params, ", ") +} + +func generatePathRust(route scanner.RouteInfo) string { + path := route.Path + if len(route.PathParams) == 0 { + return fmt.Sprintf(`"%s".to_string()`, path) + } + + var formatStr string + var args []string + + formatStr = path + for key := range route.PathParams { + placeholder := fmt.Sprintf("{%s}", key) + formatStr = strings.Replace(formatStr, placeholder, "{}", 1) + args = append(args, common.ToSnakeCase(key)) + } + + if len(args) > 0 { + return fmt.Sprintf(`format!("%s", %s)`, formatStr, strings.Join(args, ", ")) + } + return fmt.Sprintf(`"%s".to_string()`, path) +} + +func generatePayloadRust(route scanner.RouteInfo) string { + switch route.Payload.Kind { + case scanner.PayloadNone: + return "let payload: Option = None;" + case scanner.PayloadJSON: + paramName := common.ToSnakeCase(route.Payload.ParserHint) + return fmt.Sprintf("let payload = Some(serde_json::to_string(&%s)?);", paramName) + case scanner.PayloadNumeric: + paramName := common.ToSnakeCase(route.Payload.ParserHint) + return fmt.Sprintf("let payload = %s.map(|v| v.to_string());", paramName) + case scanner.PayloadString: + paramName := common.ToSnakeCase(route.Payload.ParserHint) + return fmt.Sprintf("let payload = %s.map(|s| s.to_string());", paramName) + default: + return "let payload: Option = None;" + } +} diff --git a/internal/codegen/generator/rust/project.go b/internal/codegen/generator/rust/project.go new file mode 100644 index 00000000..c7221cce --- /dev/null +++ b/internal/codegen/generator/rust/project.go @@ -0,0 +1,108 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" +) + +const cargoTomlTemplate = `[package] +name = "viiper-client" +version = "{{.Version}}" +edition = "2021" +rust-version = "1.70" +authors = ["Peter Repukat"] +description = "VIIPER Client Library for Rust" +license = "MIT" +repository = "https://github.com/Alia5/VIIPER" +homepage = "https://github.com/Alia5/VIIPER" +documentation = "https://alia5.github.io/VIIPER/stable/clients/rust/" +readme = "README.md" +keywords = ["viiper", "usbip", "virtual-device", "input-emulation", "hid"] +categories = ["api-bindings", "hardware-support"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +lazy_static = "1.5" + +# Authentication and encryption dependencies +pbkdf2 = { version = "0.12", features = ["simple"] } +sha2 = "0.10" +hmac = "0.12" +chacha20poly1305 = "0.10" +rand = "0.8" + +[dependencies.tokio] +version = "1.0" +features = ["net", "io-util", "rt", "time", "macros"] +optional = true + +[dependencies.tokio-util] +version = "0.7" +optional = true + +[features] +default = [] +async = ["tokio", "tokio-util"] + +[package.metadata.docs.rs] +all-features = true +` + +func generateProject(logger *slog.Logger, projectDir string, version string) error { + logger.Debug("Generating Cargo.toml") + outputFile := filepath.Join(projectDir, "Cargo.toml") + + funcMap := template.FuncMap{} + tmpl, err := template.New("cargo").Funcs(funcMap).Parse(cargoTomlTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Version string + }{ + Version: version, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated Cargo.toml", "file", outputFile) + return nil +} + +func generateGitignore(logger *slog.Logger, projectDir string) error { + logger.Debug("Generating .gitignore") + outputFile := filepath.Join(projectDir, ".gitignore") + + content := `# Rust build artifacts +target/ +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +` + + if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil { + return fmt.Errorf("write .gitignore: %w", err) + } + + logger.Info("Generated .gitignore", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/rust/types.go b/internal/codegen/generator/rust/types.go new file mode 100644 index 00000000..28cb3441 --- /dev/null +++ b/internal/codegen/generator/rust/types.go @@ -0,0 +1,145 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const typesTemplate = `{{.Header}} +// Management API DTO types + +{{range .DTOs}} +/// {{.Name}} DTO +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} + {{if .Optional}}#[serde(skip_serializing_if = "Option::is_none")] + {{end}}{{if ne .JSONName .RustName}}#[serde(rename = "{{.JSONName}}")] + {{end}}pub {{.RustName}}: {{.RustType}}, +{{end}}} + +{{end}} +` + +type rustFieldInfo struct { + Name string + JSONName string + RustName string + RustType string + Optional bool +} + +type rustDTOInfo struct { + Name string + Fields []rustFieldInfo +} + +func generateTypes(logger *slog.Logger, srcDir string, md *meta.Metadata) error { + logger.Debug("Generating types.rs (management API DTOs)") + outputFile := filepath.Join(srcDir, "types.rs") + + var dtos []rustDTOInfo + for _, dto := range md.DTOs { + rustDTO := rustDTOInfo{ + Name: dto.Name, + Fields: []rustFieldInfo{}, + } + + for _, field := range dto.Fields { + rustName := common.ToSnakeCase(field.Name) + if isRustKeyword(rustName) { + rustName = "r#" + rustName + } + rustType := fieldTypeToRust(field) + + rustDTO.Fields = append(rustDTO.Fields, rustFieldInfo{ + Name: field.Name, + JSONName: field.JSONName, + RustName: rustName, + RustType: rustType, + Optional: field.Optional, + }) + } + + dtos = append(dtos, rustDTO) + } + + funcMap := template.FuncMap{} + tmpl, err := template.New("types").Funcs(funcMap).Parse(typesTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := struct { + Header string + DTOs []rustDTOInfo + }{ + Header: writeFileHeaderRust(), + DTOs: dtos, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated types.rs", "file", outputFile) + return nil +} + +func fieldTypeToRust(field interface{}) string { + v := reflect.ValueOf(field) + typeStr := v.FieldByName("Type").String() + typeKind := v.FieldByName("TypeKind").String() + optional := v.FieldByName("Optional").Bool() + + var rustType string + + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + valueType, ok := parseGoMapType(typeStr) + if ok { + rustType = fmt.Sprintf("std::collections::HashMap", goTypeToRust(valueType)) + } else { + rustType = "std::collections::HashMap" + } + } else if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + rustType = fmt.Sprintf("Vec<%s>", goTypeToRust(elem)) + } else { + rustType = goTypeToRust(typeStr) + } + + if optional && !strings.HasPrefix(rustType, "Option<") { + rustType = fmt.Sprintf("Option<%s>", rustType) + } + + return rustType +} + +func isRustKeyword(s string) bool { + keywords := map[string]bool{ + "as": true, "break": true, "const": true, "continue": true, "crate": true, + "else": true, "enum": true, "extern": true, "false": true, "fn": true, + "for": true, "if": true, "impl": true, "in": true, "let": true, + "loop": true, "match": true, "mod": true, "move": true, "mut": true, + "pub": true, "ref": true, "return": true, "self": true, "Self": true, + "static": true, "struct": true, "super": true, "trait": true, "true": true, + "type": true, "unsafe": true, "use": true, "where": true, "while": true, + "async": true, "await": true, "dyn": true, + } + return keywords[s] +} diff --git a/internal/codegen/generator/rust/wire.go b/internal/codegen/generator/rust/wire.go new file mode 100644 index 00000000..1edb5c53 --- /dev/null +++ b/internal/codegen/generator/rust/wire.go @@ -0,0 +1,45 @@ +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const wireModuleTemplate = `// This file is auto-generated by VIIPER codegen. DO NOT EDIT. + +/// Trait for device input types that can be serialized to wire protocol bytes. +/// +/// Input types represent data sent from the client to the device (client-to-server). +pub trait DeviceInput { + /// Serialize this input to wire protocol bytes (little-endian). + fn to_bytes(&self) -> Vec; +} + +/// Trait for device output types that can be deserialized from wire protocol bytes. +/// +/// Output types represent data received from the device (server-to-client). +pub trait DeviceOutput: Sized { + /// Deserialize this output from wire protocol bytes (little-endian). + fn from_bytes(buf: &[u8]) -> Result; +} +` + +func generateWireModule(logger *slog.Logger, srcDir string) error { + logger.Debug("Generating wire.rs module") + outputFile := filepath.Join(srcDir, "wire.rs") + + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + if _, err := f.WriteString(wireModuleTemplate); err != nil { + return fmt.Errorf("write template: %w", err) + } + + logger.Info("Generated wire.rs", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/typescript/auth.go b/internal/codegen/generator/typescript/auth.go new file mode 100644 index 00000000..ce9f1c22 --- /dev/null +++ b/internal/codegen/generator/typescript/auth.go @@ -0,0 +1,243 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const authUtilsTemplate = `// Auto-generated VIIPER TypeScript Client Library +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +import { Socket } from 'net'; +import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, createHash, createHmac } from 'crypto'; +import { Duplex } from 'stream'; + +const HANDSHAKE_MAGIC = 'eVI1\x00'; +const NONCE_SIZE = 32; +const AUTH_CONTEXT = 'VIIPER-Auth-v1'; +const SESSION_CONTEXT = 'VIIPER-Session-v1'; +const PBKDF2_ITERATIONS = 100000; +const PBKDF2_SALT = 'VIIPER-Key-v1'; + +/** + * Derive a 32-byte key from password using PBKDF2-SHA256 + */ +function deriveKey(password: string): Buffer { + if (!password || password.length === 0) { + throw new Error('Password cannot be empty'); + } + return pbkdf2Sync(password, PBKDF2_SALT, PBKDF2_ITERATIONS, 32, 'sha256'); +} + +/** + * Derive session key from key and nonces using SHA-256 + */ +function deriveSessionKey(key: Buffer, serverNonce: Buffer, clientNonce: Buffer): Buffer { + const hash = createHash('sha256'); + hash.update(key); + hash.update(serverNonce); + hash.update(clientNonce); + hash.update(Buffer.from(SESSION_CONTEXT)); + return hash.digest(); +} + +/** + * Perform authentication handshake with VIIPER server + * Returns a wrapped socket with encryption if handshake succeeds + */ +export async function performAuthHandshake(socket: Socket, password: string): Promise { + const key = deriveKey(password); + const clientNonce = randomBytes(NONCE_SIZE); + const hmac = createHmac('sha256', key); + hmac.update(Buffer.from(AUTH_CONTEXT)); + hmac.update(clientNonce); + const authTag = hmac.digest(); + + const handshakeMsg = Buffer.concat([ + Buffer.from(HANDSHAKE_MAGIC), + clientNonce, + authTag + ]); + socket.write(handshakeMsg); + + const response = await readExactly(socket, 3 + NONCE_SIZE); + + const prefix = response.slice(0, 3).toString(); + if (prefix !== 'OK\x00') { + const remaining = await readUntilEnd(socket); + const fullResponse = Buffer.concat([response, remaining]).toString().trim(); + try { + const error = JSON.parse(fullResponse); + throw new Error(` + "`${error.status} ${error.title}: ${error.detail}`" + `); + } catch { + throw new Error(` + "`Invalid handshake response: ${fullResponse}`" + `); + } + } + + const serverNonce = response.slice(3); + + const sessionKey = deriveSessionKey(key, serverNonce, clientNonce); + + return new EncryptedSocket(socket, sessionKey); +} + +/** + * Read exact number of bytes from socket + */ +function readExactly(socket: Socket, length: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let received = 0; + + const onData = (chunk: Buffer) => { + chunks.push(chunk); + received += chunk.length; + + if (received >= length) { + socket.removeListener('data', onData); + socket.removeListener('error', onError); + socket.removeListener('end', onEnd); + + const buffer = Buffer.concat(chunks); + resolve(buffer.slice(0, length)); + } + }; + + const onError = (err: Error) => { + socket.removeListener('data', onData); + socket.removeListener('end', onEnd); + reject(err); + }; + + const onEnd = () => { + socket.removeListener('data', onData); + socket.removeListener('error', onError); + reject(new Error('Connection closed before receiving full response')); + }; + + socket.on('data', onData); + socket.on('error', onError); + socket.on('end', onEnd); + }); +} + +/** + * Read until socket closes + */ +function readUntilEnd(socket: Socket): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + socket.on('data', (chunk: Buffer) => chunks.push(chunk)); + socket.on('end', () => resolve(Buffer.concat(chunks))); + }); +} + +/** + * Encrypted socket wrapper using ChaCha20-Poly1305 + */ +class EncryptedSocket extends Duplex { + private socket: Socket; + private sessionKey: Buffer; + private sendCounter: number = 0; + private recvBuffer: Buffer = Buffer.alloc(0); + + constructor(socket: Socket, sessionKey: Buffer) { + super(); + this.socket = socket; + this.sessionKey = sessionKey; + + socket.on('data', (chunk: Buffer) => this.handleIncomingData(chunk)); + socket.on('error', (err: Error) => this.emit('error', err)); + socket.on('end', () => this.emit('end')); + socket.on('close', () => this.emit('close')); + } + + _write(chunk: Buffer, encoding: string, callback: (error?: Error | null) => void): void { + try { + const nonce = Buffer.alloc(12); + nonce.writeBigUInt64BE(BigInt(this.sendCounter), 4); + this.sendCounter++; + + const cipher = createCipheriv('chacha20-poly1305', this.sessionKey, nonce, { + authTagLength: 16 + }); + const ciphertext = Buffer.concat([cipher.update(chunk), cipher.final()]); + const authTag = cipher.getAuthTag(); + + const packet = Buffer.concat([nonce, ciphertext, authTag]); + const lengthBuf = Buffer.alloc(4); + lengthBuf.writeUInt32BE(packet.length, 0); + + this.socket.write(Buffer.concat([lengthBuf, packet]), callback); + } catch (err) { + callback(err as Error); + } + } + + _read(size: number): void { + // Called when consumer wants to read, but we push data in handleIncomingData + } + + private handleIncomingData(chunk: Buffer): void { + this.recvBuffer = Buffer.concat([this.recvBuffer, chunk]); + + while (this.recvBuffer.length >= 4) { + const packetLength = this.recvBuffer.readUInt32BE(0); + + if (this.recvBuffer.length < 4 + packetLength) { + break; + } + + const packet = this.recvBuffer.slice(4, 4 + packetLength); + this.recvBuffer = this.recvBuffer.slice(4 + packetLength); + + try { + const nonce = packet.slice(0, 12); + const ciphertext = packet.slice(12, packet.length - 16); + const authTag = packet.slice(packet.length - 16); + + const decipher = createDecipheriv('chacha20-poly1305', this.sessionKey, nonce, { + authTagLength: 16 + }); + decipher.setAuthTag(authTag); + + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + this.push(plaintext); + } catch (err) { + this.emit('error', new Error(` + "`Decryption failed: ${(err as Error).message}`" + `)); + } + } + } + + setNoDelay(noDelay: boolean): this { + this.socket.setNoDelay(noDelay); + return this; + } + + end(callback?: () => void): this { + this.socket.end(callback); + return this; + } + + on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } +} + +export { EncryptedSocket, deriveKey, deriveSessionKey }; +` + +func generateAuthUtils(logger *slog.Logger, utilsDir string) error { + logger.Debug("Generating auth.ts") + outputFile := filepath.Join(utilsDir, "auth.ts") + + if err := os.WriteFile(outputFile, []byte(authUtilsTemplate), 0644); err != nil { + return fmt.Errorf("write auth.ts: %w", err) + } + + logger.Info("Generated auth.ts", "file", outputFile) + return nil +} diff --git a/internal/codegen/generator/typescript/binary_utils.go b/internal/codegen/generator/typescript/binary_utils.go new file mode 100644 index 00000000..e94d7c09 --- /dev/null +++ b/internal/codegen/generator/typescript/binary_utils.go @@ -0,0 +1,56 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" +) + +const binaryUtilsTemplate = `{{writeFileHeaderTS}} +export class BinaryWriter { + private chunks: Buffer[] = []; + + writeU8(v: number): void { this.chunks.push(Buffer.from([v & 0xFF])); } + writeI8(v: number): void { this.writeU8((v & 0xFF) >>> 0); } + writeU16LE(v: number): void { const b = Buffer.allocUnsafe(2); b.writeUInt16LE(v); this.chunks.push(b); } + writeI16LE(v: number): void { const b = Buffer.allocUnsafe(2); b.writeInt16LE(v); this.chunks.push(b); } + writeU32LE(v: number): void { const b = Buffer.allocUnsafe(4); b.writeUInt32LE(v); this.chunks.push(b); } + writeI32LE(v: number): void { const b = Buffer.allocUnsafe(4); b.writeInt32LE(v); this.chunks.push(b); } + writeU64LE(v: bigint): void { const b = Buffer.allocUnsafe(8); b.writeBigUInt64LE(v); this.chunks.push(b); } + writeI64LE(v: bigint): void { const b = Buffer.allocUnsafe(8); b.writeBigInt64LE(v); this.chunks.push(b); } + writeBytes(buf: Buffer): void { this.chunks.push(buf); } + + toBuffer(): Buffer { return Buffer.concat(this.chunks); } +} + +export class BinaryReader { + private offset = 0; + constructor(private buf: Buffer) {} + readU8(): number { return this.buf.readUInt8(this.offset++); } + readI8(): number { return this.buf.readInt8(this.offset++); } + readU16LE(): number { const v = this.buf.readUInt16LE(this.offset); this.offset += 2; return v; } + readI16LE(): number { const v = this.buf.readInt16LE(this.offset); this.offset += 2; return v; } + readU32LE(): number { const v = this.buf.readUInt32LE(this.offset); this.offset += 4; return v; } + readI32LE(): number { const v = this.buf.readInt32LE(this.offset); this.offset += 4; return v; } + readU64LE(): bigint { const v = this.buf.readBigUInt64LE(this.offset); this.offset += 8; return v; } + readI64LE(): bigint { const v = this.buf.readBigInt64LE(this.offset); this.offset += 8; return v; } +} +` + +func generateBinaryUtils(logger *slog.Logger, utilsDir string) error { + logger.Debug("Generating binary writer/reader utilities") + f, err := os.Create(filepath.Join(utilsDir, "binary.ts")) + if err != nil { + return fmt.Errorf("write binary.ts: %w", err) + } + defer f.Close() //nolint:errcheck + tmpl := template.Must(template.New("binaryts").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(binaryUtilsTemplate)) + if err := tmpl.Execute(f, nil); err != nil { + return fmt.Errorf("execute binary template: %w", err) + } + return nil +} diff --git a/internal/codegen/generator/typescript/client.go b/internal/codegen/generator/typescript/client.go new file mode 100644 index 00000000..dbf06f95 --- /dev/null +++ b/internal/codegen/generator/typescript/client.go @@ -0,0 +1,230 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +const clientTemplateTS = `{{writeFileHeaderTS}} +import { Socket } from 'net'; +import { TextDecoder, TextEncoder } from 'util'; +import type * as Types from './types/ManagementDtos'; +import { ViiperDevice } from './ViiperDevice'; +import { performAuthHandshake } from './utils/auth'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * VIIPER management & streaming API client. + * Request framing: [ ]\0 (null terminator) ; Response framing: single JSON line ending in \n then connection close. + * + * @param host - VIIPER server hostname or IP address + * @param port - VIIPER API server port (default: 3242) + * @param password - Authentication password (default: "" = no auth). Empty string explicitly means no authentication. + */ +export class ViiperClient { + private host: string; + private port: number; + private password: string; + + constructor(host: string, port: number = 3242, password: string = "") { + this.host = host; + this.port = port; + this.password = password; + } +{{range .Routes}}{{if eq .Method "Register"}} + /** + * {{.Handler}}: {{.Path}} + */{{if .ResponseDTO}} + async {{toCamelCase .Handler}}({{generateMethodParamsTS .}}): Promise {{else}} + async {{toCamelCase .Handler}}({{generateMethodParamsTS .}}): Promise {{end}}{ + const path = ` + "`" + `{{.Path}}` + "`" + `{{range $key, $value := .PathParams}}.replace("{{lb}}{{$key}}{{rb}}", String({{toCamelCase $key}})){{end}}; + {{if eq .Payload.Kind "none"}}const payload: string = '';{{else if eq .Payload.Kind "json"}}const payload: string = JSON.stringify({{payloadParamNameTS .}});{{else if eq .Payload.Kind "numeric"}}const payload: string = {{payloadParamNameTS .}} !== undefined && {{payloadParamNameTS .}} !== null ? String({{payloadParamNameTS .}}) : '';{{else if eq .Payload.Kind "string"}}const payload: string = {{payloadParamNameTS .}} ? String({{payloadParamNameTS .}}) : '';{{end}} + {{if .ResponseDTO}}return await this.sendRequest(path, payload);{{else}}await this.sendRequest(path, payload); return true;{{end}} + } +{{end}}{{end}} + private async sendRequest(path: string, payload?: string | null): Promise { + return new Promise(async (resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, async () => { + try { + socket.setNoDelay(true); + + let wrappedSocket: Socket | any = socket; + if (this.password) { + wrappedSocket = await performAuthHandshake(socket, this.password); + } + + let line = path; // preserve case + if (payload && payload.length > 0) line += ' ' + payload; + line += '\0'; + wrappedSocket.write(encoder.encode(line)); + + let buffer = ''; + const handleData = (chunk: Buffer) => { + buffer += decoder.decode(chunk); + const nlIdx = buffer.indexOf('\n'); + if (nlIdx !== -1) { + const jsonLine = buffer.slice(0, nlIdx); + let parsed: any; + try { + parsed = JSON.parse(jsonLine); + } catch (e) { + wrappedSocket.end(); + reject(e); + return; + } + if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { + wrappedSocket.end(); + reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); + return; + } + wrappedSocket.end(); + resolve(parsed as T); + } + }; + + wrappedSocket.on('data', handleData); + } catch (e) { + socket.end(); + reject(e); + } + }); + + socket.on('error', reject); + socket.on('end', () => {/* noop */}); + }); + } + + async connectDevice(busId: number, devId: string): Promise { + return new Promise(async (resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, async () => { + try { + socket.setNoDelay(true); + + let wrappedSocket: Socket | any = socket; + if (this.password) { + wrappedSocket = await performAuthHandshake(socket, this.password); + } + + const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; + wrappedSocket.write(encoder.encode(line)); + resolve(new ViiperDevice(wrappedSocket)); + } catch (e) { + socket.end(); + reject(e); + } + }); + socket.on('error', reject); + }); + } + + /** + * AddDeviceAndConnect: create a device (JSON request payload) then connect its stream. + * Returns the stream device handle and the full Device info response. + */ + async addDeviceAndConnect(busId: number, deviceCreateRequest: Types.DeviceCreateRequest): Promise<{ device: ViiperDevice; response: Types.Device }> { + const resp = await this.busdeviceadd(busId, deviceCreateRequest); + const devId = resp.devId; + if (!devId) { + throw new Error('Device response missing devId'); + } + const device = await this.connectDevice(busId, devId); + return { device, response: resp }; + } +} +` + +func generateClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error { + logger.Debug("Generating ViiperClient.ts management API") + outputFile := filepath.Join(srcDir, "ViiperClient.ts") + funcMap := template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + "toCamelCase": common.ToCamelCase, + "generateMethodParamsTS": generateMethodParamsTS, + "payloadParamNameTS": payloadParamNameTS, + "lb": func() string { return "{" }, + "rb": func() string { return "}" }, + } + tmpl, err := template.New("clientTS").Funcs(funcMap).Parse(clientTemplateTS) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + data := struct{ Routes []scanner.RouteInfo }{Routes: md.Routes} + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + logger.Info("Generated ViiperClient.ts", "file", outputFile) + return nil +} + +func generateMethodParamsTS(route scanner.RouteInfo) string { + var params []string + for key := range route.PathParams { + params = append(params, fmt.Sprintf("%s: number", common.ToCamelCase(key))) + } + switch route.Payload.Kind { + case scanner.PayloadJSON: + name := payloadParamNameTS(route) + ptype := "any" + if route.Payload.RawType != "" { + ptype = fmt.Sprintf("Types.%s", route.Payload.RawType) + } + params = append(params, fmt.Sprintf("%s: %s", name, ptype)) + case scanner.PayloadNumeric: + name := payloadParamNameTS(route) + if route.Payload.Required { + params = append(params, fmt.Sprintf("%s: number", name)) + } else { + params = append(params, fmt.Sprintf("%s?: number", name)) + } + case scanner.PayloadString: + name := payloadParamNameTS(route) + if route.Payload.Required { + params = append(params, fmt.Sprintf("%s: string", name)) + } else { + params = append(params, fmt.Sprintf("%s?: string", name)) + } + } + return strings.Join(params, ", ") +} + +func payloadParamNameTS(route scanner.RouteInfo) string { + if route.Payload.Kind == scanner.PayloadNone { + return "" + } + hint := route.Payload.ParserHint + if hint == "" { + return "payload" + } + switch route.Payload.Kind { + case scanner.PayloadNumeric: + if strings.Contains(strings.ToLower(hint), "id") || strings.HasPrefix(hint, "uint") || strings.HasPrefix(hint, "int") { + return "id" + } + return "value" + case scanner.PayloadJSON: + if route.Payload.RawType != "" { + return common.ToCamelCase(route.Payload.RawType) + } + return "request" + case scanner.PayloadString: + return "value" + } + return "payload" +} diff --git a/internal/codegen/generator/typescript/constants.go b/internal/codegen/generator/typescript/constants.go new file mode 100644 index 00000000..fab6e1ef --- /dev/null +++ b/internal/codegen/generator/typescript/constants.go @@ -0,0 +1,268 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +type tsEnumGroup struct { + Name string + Type string + Constants []tsConstInfo +} + +type tsConstInfo struct { + Name string + Value string +} + +type tsScalarConst struct { + Name string + Type string + Value string +} + +type tsMapData struct { + Name string + KeyType string + ValueType string + Entries []tsMapEntry +} + +type tsMapEntry struct { + Key string + Value string +} + +func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + deviceConsts, ok := md.DevicePackages[deviceName] + if !ok || deviceConsts == nil { + return nil + } + if len(deviceConsts.Constants) == 0 && len(deviceConsts.Maps) == 0 { + return nil + } + pascalDevice := common.ToPascalCase(deviceName) + outputPath := filepath.Join(deviceDir, pascalDevice+"Constants.ts") + enums := groupConstants(deviceConsts.Constants) + maps := convertMaps(deviceConsts.Maps) + data := struct { + Device string + Enums []tsEnumGroup + Scalars []tsScalarConst + Maps []tsMapData + }{Device: pascalDevice, Enums: enums, Scalars: extractScalarConstantsTS(deviceConsts.Constants), Maps: maps} + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + tmpl := template.Must(template.New("constsTS").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(constantsTemplateTS)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + logger.Info("Generated TS constants", "device", deviceName, "path", outputPath) + return nil +} + +func groupConstants(constants []scanner.ConstantInfo) []tsEnumGroup { + groups := map[string]*tsEnumGroup{} + for _, c := range constants { + if !common.IsIntegerConst(c.Value, c.Type) { + continue + } + prefix := common.ExtractPrefix(c.Name) + if prefix == "" { + continue + } + g := groups[prefix] + if g == nil { + g = &tsEnumGroup{Name: prefix, Type: "number"} + groups[prefix] = g + } + _, member := common.TrimPrefixAndSanitize(c.Name) + g.Constants = append(g.Constants, tsConstInfo{Name: member, Value: formatConstValueTS(c.Value)}) + } + var result []tsEnumGroup + for _, g := range groups { + if len(g.Constants) >= 3 { + result = append(result, *g) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func extractScalarConstantsTS(constants []scanner.ConstantInfo) []tsScalarConst { + result := make([]tsScalarConst, 0) + for _, c := range constants { + if common.IsIntegerConst(c.Value, c.Type) { + continue + } + result = append(result, tsScalarConst{ + Name: c.Name, + Type: goTypeToTS(c.Type), + Value: formatConstValueTS(c.Value), + }) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func formatConstValueTS(v interface{}) string { + switch t := v.(type) { + case int64: + if t < 0 { + return fmt.Sprintf("%d", t) + } + return fmt.Sprintf("0x%X", t) + case uint64: + return fmt.Sprintf("0x%X", t) + case int: + if t < 0 { + return fmt.Sprintf("%d", t) + } + return fmt.Sprintf("0x%X", t) + case string: + return fmt.Sprintf("'%s'", t) + case float64: + return fmt.Sprintf("%f", t) + default: + return fmt.Sprintf("%v", t) + } +} + +func convertMaps(maps []scanner.MapInfo) []tsMapData { + var result []tsMapData + for _, m := range maps { + md := tsMapData{Name: m.Name, KeyType: mapGoConstTypeToTS(m.KeyType), ValueType: mapGoConstTypeToTS(m.ValueType)} + keys := common.SortedStringKeys(m.Entries) + for _, k := range keys { + v := m.Entries[k] + md.Entries = append(md.Entries, tsMapEntry{Key: formatMapKeyTS(k, m.KeyType), Value: formatMapValueTS(v, m.ValueType)}) + } + result = append(result, md) + } + return result +} + +func mapGoConstTypeToTS(goType string) string { + if strings.Contains(goType, ".") { + return goType[strings.LastIndex(goType, ".")+1:] + } + switch goType { + case "int", "int8", "int16", "int32", "uint8", "byte", "uint16", "uint32", "uint64": + return "number" + case "string": + return "string" + case "bool": + return "boolean" + default: + return "number" + } +} + +func formatMapKeyTS(key string, goType string) string { + switch goType { + case "byte", "uint8": + if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { + if pfx := common.ExtractPrefix(key); pfx != "" { + _, member := common.TrimPrefixAndSanitize(key) + if member != "" { + return fmt.Sprintf("%s.%s", pfx, member) + } + } + } + if len(key) == 2 && key[0] == '\\' { + switch key[1] { + case 'n': + return "0x0A" + case 'r': + return "0x0D" + case 't': + return "0x09" + case '\\': + return "0x5C" + case '\'': + return "0x27" + } + } + if len(key) >= 1 { + return fmt.Sprintf("0x%02X", key[0]) + } + return key + case "string": + return fmt.Sprintf("\"%s\"", key) + default: + return key + } +} + +func formatMapValueTS(value interface{}, goType string) string { + switch goType { + case "byte", "uint8": + if str, ok := value.(string); ok && !strings.Contains(str, " ") { + if pfx := common.ExtractPrefix(str); pfx != "" { + _, member := common.TrimPrefixAndSanitize(str) + return fmt.Sprintf("%s.%s", pfx, member) + } + return str + } + return formatConstValueTS(value) + case "bool": + if b, ok := value.(bool); ok { + if b { + return "true" + } + return "false" + } + if str, ok := value.(string); ok { + return str + } + return "false" + case "string": + if str, ok := value.(string); ok { + return fmt.Sprintf("\"%s\"", str) + } + return formatConstValueTS(value) + default: + return formatConstValueTS(value) + } +} + +const constantsTemplateTS = `{{writeFileHeaderTS}} +// {{.Device}} constants and maps + +{{range .Enums}} +export enum {{.Name}} { +{{range .Constants}} {{.Name}} = {{.Value}}, +{{end}}} +{{end}} + +{{range .Scalars}} +export const {{.Name}}: {{.Type}} = {{.Value}}; +{{end}} + +{{range .Maps}} +export const {{.Name}}: Record = { +{{range .Entries}} [{{.Key}}]: {{.Value}}, +{{end}}}; + +export const {{.Name}}Get = (key: number, def?: {{.ValueType}}): {{.ValueType}} | undefined => { + return Object.prototype.hasOwnProperty.call({{.Name}}, key) ? {{.Name}}[key] : def; +}; + +export const {{.Name}}Has = (key: number): boolean => Object.prototype.hasOwnProperty.call({{.Name}}, key); +{{end}} +` diff --git a/internal/codegen/generator/typescript/device_specific.go b/internal/codegen/generator/typescript/device_specific.go new file mode 100644 index 00000000..3ad08b7d --- /dev/null +++ b/internal/codegen/generator/typescript/device_specific.go @@ -0,0 +1,160 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +type tsDeviceSpecificField struct { + Name string + TSName string + JSONName string + TSType string + Optional bool + MapAccess string +} + +type tsDeviceSpecificStruct struct { + Name string + FuncNamePrefix string + Fields []tsDeviceSpecificField +} + +const deviceSpecificTemplateTS = `{{writeFileHeaderTS}} +// Typed deviceSpecific helpers for {{.Device}} + +{{range .Structs}} +export interface {{.Name}} { +{{- range .Fields}} + {{.TSName}}{{if .Optional}}?{{end}}: {{.TSType}}; +{{- end}} +} + +export const {{.FuncNamePrefix}}ToMap = (value: {{.Name}}): Record => ({ +{{- range .Fields}} + {{.JSONName}}: value.{{.TSName}}, +{{- end}} +}); + +export const {{.FuncNamePrefix}}FromMap = (data: Record | undefined | null): {{.Name}} => { + const map = data ?? {}; + return { +{{- range .Fields}} + {{.TSName}}: map['{{.JSONName}}'] as {{.MapAccess}}, +{{- end}} + }; +}; + +{{end}} +` + +func generateDeviceSpecific(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + structs := md.DeviceStructs[deviceName] + if len(structs) == 0 { + return nil + } + + logger.Debug("Generating TS meta helpers", "device", deviceName) + + pascalDevice := common.ToPascalCase(deviceName) + legacyPath := filepath.Join(deviceDir, pascalDevice+"DeviceSpecific.ts") + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove legacy TS device-specific file: %w", err) + } + outputPath := filepath.Join(deviceDir, pascalDevice+"Meta.ts") + + data := struct { + Device string + Structs []tsDeviceSpecificStruct + }{ + Device: pascalDevice, + Structs: make([]tsDeviceSpecificStruct, 0, len(structs)), + } + + for _, s := range structs { + entry := tsDeviceSpecificStruct{ + Name: s.Name, + FuncNamePrefix: lowerFirst(s.Name), + Fields: make([]tsDeviceSpecificField, 0, len(s.Fields)), + } + for _, f := range s.Fields { + tsType := fieldTypeToTSForDeviceSpecific(f) + entry.Fields = append(entry.Fields, tsDeviceSpecificField{ + Name: f.Name, + TSName: lowerFirst(f.Name), + JSONName: f.JSONName, + TSType: tsType, + Optional: f.Optional, + MapAccess: tsMapAccessType(tsType), + }) + } + data.Structs = append(data.Structs, entry) + } + + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + + tmpl := template.Must(template.New("deviceSpecificTS").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(deviceSpecificTemplateTS)) + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + logger.Info("Generated TS meta helpers", "device", deviceName, "path", outputPath) + return nil +} + +func fieldTypeToTSForDeviceSpecific(field scanner.FieldInfo) string { + typeStr := field.Type + typeKind := field.TypeKind + + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + val, ok := parseGoMapType(typeStr) + if ok { + return "Record" + } + return "Record" + } + + if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + return goTypeToTS(elem) + "[]" + } + + if typeStr == "time.Time" { + return "string" + } + + if typeKind == "struct" { + return common.ToTypeName(typeStr) + } + + return goTypeToTS(typeStr) +} + +func tsMapAccessType(tsType string) string { + if strings.HasPrefix(tsType, "Record<") { + return "Record" + } + return tsType +} + +func lowerFirst(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/internal/codegen/generator/typescript/device_types.go b/internal/codegen/generator/typescript/device_types.go new file mode 100644 index 00000000..e83f40a7 --- /dev/null +++ b/internal/codegen/generator/typescript/device_types.go @@ -0,0 +1,193 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" +) + +func generateDeviceTypes(logger *slog.Logger, deviceDir string, deviceName string, md *meta.Metadata) error { + logger.Debug("Generating TS device types", "device", deviceName) + if md.WireTags == nil { + return nil + } + c2sTag := md.WireTags.GetTag(deviceName, "c2s") + s2cTag := md.WireTags.GetTag(deviceName, "s2c") + pascalDevice := common.ToPascalCase(deviceName) + if c2sTag != nil { + path := filepath.Join(deviceDir, pascalDevice+"Input.ts") + if err := generateWireClassTS(path, pascalDevice, "Input", c2sTag); err != nil { + return err + } + } + if s2cTag != nil { + path := filepath.Join(deviceDir, pascalDevice+"Output.ts") + if err := generateWireClassTS(path, pascalDevice, "Output", s2cTag); err != nil { + return err + } + } + return nil +} + +type tsWireField struct { + Name string + WireType string + BaseType string + Writer string + Reader string + IsArray bool + CountName string + FixedLen int +} + +func splitWireType(wireType string) (baseType string, countToken string, isArray bool) { + idx := strings.Index(wireType, "*") + if idx < 0 { + return wireType, "", false + } + baseType = wireType[:idx] + countToken = wireType[idx+1:] + return baseType, countToken, true +} + +func writerFor(goType string) string { + switch goType { + case "u8": + return "writeU8" + case "i8": + return "writeI8" + case "u16": + return "writeU16LE" + case "i16": + return "writeI16LE" + case "u32": + return "writeU32LE" + case "i32": + return "writeI32LE" + case "u64": + return "writeU64LE" + case "i64": + return "writeI64LE" + default: + return "writeU8" + } +} + +func readerFor(goType string) string { + switch goType { + case "u8": + return "readU8" + case "i8": + return "readI8" + case "u16": + return "readU16LE" + case "i16": + return "readI16LE" + case "u32": + return "readU32LE" + case "i32": + return "readI32LE" + case "u64": + return "readU64LE" + case "i64": + return "readI64LE" + default: + return "readU8" + } +} + +func generateWireClassTS(outputPath, device, className string, tag *scanner.WireTag) error { + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + data := struct { + Device string + ClassName string + Fields []tsWireField + }{Device: device, ClassName: className} + for _, field := range tag.Fields { + baseType, countToken, isArray := splitWireType(field.Type) + wf := tsWireField{ + Name: common.ToPascalCase(field.Name), + WireType: field.Type, + BaseType: baseType, + Writer: writerFor(baseType), + Reader: readerFor(baseType), + IsArray: isArray, + } + if isArray { + if n, err := strconv.Atoi(countToken); err == nil { + wf.FixedLen = n + } else { + wf.CountName = common.ToPascalCase(countToken) + } + } + data.Fields = append(data.Fields, wf) + } + funcMap := template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + "toCamelTS": func(s string) string { + if len(s) == 0 { + return s + } + return strings.ToLower(s[:1]) + s[1:] + }, + } + tmpl := template.Must(template.New("wirets").Funcs(funcMap).Parse(wireClassTemplateTS)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + return nil +} + +const wireClassTemplateTS = `{{writeFileHeaderTS}} +import { BinaryWriter, BinaryReader } from '../../utils/binary'; +import type { IBinarySerializable } from '../../ViiperDevice'; + +export class {{.Device}}{{.ClassName}} implements IBinarySerializable { +{{range .Fields}} {{.Name}}!: {{if or (eq .BaseType "u64") (eq .BaseType "i64")}}bigint{{else}}number{{end}}{{if .IsArray}}[]{{end}}; +{{end}} + constructor(init: Partial<{{.Device}}{{.ClassName}}> = {}) { + Object.assign(this, init); + } + write(writer: BinaryWriter): void { +{{range .Fields}}{{if .IsArray}}{{if gt .FixedLen 0}} + { + const arr = (this.{{.Name}} ?? []) as any[]; + for (let i = 0; i < {{.FixedLen}}; i++) { + writer.{{.Writer}}((arr[i] ?? 0) as any); + } + } +{{else}} + for (let i = 0; i < Number((this.{{.CountName}} as any)); i++) { + writer.{{.Writer}}((this.{{.Name}} as any[])[i]); + } +{{end}}{{else}} writer.{{.Writer}}(this.{{.Name}} as any); +{{end}}{{end}} } + static read(reader: BinaryReader): {{.Device}}{{.ClassName}} { +{{range .Fields}}{{if .IsArray}}{{if gt .FixedLen 0}} const {{.Name | toCamelTS}}: any[] = new Array({{.FixedLen}}); + for (let i = 0; i < {{.FixedLen}}; i++) { + {{.Name | toCamelTS}}[i] = reader.{{.Reader}}(); + } +{{else}} const {{.Name | toCamelTS}}: any[] = new Array(Number({{.CountName | toCamelTS}})); + for (let i = 0; i < Number({{.CountName | toCamelTS}}); i++) { + {{.Name | toCamelTS}}[i] = reader.{{.Reader}}(); + } +{{end}}{{else}} const {{.Name | toCamelTS}} = reader.{{.Reader}}(); +{{end}}{{end}} + return new {{.Device}}{{.ClassName}}({ +{{range .Fields}} {{.Name}}: {{.Name | toCamelTS}}, +{{end}} }); + } +} +` diff --git a/internal/codegen/generator/typescript/device_wrapper.go b/internal/codegen/generator/typescript/device_wrapper.go new file mode 100644 index 00000000..c1aec487 --- /dev/null +++ b/internal/codegen/generator/typescript/device_wrapper.go @@ -0,0 +1,63 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +func generateDeviceWrapper(logger *slog.Logger, srcDir string) error { + logger.Debug("Generating ViiperDevice.ts stream wrapper") + content := writeFileHeaderTS() + `import { Socket } from 'net'; +import { BinaryWriter } from './utils/binary'; +import { EventEmitter } from 'events'; + +export interface IBinarySerializable { + write(writer: BinaryWriter): void; +} + +export class ViiperDevice extends EventEmitter { + private socket: Socket; + + constructor(socket: Socket) { + super(); + this.socket = socket; + this.socket.on('data', (buf: Buffer) => { + // emit raw output frames; higher-level parsers can decode as needed + this.emit('output', buf); + }); + this.socket.on('error', (err: Error) => { + this.emit('error', err); + }); + this.socket.on('end', () => { + this.emit('end'); + }); + } + + async send(payload: T): Promise { + const writer = new BinaryWriter(); + payload.write(writer); + const buf = writer.toBuffer(); + await new Promise((resolve, reject) => { + this.socket.write(buf, (err) => err ? reject(err as Error) : resolve()); + }); + } + + async sendRaw(buf: Buffer): Promise { + await new Promise((resolve, reject) => { + this.socket.write(buf, (err) => err ? reject(err as Error) : resolve()); + }); + } + + close(): void { + this.socket.end(); + this.removeAllListeners(); + } +} +` + if err := os.WriteFile(filepath.Join(srcDir, "ViiperDevice.ts"), []byte(content), 0o644); err != nil { + return fmt.Errorf("write ViiperDevice.ts: %w", err) + } + return nil +} diff --git a/internal/codegen/generator/typescript/gen.go b/internal/codegen/generator/typescript/gen.go new file mode 100644 index 00000000..91db7f44 --- /dev/null +++ b/internal/codegen/generator/typescript/gen.go @@ -0,0 +1,82 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { + projectDir := outputDir + srcDir := filepath.Join(projectDir, "src") + typesDir := filepath.Join(srcDir, "types") + devicesDir := filepath.Join(srcDir, "devices") + utilsDir := filepath.Join(srcDir, "utils") + + for _, dir := range []string{projectDir, srcDir, typesDir, devicesDir, utilsDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + } + + version, err := common.GetVersion() + if err != nil { + return fmt.Errorf("get version: %w", err) + } + + if err := generateProject(logger, projectDir, version); err != nil { + return err + } + if err := generateIndex(logger, srcDir); err != nil { + return err + } + if err := generateBinaryUtils(logger, utilsDir); err != nil { + return err + } + if err := generateAuthUtils(logger, utilsDir); err != nil { + return err + } + if err := generateTypes(logger, typesDir, md); err != nil { + return err + } + if err := generateClient(logger, srcDir, md); err != nil { + return err + } + if err := generateDeviceWrapper(logger, srcDir); err != nil { + return err + } + + for deviceName := range md.DevicePackages { + deviceDir := filepath.Join(devicesDir, common.ToPascalCase(deviceName)) + if err := os.MkdirAll(deviceDir, 0o755); err != nil { + return fmt.Errorf("create device directory %s: %w", deviceDir, err) + } + if err := generateDeviceTypes(logger, deviceDir, deviceName, md); err != nil { + return err + } + if err := generateConstants(logger, deviceDir, deviceName, md); err != nil { + return err + } + if err := generateDeviceSpecific(logger, deviceDir, deviceName, md); err != nil { + return err + } + if err := generateDeviceIndex(logger, deviceDir, deviceName); err != nil { + return err + } + } + + if err := common.GenerateLicense(logger, projectDir); err != nil { + return err + } + + if err := common.GenerateReadme(logger, projectDir); err != nil { + return err + } + + logger.Info("Generated TypeScript client library", "dir", projectDir) + return nil +} diff --git a/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go new file mode 100644 index 00000000..9ec0909c --- /dev/null +++ b/internal/codegen/generator/typescript/helpers.go @@ -0,0 +1,40 @@ +package typescript + +import ( + "strings" + + "github.com/Alia5/VIIPER/internal/codegen/common" +) + +func goTypeToTS(goType string) string { + base, _, _ := common.NormalizeGoType(goType) + switch base { + case "byte", "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": + return "number" + case "bool": + return "boolean" + case "string": + return "string" + case "any", "interface{}": + return "unknown" + default: + return common.ToTypeName(base) + } +} + +func parseGoMapType(typeStr string) (string, bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", false + } + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false + } + return valueType, true +} + +func writeFileHeaderTS() string { return common.FileHeader("//", "TypeScript") } diff --git a/internal/codegen/generator/typescript/index.go b/internal/codegen/generator/typescript/index.go new file mode 100644 index 00000000..1a4990bb --- /dev/null +++ b/internal/codegen/generator/typescript/index.go @@ -0,0 +1,87 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" +) + +const indexTemplate = `{{writeFileHeaderTS}} +export * from './ViiperClient'; +export * from './ViiperDevice'; +export * as Types from './types/ManagementDtos'; +export * as Keyboard from './devices/Keyboard'; +export * as Mouse from './devices/Mouse'; +export * as Xbox360 from './devices/Xbox360'; +` + +const deviceIndexTemplate = `{{writeFileHeaderTS}} +export * from './{{.PascalName}}Input'; +{{if .HasOutput}}export * from './{{.PascalName}}Output'; +{{end}}export * from './{{.PascalName}}Constants'; +{{if .HasMeta}}export * from './{{.PascalName}}Meta'; +{{end}} +` + +func generateIndex(logger *slog.Logger, srcDir string) error { + logger.Debug("Generating index.ts re-exports") + f, err := os.Create(filepath.Join(srcDir, "index.ts")) + if err != nil { + return fmt.Errorf("write index.ts: %w", err) + } + defer f.Close() //nolint:errcheck + tmpl := template.Must(template.New("index").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(indexTemplate)) + if err := tmpl.Execute(f, nil); err != nil { + return fmt.Errorf("execute index template: %w", err) + } + return nil +} + +func generateDeviceIndex(logger *slog.Logger, deviceDir, deviceName string) error { + logger.Debug("Generating device index.ts", "device", deviceName) + + pascalName := common.ToPascalCase(deviceName) + + hasOutput := false + outputPath := filepath.Join(deviceDir, pascalName+"Output.ts") + if _, err := os.Stat(outputPath); err == nil { + hasOutput = true + } + + hasMeta := false + metaPath := filepath.Join(deviceDir, pascalName+"Meta.ts") + if _, err := os.Stat(metaPath); err == nil { + hasMeta = true + } + + f, err := os.Create(filepath.Join(deviceDir, "index.ts")) + if err != nil { + return fmt.Errorf("write device index.ts: %w", err) + } + defer f.Close() //nolint:errcheck + + tmpl := template.Must(template.New("deviceIndex").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(deviceIndexTemplate)) + + data := struct { + PascalName string + HasOutput bool + HasMeta bool + }{ + PascalName: pascalName, + HasOutput: hasOutput, + HasMeta: hasMeta, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute device index template: %w", err) + } + return nil +} diff --git a/internal/codegen/generator/typescript/project.go b/internal/codegen/generator/typescript/project.go new file mode 100644 index 00000000..3027de06 --- /dev/null +++ b/internal/codegen/generator/typescript/project.go @@ -0,0 +1,86 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +const packageJSONTemplate = `{ + "name": "viiperclient", + "version": "{{.Version}}", + "description": "VIIPER Client Library for TypeScript/Node.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Alia5/VIIPER.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./devices/*": { + "types": "./dist/devices/*/index.d.ts", + "default": "./dist/devices/*/index.js" + } + }, + "scripts": { + "build": "tsc -p .", + "clean": "rimraf dist" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "24.10.1", + "rimraf": "6.1.0", + "typescript": "5.9.3" + } +} +` + +const tsconfigTemplate = `{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} +` + +func generateProject(logger *slog.Logger, projectDir, version string) error { + logger.Debug("Generating TypeScript project scaffolding") + + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(packageTemplateFor(version)), 0o644); err != nil { + return fmt.Errorf("write package.json: %w", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "tsconfig.json"), []byte(tsconfigTemplate), 0o644); err != nil { + return fmt.Errorf("write tsconfig.json: %w", err) + } + + logger.Info("Generated TypeScript package.json and tsconfig.json", "version", version) + return nil +} + +func packageTemplateFor(version string) string { + tmpl, err := template.New("pkg").Parse(packageJSONTemplate) + if err != nil { + return "{}" + } + var buf strings.Builder + _ = tmpl.Execute(&buf, struct{ Version string }{Version: version}) + return buf.String() +} diff --git a/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go new file mode 100644 index 00000000..837ad46c --- /dev/null +++ b/internal/codegen/generator/typescript/types.go @@ -0,0 +1,76 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const dtoTemplateTS = `{{writeFileHeaderTS}} +// Management API DTO interfaces + +{{range .DTOs}} +// {{.Name}} DTO +export interface {{.Name}} { +{{- range .Fields}} + {{.JSONName}}{{if .Optional}}?{{end}}: {{fieldTypeToTS .}}; +{{end}} +} + +{{end}} +` + +func generateTypes(logger *slog.Logger, typesDir string, md *meta.Metadata) error { + logger.Debug("Generating management API DTO interfaces (TypeScript)") + outputFile := filepath.Join(typesDir, "ManagementDtos.ts") + + funcMap := template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + "fieldTypeToTS": fieldTypeToTS, + } + tmpl, err := template.New("dtos").Funcs(funcMap).Parse(dtoTemplateTS) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() //nolint:errcheck + data := struct{ DTOs interface{} }{DTOs: md.DTOs} + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + logger.Info("Generated DTO interfaces", "file", outputFile) + return nil +} + +func fieldTypeToTS(field interface{}) string { + v := reflect.ValueOf(field) + typeStr := v.FieldByName("Type").String() + typeKind := v.FieldByName("TypeKind").String() + + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + val, ok := parseGoMapType(typeStr) + if ok { + return "Record" + } + return "Record" + } + + if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + return goTypeToTS(elem) + "[]" + } + if typeKind == "struct" { + return common.ToTypeName(typeStr) + } + return goTypeToTS(typeStr) +} diff --git a/viiper/internal/codegen/meta/meta.go b/internal/codegen/meta/meta.go similarity index 59% rename from viiper/internal/codegen/meta/meta.go rename to internal/codegen/meta/meta.go index 2ce6dc96..eff5dffa 100644 --- a/viiper/internal/codegen/meta/meta.go +++ b/internal/codegen/meta/meta.go @@ -1,12 +1,14 @@ -package meta - -import "viiper/internal/codegen/scanner" - -// Metadata holds all scanned information needed for code generation -// Shared between generator orchestrator and language-specific generators. -type Metadata struct { - Routes []scanner.RouteInfo - DTOs []scanner.DTOSchema - DevicePackages map[string]*scanner.DeviceConstants // device name -> constants/maps - WireTags *scanner.WireTags // parsed viiper:wire comments -} +package meta + +import "github.com/Alia5/VIIPER/internal/codegen/scanner" + +// Metadata holds all scanned information needed for code generation +// Shared between generator orchestrator and language-specific generators. +type Metadata struct { + Routes []scanner.RouteInfo + DTOs []scanner.DTOSchema + DevicePackages map[string]*scanner.DeviceConstants // device name -> constants/maps + DeviceStructs map[string][]scanner.DTOSchema // device name -> JSON-tagged structs for deviceSpecific helpers + WireTags *scanner.WireTags // parsed viiper:wire comments + CTypeNames map[string]string // DTO name -> C typedef name (e.g., "Device" -> "device_info") +} diff --git a/internal/codegen/scanner/constants.go b/internal/codegen/scanner/constants.go new file mode 100644 index 00000000..5150bed4 --- /dev/null +++ b/internal/codegen/scanner/constants.go @@ -0,0 +1,453 @@ +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "math" + "os" + "path/filepath" + "strconv" + "strings" +) + +// ConstantInfo represents a single constant definition +type ConstantInfo struct { + Name string `json:"name"` + Value any `json:"value"` // Can be int, string, etc. + Type string `json:"type"` // e.g., "int", "uint8", "string" +} + +// MapInfo represents a map variable with its entries +type MapInfo struct { + Name string `json:"name"` + KeyType string `json:"keyType"` + ValueType string `json:"valueType"` + Entries map[string]any `json:"entries"` // Key as string, value as interface{} +} + +// DeviceConstants holds all constants and maps for a device package +type DeviceConstants struct { + DeviceType string `json:"deviceType"` + Constants []ConstantInfo `json:"constants"` + Maps []MapInfo `json:"maps"` +} + +// ScanDeviceConstants scans a device package directory for constants and maps +func ScanDeviceConstants(devicePkgPath string) (*DeviceConstants, error) { + deviceType := filepath.Base(devicePkgPath) + result := &DeviceConstants{ + DeviceType: deviceType, + Constants: []ConstantInfo{}, + Maps: []MapInfo{}, + } + constEnv := make(map[string]ConstantInfo) + + entries, err := os.ReadDir(devicePkgPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", devicePkgPath, err) + } + + fset := token.NewFileSet() + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + + filePath := filepath.Join(devicePkgPath, entry.Name()) + file, err := parseFile(fset, filePath) + if err != nil { + continue + } + + for _, decl := range file.Decls { + if genDecl, ok := decl.(*ast.GenDecl); ok { + switch genDecl.Tok { + case token.CONST: + result.Constants = append(result.Constants, extractConstants(genDecl, constEnv)...) + case token.VAR: + maps := extractMaps(genDecl) + result.Maps = append(result.Maps, maps...) + } + } + } + } + + return result, nil +} + +func parseFile(fset *token.FileSet, filePath string) (*ast.File, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + return parseGoSource(fset, filePath, data) +} + +func parseGoSource(fset *token.FileSet, filename string, src []byte) (*ast.File, error) { + return parser.ParseFile(fset, filename, src, parser.ParseComments) +} + +func extractConstants(genDecl *ast.GenDecl, env map[string]ConstantInfo) []ConstantInfo { + var constants []ConstantInfo + var previousValues []ast.Expr + previousType := "" + iotaValue := 0 + + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + currentType := previousType + if len(valueSpec.Values) > 0 { + currentType = "" + } + if valueSpec.Type != nil { + currentType = exprToString(valueSpec.Type) + } + + exprs := previousValues + if len(valueSpec.Values) > 0 { + exprs = valueSpec.Values + previousValues = valueSpec.Values + previousType = currentType + } + + evaluator := constEvaluator{env: env, iotaValue: iotaValue} + + for i, name := range valueSpec.Names { + if len(exprs) == 0 { + continue + } + exprIndex := i + if exprIndex >= len(exprs) { + exprIndex = len(exprs) - 1 + } + + constInfo := ConstantInfo{ + Name: name.Name, + Type: currentType, + } + constInfo.Value = evaluator.eval(exprs[exprIndex]) + if currentType == "" { + constInfo.Type = inferType(constInfo.Value) + } + env[name.Name] = constInfo + + if name.IsExported() { + constants = append(constants, constInfo) + } + } + + iotaValue++ + } + + return constants +} + +type constEvaluator struct { + env map[string]ConstantInfo + iotaValue int +} + +func (e constEvaluator) eval(expr ast.Expr) interface{} { + switch v := expr.(type) { + case *ast.ParenExpr: + return e.eval(v.X) + case *ast.BasicLit: + switch v.Kind { + case token.INT: + if val, err := strconv.ParseInt(v.Value, 0, 64); err == nil { + return val + } + if val, err := strconv.ParseUint(v.Value, 0, 64); err == nil { + return val + } + case token.STRING: + return strings.Trim(v.Value, `"`) + case token.FLOAT: + if val, err := strconv.ParseFloat(v.Value, 64); err == nil { + return val + } + case token.CHAR: + if unquoted, err := strconv.Unquote(v.Value); err == nil { + return unquoted + } + return strings.Trim(v.Value, "'") + } + case *ast.Ident: + if v.Name == "iota" { + return int64(e.iotaValue) + } + if info, ok := e.env[v.Name]; ok { + return info.Value + } + return v.Name + case *ast.BinaryExpr: + left := e.eval(v.X) + right := e.eval(v.Y) + if result, ok := evalBinaryExpr(v.Op, left, right); ok { + return result + } + return fmt.Sprintf("%v %s %v", left, v.Op.String(), right) + case *ast.UnaryExpr: + value := e.eval(v.X) + if result, ok := evalUnaryExpr(v.Op, value); ok { + return result + } + return fmt.Sprintf("%s%v", v.Op.String(), value) + case *ast.SelectorExpr: + name := exprToString(v) + if info, ok := e.env[name]; ok { + return info.Value + } + return name + } + return nil +} + +func evalBinaryExpr(op token.Token, left interface{}, right interface{}) (interface{}, bool) { + if ls, ok := left.(string); ok && op == token.ADD { + if rs, ok := right.(string); ok { + return ls + rs, true + } + } + + lu, lok := toUint64(left) + ru, rok := toUint64(right) + if lok && rok { + switch op { + case token.SHL: + if ru < 64 { + return lu << ru, true + } + case token.SHR: + if ru < 64 { + return lu >> ru, true + } + case token.OR: + return lu | ru, true + case token.AND: + return lu & ru, true + case token.XOR: + return lu ^ ru, true + case token.ADD: + return lu + ru, true + case token.SUB: + if lu >= ru { + return lu - ru, true + } + case token.MUL: + return lu * ru, true + case token.QUO: + if ru != 0 { + return lu / ru, true + } + } + } + + li, lok := toInt64(left) + ri, rok := toInt64(right) + if lok && rok { + switch op { + case token.ADD: + return li + ri, true + case token.SUB: + return li - ri, true + case token.MUL: + return li * ri, true + case token.QUO: + if ri != 0 { + return li / ri, true + } + } + } + + return nil, false +} + +func evalUnaryExpr(op token.Token, value interface{}) (interface{}, bool) { + if u, ok := toUint64(value); ok { + switch op { + case token.ADD: + return u, true + case token.XOR: + return ^u, true + } + } + + if i, ok := toInt64(value); ok { + switch op { + case token.ADD: + return i, true + case token.SUB: + return -i, true + case token.XOR: + return ^i, true + } + } + + return nil, false +} + +func toUint64(value any) (uint64, bool) { + switch v := value.(type) { + case uint64: + return v, true + case int64: + if v < 0 { + return 0, false + } + return uint64(v), true + case int: + if v < 0 { + return 0, false + } + return uint64(v), true + case string: + if parsed, err := strconv.ParseUint(v, 0, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +func toInt64(value any) (int64, bool) { + switch v := value.(type) { + case int64: + return v, true + case uint64: + if v > uint64(math.MaxInt64) { + return 0, false + } + return int64(v), true + case int: + return int64(v), true + case string: + if parsed, err := strconv.ParseInt(v, 0, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +func extractMaps(genDecl *ast.GenDecl) []MapInfo { + var maps []MapInfo + + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for i, name := range valueSpec.Names { + if !name.IsExported() { + continue + } + + var mapType *ast.MapType + + if valueSpec.Type != nil { + if mt, ok := valueSpec.Type.(*ast.MapType); ok { + mapType = mt + } + } + + if mapType == nil && i < len(valueSpec.Values) { + if compositeLit, ok := valueSpec.Values[i].(*ast.CompositeLit); ok { + if mt, ok := compositeLit.Type.(*ast.MapType); ok { + mapType = mt + } + } + } + + if mapType == nil { + continue + } + + mapInfo := MapInfo{ + Name: name.Name, + KeyType: exprToString(mapType.Key), + ValueType: exprToString(mapType.Value), + Entries: make(map[string]interface{}), + } + + if i < len(valueSpec.Values) { + if compositeLit, ok := valueSpec.Values[i].(*ast.CompositeLit); ok { + mapInfo.Entries = extractMapEntries(compositeLit) + } + } + + maps = append(maps, mapInfo) + } + } + + return maps +} + +func extractMapEntries(compositeLit *ast.CompositeLit) map[string]interface{} { + entries := make(map[string]interface{}) + + for _, elt := range compositeLit.Elts { + kvExpr, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + + key := extractValue(kvExpr.Key) + value := extractValue(kvExpr.Value) + + keyStr := fmt.Sprintf("%v", key) + entries[keyStr] = value + } + + return entries +} + +func extractValue(expr ast.Expr) interface{} { + return constEvaluator{}.eval(expr) +} + +func exprToString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + return exprToString(e.X) + "." + e.Sel.Name + case *ast.StarExpr: + return "*" + exprToString(e.X) + case *ast.ArrayType: + if e.Len != nil { + return fmt.Sprintf("[%s]%s", exprToString(e.Len), exprToString(e.Elt)) + } + return "[]" + exprToString(e.Elt) + case *ast.MapType: + return fmt.Sprintf("map[%s]%s", exprToString(e.Key), exprToString(e.Value)) + } + return "" +} + +func inferType(value interface{}) string { + switch v := value.(type) { + case int64: + if v >= 0 && v <= 255 { + return "uint8" + } + return "int" + case uint64: + if v <= 255 { + return "uint8" + } + return "uint" + case string: + return "string" + case float64: + return "float64" + default: + return "unknown" + } +} diff --git a/viiper/internal/codegen/scanner/constants_test.go b/internal/codegen/scanner/constants_test.go similarity index 54% rename from viiper/internal/codegen/scanner/constants_test.go rename to internal/codegen/scanner/constants_test.go index e50fde79..e0d72d2a 100644 --- a/viiper/internal/codegen/scanner/constants_test.go +++ b/internal/codegen/scanner/constants_test.go @@ -1,56 +1,86 @@ -package scanner - -import ( - "path/filepath" - "testing" -) - -func TestScanKeyboardConstants(t *testing.T) { - // Relative path from scanner package to keyboard device - keyboardPath := filepath.Join("..", "..", "..", "pkg", "device", "keyboard") - - result, err := ScanDeviceConstants(keyboardPath) - if err != nil { - t.Fatalf("Failed to scan keyboard constants: %v", err) - } - - if result.DeviceType != "keyboard" { - t.Errorf("Expected deviceType 'keyboard', got '%s'", result.DeviceType) - } - - if len(result.Constants) == 0 { - t.Errorf("Expected to find keyboard constants, got none") - } - - // Should find 3 maps: KeyName, CharToKey, ShiftChars - if len(result.Maps) != 3 { - t.Errorf("Expected 3 maps, got %d", len(result.Maps)) - for i, m := range result.Maps { - t.Logf("Map %d: %s (keyType: %s, valueType: %s, entries: %d)", - i, m.Name, m.KeyType, m.ValueType, len(m.Entries)) - } - } - - t.Logf("Found %d constants and %d maps", len(result.Constants), len(result.Maps)) -} - -func TestScanXbox360Constants(t *testing.T) { - xbox360Path := filepath.Join("..", "..", "..", "pkg", "device", "xbox360") - - result, err := ScanDeviceConstants(xbox360Path) - if err != nil { - t.Fatalf("Failed to scan xbox360 constants: %v", err) - } - - // Should find 15 button constants - if len(result.Constants) != 15 { - t.Errorf("Expected 15 button constants, got %d", len(result.Constants)) - } - - // Xbox360 has no maps - if len(result.Maps) != 0 { - t.Errorf("Expected 0 maps, got %d", len(result.Maps)) - } - - t.Logf("Found %d constants", len(result.Constants)) -} +package scanner + +import ( + "path/filepath" + "testing" +) + +func TestScanKeyboardConstants(t *testing.T) { + // Relative path from scanner package to keyboard device + keyboardPath := filepath.Join("..", "..", "..", "device", "keyboard") + + result, err := ScanDeviceConstants(keyboardPath) + if err != nil { + t.Fatalf("Failed to scan keyboard constants: %v", err) + } + + if result.DeviceType != "keyboard" { + t.Errorf("Expected deviceType 'keyboard', got '%s'", result.DeviceType) + } + + if len(result.Constants) == 0 { + t.Errorf("Expected to find keyboard constants, got none") + } + + // Should find 3 maps: KeyName, CharToKey, ShiftChars + if len(result.Maps) != 3 { + t.Errorf("Expected 3 maps, got %d", len(result.Maps)) + for i, m := range result.Maps { + t.Logf("Map %d: %s (keyType: %s, valueType: %s, entries: %d)", + i, m.Name, m.KeyType, m.ValueType, len(m.Entries)) + } + } + + t.Logf("Found %d constants and %d maps", len(result.Constants), len(result.Maps)) +} + +func TestScanXbox360Constants(t *testing.T) { + xbox360Path := filepath.Join("..", "..", "..", "device", "xbox360") + + result, err := ScanDeviceConstants(xbox360Path) + if err != nil { + t.Fatalf("Failed to scan xbox360 constants: %v", err) + } + + // Should find 15 button constants + if len(result.Constants) != 15 { + t.Errorf("Expected 15 button constants, got %d", len(result.Constants)) + } + + // Xbox360 has no maps + if len(result.Maps) != 0 { + t.Errorf("Expected 0 maps, got %d", len(result.Maps)) + } + + t.Logf("Found %d constants", len(result.Constants)) +} + +func TestScanNs2ProConstants(t *testing.T) { + ns2proPath := filepath.Join("..", "..", "..", "device", "ns2pro") + + result, err := ScanDeviceConstants(ns2proPath) + if err != nil { + t.Fatalf("Failed to scan ns2pro constants: %v", err) + } + + constants := make(map[string]ConstantInfo, len(result.Constants)) + for _, c := range result.Constants { + constants[c.Name] = c + } + + if got := constants["ButtonB"].Value; got != uint64(1) { + t.Fatalf("expected ButtonB to equal 1, got %#v", got) + } + if got := constants["ButtonA"].Value; got != uint64(2) { + t.Fatalf("expected ButtonA to equal 2, got %#v", got) + } + if got := constants["ButtonHeadset"].Value; got != uint64(1<<21) { + t.Fatalf("expected ButtonHeadset to equal 1<<21, got %#v", got) + } + if got := constants["DefaultSerialEnding"].Value; got != "00" { + t.Fatalf("expected DefaultSerialEnding to equal 00, got %#v", got) + } + if got := constants["DefaultSerial"].Value; got != "VIIPER-NS2PRO-00" { + t.Fatalf("expected DefaultSerial to equal VIIPER-NS2PRO-00, got %#v", got) + } +} diff --git a/internal/codegen/scanner/device_structs.go b/internal/codegen/scanner/device_structs.go new file mode 100644 index 00000000..708ef0da --- /dev/null +++ b/internal/codegen/scanner/device_structs.go @@ -0,0 +1,120 @@ +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" +) + +// ScanDeviceStructs scans a device package for JSON-tagged struct types that can be +// used as typed deviceSpecific helpers in generated SDKs. +func ScanDeviceStructs(devicePkgPath string) ([]DTOSchema, error) { + entries, err := os.ReadDir(devicePkgPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", devicePkgPath, err) + } + + fset := token.NewFileSet() + var schemas []DTOSchema + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + + filePath := filepath.Join(devicePkgPath, entry.Name()) + file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + continue + } + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + schema := DTOSchema{Name: typeSpec.Name.Name} + hasJSONField := false + + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + continue + } + + fieldName := field.Names[0].Name + if !ast.IsExported(fieldName) { + continue + } + + jsonName, hasJSON, optional := parseJSONTag(field, fieldName) + if !hasJSON { + continue + } + hasJSONField = true + + typeName, typeKind := extractTypeInfo(field.Type) + if _, isPtr := field.Type.(*ast.StarExpr); isPtr { + optional = true + } + + schema.Fields = append(schema.Fields, FieldInfo{ + Name: fieldName, + JSONName: jsonName, + Type: typeName, + TypeKind: typeKind, + Optional: optional, + }) + } + + if hasJSONField && len(schema.Fields) > 0 { + schemas = append(schemas, schema) + } + } + } + } + + return schemas, nil +} + +func parseJSONTag(field *ast.Field, fallback string) (jsonName string, hasJSON bool, optional bool) { + jsonName = fallback + if field.Tag == nil { + return jsonName, false, false + } + + tag := strings.Trim(field.Tag.Value, "`") + jsonTag := reflect.StructTag(tag).Get("json") + if jsonTag == "" || jsonTag == "-" { + return jsonName, false, false + } + + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + jsonName = parts[0] + } + for _, part := range parts[1:] { + if part == "omitempty" { + optional = true + break + } + } + + return jsonName, true, optional +} diff --git a/viiper/internal/codegen/scanner/dtos.go b/internal/codegen/scanner/dtos.go similarity index 95% rename from viiper/internal/codegen/scanner/dtos.go rename to internal/codegen/scanner/dtos.go index ce20ff1a..31653b57 100644 --- a/viiper/internal/codegen/scanner/dtos.go +++ b/internal/codegen/scanner/dtos.go @@ -1,189 +1,189 @@ -package scanner - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "reflect" - "strings" -) - -// DTOSchema represents the schema of a response DTO. -type DTOSchema struct { - Name string `json:"name"` // Type name (e.g., "BusCreateResponse") - Fields []FieldInfo `json:"fields"` // Struct fields -} - -// FieldInfo describes a single field in a DTO. -type FieldInfo struct { - Name string `json:"name"` // Go field name (e.g., "BusID") - JSONName string `json:"jsonName"` // JSON tag name (e.g., "busId") - Type string `json:"type"` // Go type (e.g., "uint32", "[]Device") - TypeKind string `json:"typeKind"` // Kind: "primitive", "slice", "struct" - Optional bool `json:"optional"` // Whether field can be omitted (pointer or has omitempty) -} - -// ScanDTOs scans a Go file containing DTO struct definitions and extracts their schemas. -func ScanDTOs(filePath string) ([]DTOSchema, error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - var schemas []DTOSchema - - ast.Inspect(node, func(n ast.Node) bool { - genDecl, ok := n.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - return true - } - - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - continue - } - - schema := DTOSchema{ - Name: typeSpec.Name.Name, - Fields: []FieldInfo{}, - } - - for _, field := range structType.Fields.List { - if len(field.Names) == 0 { - continue - } - - fieldName := field.Names[0].Name - - if !ast.IsExported(fieldName) { - continue - } - - jsonName := fieldName - optional := false - if field.Tag != nil { - tag := strings.Trim(field.Tag.Value, "`") - jsonTag := reflect.StructTag(tag).Get("json") - if jsonTag != "" && jsonTag != "-" { - parts := strings.Split(jsonTag, ",") - jsonName = parts[0] - for _, part := range parts[1:] { - if part == "omitempty" { - optional = true - break - } - } - } - } - - typeName, typeKind := extractTypeInfo(field.Type) - - if _, isPtr := field.Type.(*ast.StarExpr); isPtr { - optional = true - } - - schema.Fields = append(schema.Fields, FieldInfo{ - Name: fieldName, - JSONName: jsonName, - Type: typeName, - TypeKind: typeKind, - Optional: optional, - }) - } - - schemas = append(schemas, schema) - } - - return true - }) - - return schemas, nil -} - -func extractTypeInfo(expr ast.Expr) (typeName string, typeKind string) { - switch t := expr.(type) { - case *ast.Ident: - return t.Name, determineTypeKind(t.Name) - case *ast.StarExpr: - innerType, innerKind := extractTypeInfo(t.X) - return "*" + innerType, innerKind - case *ast.ArrayType: - if t.Len == nil { - elemType, _ := extractTypeInfo(t.Elt) - return "[]" + elemType, "slice" - } - elemType, _ := extractTypeInfo(t.Elt) - return "[]" + elemType, "array" - case *ast.SelectorExpr: - if ident, ok := t.X.(*ast.Ident); ok { - return ident.Name + "." + t.Sel.Name, "struct" - } - return t.Sel.Name, "struct" - case *ast.MapType: - keyType, _ := extractTypeInfo(t.Key) - valueType, _ := extractTypeInfo(t.Value) - return "map[" + keyType + "]" + valueType, "map" - default: - return "unknown", "unknown" - } -} - -func determineTypeKind(typeName string) string { - primitives := map[string]bool{ - "bool": true, - "string": true, - "int": true, - "int8": true, - "int16": true, - "int32": true, - "int64": true, - "uint": true, - "uint8": true, - "uint16": true, - "uint32": true, - "uint64": true, - "byte": true, - "rune": true, - "float32": true, - "float64": true, - "complex64": true, - "complex128": true, - } - - if primitives[typeName] { - return "primitive" - } - return "struct" -} - -// ScanDTOsInPackage scans all Go files in a package and extracts DTO schemas. -func ScanDTOsInPackage(pkgPath string) ([]DTOSchema, error) { - matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) - if err != nil { - return nil, fmt.Errorf("glob package files: %w", err) - } - - var allSchemas []DTOSchema - for _, file := range matches { - if strings.HasSuffix(file, "_test.go") { - continue - } - - schemas, err := ScanDTOs(file) - if err != nil { - return nil, fmt.Errorf("scan %s: %w", file, err) - } - allSchemas = append(allSchemas, schemas...) - } - - return allSchemas, nil -} +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "strings" +) + +// DTOSchema represents the schema of a response DTO. +type DTOSchema struct { + Name string `json:"name"` // Type name (e.g., "BusCreateResponse") + Fields []FieldInfo `json:"fields"` // Struct fields +} + +// FieldInfo describes a single field in a DTO. +type FieldInfo struct { + Name string `json:"name"` // Go field name (e.g., "BusID") + JSONName string `json:"jsonName"` // JSON tag name (e.g., "busId") + Type string `json:"type"` // Go type (e.g., "uint32", "[]Device") + TypeKind string `json:"typeKind"` // Kind: "primitive", "slice", "struct" + Optional bool `json:"optional"` // Whether field can be omitted (pointer or has omitempty) +} + +// ScanDTOs scans a Go file containing DTO struct definitions and extracts their schemas. +func ScanDTOs(filePath string) ([]DTOSchema, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse file: %w", err) + } + + var schemas []DTOSchema + + ast.Inspect(node, func(n ast.Node) bool { + genDecl, ok := n.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + return true + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + schema := DTOSchema{ + Name: typeSpec.Name.Name, + Fields: []FieldInfo{}, + } + + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + continue + } + + fieldName := field.Names[0].Name + + if !ast.IsExported(fieldName) { + continue + } + + jsonName := fieldName + optional := false + if field.Tag != nil { + tag := strings.Trim(field.Tag.Value, "`") + jsonTag := reflect.StructTag(tag).Get("json") + if jsonTag != "" && jsonTag != "-" { + parts := strings.Split(jsonTag, ",") + jsonName = parts[0] + for _, part := range parts[1:] { + if part == "omitempty" { + optional = true + break + } + } + } + } + + typeName, typeKind := extractTypeInfo(field.Type) + + if _, isPtr := field.Type.(*ast.StarExpr); isPtr { + optional = true + } + + schema.Fields = append(schema.Fields, FieldInfo{ + Name: fieldName, + JSONName: jsonName, + Type: typeName, + TypeKind: typeKind, + Optional: optional, + }) + } + + schemas = append(schemas, schema) + } + + return true + }) + + return schemas, nil +} + +func extractTypeInfo(expr ast.Expr) (typeName string, typeKind string) { + switch t := expr.(type) { + case *ast.Ident: + return t.Name, determineTypeKind(t.Name) + case *ast.StarExpr: + innerType, innerKind := extractTypeInfo(t.X) + return "*" + innerType, innerKind + case *ast.ArrayType: + if t.Len == nil { + elemType, _ := extractTypeInfo(t.Elt) + return "[]" + elemType, "slice" + } + elemType, _ := extractTypeInfo(t.Elt) + return "[]" + elemType, "array" + case *ast.SelectorExpr: + if ident, ok := t.X.(*ast.Ident); ok { + return ident.Name + "." + t.Sel.Name, "struct" + } + return t.Sel.Name, "struct" + case *ast.MapType: + keyType, _ := extractTypeInfo(t.Key) + valueType, _ := extractTypeInfo(t.Value) + return "map[" + keyType + "]" + valueType, "map" + default: + return "unknown", "unknown" + } +} + +func determineTypeKind(typeName string) string { + primitives := map[string]bool{ + "bool": true, + "string": true, + "int": true, + "int8": true, + "int16": true, + "int32": true, + "int64": true, + "uint": true, + "uint8": true, + "uint16": true, + "uint32": true, + "uint64": true, + "byte": true, + "rune": true, + "float32": true, + "float64": true, + "complex64": true, + "complex128": true, + } + + if primitives[typeName] { + return "primitive" + } + return "struct" +} + +// ScanDTOsInPackage scans all Go files in a package and extracts DTO schemas. +func ScanDTOsInPackage(pkgPath string) ([]DTOSchema, error) { + matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) + if err != nil { + return nil, fmt.Errorf("glob package files: %w", err) + } + + var allSchemas []DTOSchema + for _, file := range matches { + if strings.HasSuffix(file, "_test.go") { + continue + } + + schemas, err := ScanDTOs(file) + if err != nil { + return nil, fmt.Errorf("scan %s: %w", file, err) + } + allSchemas = append(allSchemas, schemas...) + } + + return allSchemas, nil +} diff --git a/viiper/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go similarity index 90% rename from viiper/internal/codegen/scanner/dtos_test.go rename to internal/codegen/scanner/dtos_test.go index 8299363e..b6588790 100644 --- a/viiper/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -1,93 +1,92 @@ -package scanner - -import ( - "encoding/json" - "testing" -) - -func TestScanDTOs(t *testing.T) { - // Scan the apitypes package - schemas, err := ScanDTOsInPackage("../../../pkg/apitypes") - if err != nil { - t.Fatalf("ScanDTOsInPackage failed: %v", err) - } - - if len(schemas) == 0 { - t.Fatal("expected at least one DTO schema, got none") - } - - t.Logf("Found %d DTO schemas", len(schemas)) - - // Expected DTOs - expectedDTOs := map[string]bool{ - "ApiError": true, - "BusListResponse": true, - "BusCreateResponse": true, - "BusRemoveResponse": true, - "Device": true, - "DevicesListResponse": true, - "DeviceAddResponse": true, - "DeviceRemoveResponse": true, - } - - foundDTOs := make(map[string]bool) - for _, schema := range schemas { - foundDTOs[schema.Name] = true - } - - for expectedDTO := range expectedDTOs { - if !foundDTOs[expectedDTO] { - t.Errorf("expected to find DTO %q, but it was not discovered", expectedDTO) - } - } - - // Print all discovered DTOs as JSON for inspection - t.Log("Discovered DTO schemas:") - for _, schema := range schemas { - data, _ := json.MarshalIndent(schema, "", " ") - t.Logf("%s", data) - } - - // Verify BusCreateResponse has correct structure - for _, schema := range schemas { - if schema.Name == "BusCreateResponse" { - if len(schema.Fields) != 1 { - t.Errorf("BusCreateResponse: expected 1 field, got %d", len(schema.Fields)) - } - if len(schema.Fields) > 0 { - field := schema.Fields[0] - if field.Name != "BusID" { - t.Errorf("BusCreateResponse: expected field name 'BusID', got %q", field.Name) - } - if field.JSONName != "busId" { - t.Errorf("BusCreateResponse: expected JSON name 'busId', got %q", field.JSONName) - } - if field.Type != "uint32" { - t.Errorf("BusCreateResponse: expected type 'uint32', got %q", field.Type) - } - if field.TypeKind != "primitive" { - t.Errorf("BusCreateResponse: expected typeKind 'primitive', got %q", field.TypeKind) - } - } - } - - // Verify DevicesListResponse has array field - if schema.Name == "DevicesListResponse" { - if len(schema.Fields) != 1 { - t.Errorf("DevicesListResponse: expected 1 field, got %d", len(schema.Fields)) - } - if len(schema.Fields) > 0 { - field := schema.Fields[0] - if field.Name != "Devices" { - t.Errorf("DevicesListResponse: expected field name 'Devices', got %q", field.Name) - } - if field.Type != "[]Device" { - t.Errorf("DevicesListResponse: expected type '[]Device', got %q", field.Type) - } - if field.TypeKind != "slice" { - t.Errorf("DevicesListResponse: expected typeKind 'slice', got %q", field.TypeKind) - } - } - } - } -} +package scanner + +import ( + "encoding/json" + "testing" +) + +func TestScanDTOs(t *testing.T) { + // Scan the viipertypes package + schemas, err := ScanDTOsInPackage("../../..//viipertypes") + if err != nil { + t.Fatalf("ScanDTOsInPackage failed: %v", err) + } + + if len(schemas) == 0 { + t.Fatal("expected at least one DTO schema, got none") + } + + t.Logf("Found %d DTO schemas", len(schemas)) + + // Expected DTOs + expectedDTOs := map[string]bool{ + "APIError": true, + "BusListResponse": true, + "BusCreateResponse": true, + "BusRemoveResponse": true, + "Device": true, + "DevicesListResponse": true, + "DeviceRemoveResponse": true, + } + + foundDTOs := make(map[string]bool) + for _, schema := range schemas { + foundDTOs[schema.Name] = true + } + + for expectedDTO := range expectedDTOs { + if !foundDTOs[expectedDTO] { + t.Errorf("expected to find DTO %q, but it was not discovered", expectedDTO) + } + } + + // Print all discovered DTOs as JSON for inspection + t.Log("Discovered DTO schemas:") + for _, schema := range schemas { + data, _ := json.MarshalIndent(schema, "", " ") + t.Logf("%s", data) + } + + // Verify BusCreateResponse has correct structure + for _, schema := range schemas { + if schema.Name == "BusCreateResponse" { + if len(schema.Fields) != 1 { + t.Errorf("BusCreateResponse: expected 1 field, got %d", len(schema.Fields)) + } + if len(schema.Fields) > 0 { + field := schema.Fields[0] + if field.Name != "BusID" { + t.Errorf("BusCreateResponse: expected field name 'BusID', got %q", field.Name) + } + if field.JSONName != "busId" { + t.Errorf("BusCreateResponse: expected JSON name 'busId', got %q", field.JSONName) + } + if field.Type != "uint32" { + t.Errorf("BusCreateResponse: expected type 'uint32', got %q", field.Type) + } + if field.TypeKind != "primitive" { + t.Errorf("BusCreateResponse: expected typeKind 'primitive', got %q", field.TypeKind) + } + } + } + + // Verify DevicesListResponse has array field + if schema.Name == "DevicesListResponse" { + if len(schema.Fields) != 1 { + t.Errorf("DevicesListResponse: expected 1 field, got %d", len(schema.Fields)) + } + if len(schema.Fields) > 0 { + field := schema.Fields[0] + if field.Name != "Devices" { + t.Errorf("DevicesListResponse: expected field name 'Devices', got %q", field.Name) + } + if field.Type != "[]Device" { + t.Errorf("DevicesListResponse: expected type '[]Device', got %q", field.Type) + } + if field.TypeKind != "slice" { + t.Errorf("DevicesListResponse: expected typeKind 'slice', got %q", field.TypeKind) + } + } + } + } +} diff --git a/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go new file mode 100644 index 00000000..fade2387 --- /dev/null +++ b/internal/codegen/scanner/payload.go @@ -0,0 +1,422 @@ +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" +) + +// ScanHandlerPayloadInfo analyzes handler functions to infer payload semantics (kind, required, parser hints). +// It complements JSON payload detection; if both numeric and JSON patterns appear JSON wins. +func ScanHandlerPayloadInfo(pkgPath string) (map[string]PayloadInfo, error) { + matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) + if err != nil { + return nil, fmt.Errorf("glob handler files: %w", err) + } + out := make(map[string]PayloadInfo) + for _, file := range matches { + if strings.HasSuffix(file, "_test.go") { + continue + } + if err := scanPayloadFile(file, out); err != nil { + return nil, fmt.Errorf("scan %s: %w", file, err) + } + } + return out, nil +} + +func scanPayloadFile(filePath string, acc map[string]PayloadInfo) error { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + return fmt.Errorf("parse file: %w", err) + } + + // Track variable type declarations for JSON target inference + varDeclTypes := make(map[string]string) + for _, decl := range node.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || vs.Type == nil { + continue + } + for _, name := range vs.Names { + varDeclTypes[name.Name] = extractTypeName(vs.Type) + } + } + } + + ast.Inspect(node, func(n ast.Node) bool { + funcDecl, ok := n.(*ast.FuncDecl) + if !ok || funcDecl.Body == nil { + return true + } + // Only consider functions returning api.HandlerFunc (SelectorExpr.Sel.Name == HandlerFunc) + if funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) == 0 { + return true + } + returnsHandlerFunc := false + for _, r := range funcDecl.Type.Results.List { + if sel, ok := r.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "HandlerFunc" { + returnsHandlerFunc = true + break + } + } + if !returnsHandlerFunc { + return true + } + + handler := funcDecl.Name.Name + // Initialize with no payload + pi := PayloadInfo{Kind: PayloadNone, Required: false} + + // Flags collected + hasEmptyError := false + hasNonEmptyBranch := false + hasJSON := false + hasNumeric := false + hasDirectUse := false + numericBitSize := "" + jsonTargetType := "" + + // Walk body - also track local variable declarations + localVarTypes := make(map[string]string) + ast.Inspect(funcDecl.Body, func(nn ast.Node) bool { + // Track local variable declarations (var x Type) + if decl, ok := nn.(*ast.DeclStmt); ok { + if gen, ok := decl.Decl.(*ast.GenDecl); ok && gen.Tok == token.VAR { + for _, spec := range gen.Specs { + if vs, ok := spec.(*ast.ValueSpec); ok && vs.Type != nil { + for _, name := range vs.Names { + localVarTypes[name.Name] = extractTypeName(vs.Type) + } + } + } + } + } + + // If statements for empty/non-empty checks + if ifs, ok := nn.(*ast.IfStmt); ok { + if isPayloadComparison(ifs.Cond, token.EQL) || isLenPayloadComparison(ifs.Cond, token.EQL) { + if blockReturnsError(ifs.Body) { + hasEmptyError = true + } + } + if isPayloadComparison(ifs.Cond, token.NEQ) || isLenPayloadComparison(ifs.Cond, token.GTR) { + hasNonEmptyBranch = true + } + } + + call, ok := nn.(*ast.CallExpr) + if ok { + // Detect json.Unmarshal([]byte(req.Payload), &X) + if isJSONUnmarshal(call) { + if len(call.Args) >= 2 { + if unary, ok := call.Args[1].(*ast.UnaryExpr); ok && unary.Op == token.AND { + if ident, ok := unary.X.(*ast.Ident); ok { + // Check local vars first, then package-level vars + if tname, found := localVarTypes[ident.Name]; found { + jsonTargetType = baseTypeName(tname) + } else if tname, found := varDeclTypes[ident.Name]; found { + jsonTargetType = baseTypeName(tname) + } + } + } + } + hasJSON = true + } + if isNumericParse(call) { + hasNumeric = true + numericBitSize = inferNumericBitSize(call) + } + if isFmtSscanf(call) && fmtSscanfUsesPayload(call) { + hasNumeric = true + if numericBitSize == "" { + numericBitSize = "int" + } + } + } + + // Direct usage detection (assignments / passes) + if assign, ok := nn.(*ast.AssignStmt); ok { + for _, rhs := range assign.Rhs { + if usesPayloadDirect(rhs) { + hasDirectUse = true + } + } + } + if exprStmt, ok := nn.(*ast.ExprStmt); ok { + if call, ok := exprStmt.X.(*ast.CallExpr); ok { + for _, a := range call.Args { + if usesPayloadDirect(a) { + hasDirectUse = true + } + } + } + } + return true + }) + + // Determine kind precedence: JSON > Numeric > String > None + switch { + case hasJSON: + pi.Kind = PayloadJSON + pi.Required = hasEmptyError || !hasNonEmptyBranch // current JSON always required + if jsonTargetType != "" { + pi.ParserHint, pi.RawType = jsonTargetType, jsonTargetType + } + pi.Notes = "JSON payload" + case hasNumeric: + pi.Kind = PayloadNumeric + // Optional if there's a non-empty branch and no empty error + pi.Required = hasEmptyError || !hasNonEmptyBranch + if numericBitSize != "" { + pi.ParserHint, pi.RawType = numericBitSize, numericBitSize + } + case hasDirectUse: + pi.Kind = PayloadString + pi.Required = hasEmptyError + pi.ParserHint = "string" + default: + // none remains + } + + acc[handler] = pi + return true + }) + return nil +} + +// Helper functions +// isPayloadComparison detects req.Payload == "" or != "" depending on op. +func isPayloadComparison(expr ast.Expr, op token.Token) bool { + be, ok := expr.(*ast.BinaryExpr) + if !ok || be.Op != op { + return false + } + leftSel, ok := be.X.(*ast.SelectorExpr) + if !ok || leftSel.Sel.Name != "Payload" { + return false + } + if _, ok := leftSel.X.(*ast.Ident); !ok { + return false + } + lit, ok := be.Y.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING || lit.Value != "\"\"" { + return false + } + return true +} + +// isLenPayloadComparison detects len(req.Payload) == 0 or > 0. +func isLenPayloadComparison(expr ast.Expr, op token.Token) bool { + be, ok := expr.(*ast.BinaryExpr) + if !ok || be.Op != op { + return false + } + ce, ok := be.X.(*ast.CallExpr) + if !ok { + return false + } + funIdent, ok := ce.Fun.(*ast.Ident) + if !ok || funIdent.Name != "len" { + return false + } + if len(ce.Args) != 1 { + return false + } + sel, ok := ce.Args[0].(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Payload" { + return false + } + lit, ok := be.Y.(*ast.BasicLit) + if !ok || lit.Kind != token.INT { + return false + } + if op == token.EQL && lit.Value == "0" { + return true + } + if op == token.GTR && lit.Value == "0" { + return true + } // len(req.Payload) > 0 + return false +} + +func blockReturnsError(block *ast.BlockStmt) bool { + if block == nil { + return false + } + for _, stmt := range block.List { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok { + continue + } + for _, res := range ret.Results { + if call, ok := res.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if ident, ok := sel.X.(*ast.Ident); ok { + if (ident.Name == "api" || ident.Name == "apierror") && strings.HasPrefix(sel.Sel.Name, "Err") { + return true + } + } + } + } + } + } + return false +} + +func isJSONUnmarshal(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + if ident, ok := sel.X.(*ast.Ident); !ok || ident.Name != "json" || sel.Sel.Name != "Unmarshal" { + return false + } + if len(call.Args) < 1 { + return false + } + // Expect first arg []byte(req.Payload) + conv, ok := call.Args[0].(*ast.CallExpr) + if !ok { + return false + } + if arr, ok := conv.Fun.(*ast.ArrayType); ok { + if ident, ok := arr.Elt.(*ast.Ident); ok && ident.Name == "byte" { + if len(conv.Args) == 1 { + if selExpr, ok := conv.Args[0].(*ast.SelectorExpr); ok && selExpr.Sel.Name == "Payload" { + return true + } + } + } + } + return false +} + +func isNumericParse(call *ast.CallExpr) bool { + funIdent, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + ident, ok := funIdent.X.(*ast.Ident) + if !ok || ident.Name != "strconv" { + return false + } + switch funIdent.Sel.Name { + case "ParseUint", "ParseInt", "Atoi": + // ensure first arg originates from req.Payload (possibly wrapped) + if len(call.Args) > 0 && originatesFromPayload(call.Args[0]) { + return true + } + } + return false +} + +func inferNumericBitSize(call *ast.CallExpr) string { + funIdent, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return "" + } + switch funIdent.Sel.Name { + case "ParseUint", "ParseInt": + if len(call.Args) >= 3 { // arg0 string, arg1 base, arg2 bitSize + if lit, ok := call.Args[2].(*ast.BasicLit); ok && lit.Kind == token.INT { + return "uint" + lit.Value + } + } + return "int" // fallback + case "Atoi": + return "int" + } + return "" +} + +func originatesFromPayload(expr ast.Expr) bool { + // Direct req.Payload or wrappers like strings.TrimSpace(req.Payload) + switch v := expr.(type) { + case *ast.SelectorExpr: + if v.Sel.Name == "Payload" { + return true + } + case *ast.CallExpr: + for _, a := range v.Args { + if originatesFromPayload(a) { + return true + } + } + } + return false +} + +func isFmtSscanf(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + pkgIdent, ok := sel.X.(*ast.Ident) + if !ok || pkgIdent.Name != "fmt" || sel.Sel.Name != "Sscanf" { + return false + } + return true +} + +func fmtSscanfUsesPayload(call *ast.CallExpr) bool { + if len(call.Args) == 0 { + return false + } + return originatesFromPayload(call.Args[0]) +} + +func usesPayloadDirect(expr ast.Expr) bool { + if expr == nil { + return false + } + switch v := expr.(type) { + case *ast.SelectorExpr: + return v.Sel.Name == "Payload" + case *ast.CallExpr: + for _, a := range v.Args { + if usesPayloadDirect(a) { + return true + } + } + case *ast.UnaryExpr: + return usesPayloadDirect(v.X) + case *ast.BinaryExpr: + return usesPayloadDirect(v.X) || usesPayloadDirect(v.Y) + } + return false +} + +func extractTypeName(expr ast.Expr) string { + switch v := expr.(type) { + case *ast.SelectorExpr: + // e.g., viipertypes.DeviceCreateRequest + left := extractTypeName(v.X) + if left == "" { + return v.Sel.Name + } + return left + "." + v.Sel.Name + case *ast.Ident: + return v.Name + case *ast.StarExpr: + return extractTypeName(v.X) + } + return "" +} + +func baseTypeName(full string) string { + if full == "" { + return "" + } + parts := strings.Split(full, ".") + return parts[len(parts)-1] +} diff --git a/viiper/internal/codegen/scanner/returns.go b/internal/codegen/scanner/returns.go similarity index 86% rename from viiper/internal/codegen/scanner/returns.go rename to internal/codegen/scanner/returns.go index 3869f46d..20cdb356 100644 --- a/viiper/internal/codegen/scanner/returns.go +++ b/internal/codegen/scanner/returns.go @@ -1,124 +1,128 @@ -package scanner - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "strings" -) - -// ScanHandlerReturnDTOs scans handler implementations to find the apitypes.* struct -// passed to json.Marshal, and returns a mapping of handler factory name (e.g., "BusList") -// to DTO type name (e.g., "BusListResponse"). If no DTO is found, the handler is omitted. -func ScanHandlerReturnDTOs(pkgPath string) (map[string]string, error) { - matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) - if err != nil { - return nil, fmt.Errorf("glob handler files: %w", err) - } - - out := make(map[string]string) - - for _, file := range matches { - if strings.HasSuffix(file, "_test.go") { - continue - } - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, file, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - ast.Inspect(node, func(n ast.Node) bool { - fn, ok := n.(*ast.FuncDecl) - if !ok { - return true - } - if fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { - return true - } - returnsHandler := false - for _, r := range fn.Type.Results.List { - if sel, ok := r.Type.(*ast.SelectorExpr); ok { - if sel.Sel.Name == "HandlerFunc" { - returnsHandler = true - break - } - } - } - if !returnsHandler { - return true - } - - handlerName := fn.Name.Name - - ast.Inspect(fn.Body, func(n2 ast.Node) bool { - call, ok := n2.(*ast.CallExpr) - if !ok { - return true - } - if sel, ok := call.Fun.(*ast.SelectorExpr); ok { - if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "json" && sel.Sel.Name == "Marshal" { - if len(call.Args) > 0 { - dto := detectDTOType(call.Args[0]) - if dto != "" { - out[handlerName] = dto - } - } - } - } - return true - }) - return true - }) - } - - return out, nil -} - -func detectDTOType(expr ast.Expr) string { - switch v := expr.(type) { - case *ast.CompositeLit: - switch tt := v.Type.(type) { - case *ast.SelectorExpr: - if pkg, ok := tt.X.(*ast.Ident); ok && pkg.Name == "apitypes" { - return tt.Sel.Name - } - } - case *ast.Ident: - // Heuristic: walk up to its Obj and inspect Decl if available - if v.Obj != nil && v.Obj.Decl != nil { - if asn, ok := v.Obj.Decl.(*ast.AssignStmt); ok { - for _, rhs := range asn.Rhs { - if lit, ok := rhs.(*ast.CompositeLit); ok { - if sel, ok := lit.Type.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "apitypes" { - return sel.Sel.Name - } - } - } - } - } - if vs, ok := v.Obj.Decl.(*ast.ValueSpec); ok { - if len(vs.Values) > 0 { - if lit, ok := vs.Values[0].(*ast.CompositeLit); ok { - if sel, ok := lit.Type.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "apitypes" { - return sel.Sel.Name - } - } - } - } - if vs.Type != nil { - if sel, ok := vs.Type.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "apitypes" { - return sel.Sel.Name - } - } - } - } - } - } - return "" -} +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" +) + +// ScanHandlerReturnDTOs scans handler implementations to find the viipertypes.* struct +// passed to json.Marshal, and returns a mapping of handler factory name (e.g., "BusList") +// to DTO type name (e.g., "BusListResponse"). If no DTO is found, the handler is omitted. +func ScanHandlerReturnDTOs(pkgPath string) (map[string]string, error) { + matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) + if err != nil { + return nil, fmt.Errorf("glob handler files: %w", err) + } + + out := make(map[string]string) + + for _, file := range matches { + if strings.HasSuffix(file, "_test.go") { + continue + } + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, file, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse file: %w", err) + } + + ast.Inspect(node, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok { + return true + } + if fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { + return true + } + returnsHandler := false + for _, r := range fn.Type.Results.List { + if sel, ok := r.Type.(*ast.SelectorExpr); ok { + if sel.Sel.Name == "HandlerFunc" { + returnsHandler = true + break + } + } + } + if !returnsHandler { + return true + } + + handlerName := fn.Name.Name + + ast.Inspect(fn.Body, func(n2 ast.Node) bool { + call, ok := n2.(*ast.CallExpr) + if !ok { + return true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "json" && sel.Sel.Name == "Marshal" { + if len(call.Args) > 0 { + dto := detectDTOType(call.Args[0]) + if dto != "" { + out[handlerName] = dto + } + } + } + } + return true + }) + return true + }) + } + + return out, nil +} + +func detectDTOType(expr ast.Expr) string { + switch v := expr.(type) { + case *ast.CompositeLit: + switch tt := v.Type.(type) { + case *ast.SelectorExpr: + if pkg, ok := tt.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { + return tt.Sel.Name + } + } + case *ast.Ident: + // Heuristic: walk up to its Obj and inspect Decl if available + if v.Obj != nil && v.Obj.Decl != nil { + if asn, ok := v.Obj.Decl.(*ast.AssignStmt); ok { + for _, rhs := range asn.Rhs { + if lit, ok := rhs.(*ast.CompositeLit); ok { + if sel, ok := lit.Type.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { + return sel.Sel.Name + } + } + } + } + } + if vs, ok := v.Obj.Decl.(*ast.ValueSpec); ok { + if len(vs.Values) > 0 { + if lit, ok := vs.Values[0].(*ast.CompositeLit); ok { + if sel, ok := lit.Type.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { + return sel.Sel.Name + } + } + } + } + if vs.Type != nil { + if sel, ok := vs.Type.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { + return sel.Sel.Name + } + } + } + } + } + } + return "" +} + +func isDTOPackageName(name string) bool { + return name == "viipertypes" || name == "apitypes" +} diff --git a/viiper/internal/codegen/scanner/routes.go b/internal/codegen/scanner/routes.go similarity index 77% rename from viiper/internal/codegen/scanner/routes.go rename to internal/codegen/scanner/routes.go index 696bcd70..1c3a762b 100644 --- a/viiper/internal/codegen/scanner/routes.go +++ b/internal/codegen/scanner/routes.go @@ -1,173 +1,173 @@ -package scanner - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "strings" -) - -// RouteInfo describes a discovered API route. -type RouteInfo struct { - Path string `json:"path"` // e.g., "bus/{id}/list" - Method string `json:"method"` // "Register" or "RegisterStream" - Handler string `json:"handler"` // e.g., "BusList" - PathParams map[string]string `json:"pathParams"` // e.g., {"id": "string"} - Arguments []ArgumentInfo `json:"arguments"` // Expected request arguments - ResponseDTO string `json:"responseDTO"` // Name of DTO type returned (e.g., "BusListResponse"), empty if none -} - -// ArgumentInfo describes an argument expected by a route handler. -type ArgumentInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Optional bool `json:"optional"` -} - -// ScanRoutes scans the specified Go file for router.Register() and router.RegisterStream() calls -// and returns metadata about discovered routes. -func ScanRoutes(filePath string) ([]RouteInfo, error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - var routes []RouteInfo - - ast.Inspect(node, func(n ast.Node) bool { - callExpr, ok := n.(*ast.CallExpr) - if !ok { - return true - } - - selExpr, ok := callExpr.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - - methodName := selExpr.Sel.Name - if methodName != "Register" && methodName != "RegisterStream" { - return true - } - - if len(callExpr.Args) < 2 { - return true - } - - pathLit, ok := callExpr.Args[0].(*ast.BasicLit) - if !ok || pathLit.Kind != token.STRING { - return true - } - path := strings.Trim(pathLit.Value, `"`) - - handlerName := extractHandlerName(callExpr.Args[1]) - - pathParams := extractPathParams(path) - - routes = append(routes, RouteInfo{ - Path: path, - Method: methodName, - Handler: handlerName, - PathParams: pathParams, - Arguments: []ArgumentInfo{}, // Will be enriched later if needed - }) - - return true - }) - - return routes, nil -} - -// extractHandlerName tries to extract the handler function name from a call expression. -// For handler.BusList(usbSrv), it returns "BusList". -func extractHandlerName(expr ast.Expr) string { - callExpr, ok := expr.(*ast.CallExpr) - if !ok { - return "unknown" - } - - switch fun := callExpr.Fun.(type) { - case *ast.SelectorExpr: - return fun.Sel.Name - case *ast.Ident: - return fun.Name - default: - return "unknown" - } -} - -// extractPathParams parses a route pattern like "bus/{id}/list" and returns -// a map of parameter names to their types (currently all "string"). -func extractPathParams(pattern string) map[string]string { - params := make(map[string]string) - parts := strings.Split(pattern, "/") - for _, part := range parts { - if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { - paramName := part[1 : len(part)-1] - params[paramName] = "string" // API uses string params, converted as needed - } - } - return params -} - -// ScanRoutesInPackage scans all Go files in the specified directory (non-recursively) -// and aggregates route information. -func ScanRoutesInPackage(pkgPath string) ([]RouteInfo, error) { - matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) - if err != nil { - return nil, fmt.Errorf("glob package files: %w", err) - } - - var allRoutes []RouteInfo - for _, file := range matches { - if strings.HasSuffix(file, "_test.go") { - continue - } - routes, err := ScanRoutes(file) - if err != nil { - return nil, fmt.Errorf("scan %s: %w", file, err) - } - allRoutes = append(allRoutes, routes...) - } - - return allRoutes, nil -} - -// EnrichRoutesWithHandlerInfo scans handler implementations and enriches routes with argument metadata. -func EnrichRoutesWithHandlerInfo(routes []RouteInfo, handlerPkgPath string) ([]RouteInfo, error) { - handlerInfo, err := ScanHandlerArgs(handlerPkgPath) - if err != nil { - return nil, fmt.Errorf("scan handler args: %w", err) - } - - returnTypes, err := ScanHandlerReturnDTOs(handlerPkgPath) - if err != nil { - return nil, fmt.Errorf("scan handler return types: %w", err) - } - - enriched := make([]RouteInfo, len(routes)) - for i, route := range routes { - enriched[i] = route - if info, ok := handlerInfo[route.Handler]; ok { - seen := make(map[int]bool) - for _, access := range info.ArgAccesses { - if !seen[access.Index] { - seen[access.Index] = true - enriched[i].Arguments = append(enriched[i].Arguments, ArgumentInfo{ - Name: access.VarName, - Type: "string", - Optional: !access.Required, - }) - } - } - } - if rt, ok := returnTypes[route.Handler]; ok { - enriched[i].ResponseDTO = rt - } - } - - return enriched, nil -} +package scanner + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" +) + +// RouteInfo describes a discovered API route. +type RouteInfo struct { + Path string `json:"path"` // e.g., "bus/{id}/list" + Method string `json:"method"` // "Register" or "RegisterStream" + Handler string `json:"handler"` // e.g., "BusList" + PathParams map[string]string `json:"pathParams"` // e.g., {"id": "string"} + ResponseDTO string `json:"responseDTO"` // Name of DTO type returned (e.g., "BusListResponse"), empty if none + Payload PayloadInfo `json:"payload"` // payload classification +} + +// PayloadKind enumerates recognized payload semantics. +type PayloadKind string + +const ( + PayloadNone PayloadKind = "none" + PayloadNumeric PayloadKind = "numeric" + PayloadJSON PayloadKind = "json" + PayloadString PayloadKind = "string" +) + +// PayloadInfo describes how a route's req.Payload is interpreted. +type PayloadInfo struct { + Kind PayloadKind `json:"kind"` // none|numeric|json|string + Required bool `json:"required"` // true if handler rejects empty payload + ParserHint string `json:"parserHint,omitempty"` // e.g., uint32, DeviceCreateRequest, deviceID + RawType string `json:"rawType,omitempty"` // Underlying Go type name for JSON / numeric width + Notes string `json:"notes,omitempty"` // Additional guidance for generators +} + +// ScanRoutes scans the specified Go file for router.Register() and router.RegisterStream() calls +// and returns metadata about discovered routes. +func ScanRoutes(filePath string) ([]RouteInfo, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse file: %w", err) + } + + var routes []RouteInfo + + ast.Inspect(node, func(n ast.Node) bool { + callExpr, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + selExpr, ok := callExpr.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + + methodName := selExpr.Sel.Name + if methodName != "Register" && methodName != "RegisterStream" { + return true + } + + if len(callExpr.Args) < 2 { + return true + } + + pathLit, ok := callExpr.Args[0].(*ast.BasicLit) + if !ok || pathLit.Kind != token.STRING { + return true + } + path := strings.Trim(pathLit.Value, `"`) + + handlerName := extractHandlerName(callExpr.Args[1]) + + pathParams := extractPathParams(path) + + routes = append(routes, RouteInfo{ + Path: path, + Method: methodName, + Handler: handlerName, + PathParams: pathParams, + }) + + return true + }) + + return routes, nil +} + +// extractHandlerName tries to extract the handler function name from a call expression. +// For handler.BusList(usbSrv), it returns "BusList". +func extractHandlerName(expr ast.Expr) string { + callExpr, ok := expr.(*ast.CallExpr) + if !ok { + return "unknown" + } + + switch fun := callExpr.Fun.(type) { + case *ast.SelectorExpr: + return fun.Sel.Name + case *ast.Ident: + return fun.Name + default: + return "unknown" + } +} + +// extractPathParams parses a route pattern like "bus/{id}/list" and returns +// a map of parameter names to their types (currently all "string"). +func extractPathParams(pattern string) map[string]string { + params := make(map[string]string) + parts := strings.Split(pattern, "/") + for _, part := range parts { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + paramName := part[1 : len(part)-1] + params[paramName] = "string" // API uses string params, converted as needed + } + } + return params +} + +// ScanRoutesInPackage scans all Go files in the specified directory (non-recursively) +// and aggregates route information. +func ScanRoutesInPackage(pkgPath string) ([]RouteInfo, error) { + matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) + if err != nil { + return nil, fmt.Errorf("glob package files: %w", err) + } + + var allRoutes []RouteInfo + for _, file := range matches { + if strings.HasSuffix(file, "_test.go") { + continue + } + routes, err := ScanRoutes(file) + if err != nil { + return nil, fmt.Errorf("scan %s: %w", file, err) + } + allRoutes = append(allRoutes, routes...) + } + + return allRoutes, nil +} + +// EnrichRoutesWithHandlerInfo scans handler implementations and enriches routes with argument metadata. +func EnrichRoutesWithHandlerInfo(routes []RouteInfo, handlerPkgPath string) ([]RouteInfo, error) { + returnTypes, err := ScanHandlerReturnDTOs(handlerPkgPath) + if err != nil { + return nil, fmt.Errorf("scan handler return types: %w", err) + } + payloadInfo, err := ScanHandlerPayloadInfo(handlerPkgPath) + if err != nil { + return nil, fmt.Errorf("scan handler payload info: %w", err) + } + enriched := make([]RouteInfo, len(routes)) + for i, route := range routes { + enriched[i] = route + if rt, ok := returnTypes[route.Handler]; ok { + enriched[i].ResponseDTO = rt + } + if pi, ok := payloadInfo[route.Handler]; ok { + enriched[i].Payload = pi + } else { + enriched[i].Payload = PayloadInfo{Kind: PayloadNone, Required: false} + } + } + return enriched, nil +} diff --git a/internal/codegen/scanner/routes_test.go b/internal/codegen/scanner/routes_test.go new file mode 100644 index 00000000..6ec9116f --- /dev/null +++ b/internal/codegen/scanner/routes_test.go @@ -0,0 +1,88 @@ +package scanner + +import ( + "encoding/json" + "testing" +) + +type testCase struct { + name string + run func(t *testing.T) +} + +func TestScannerSuite(t *testing.T) { + cases := []testCase{ + { + name: "ScanRoutes discovers expected paths", + run: func(t *testing.T) { + routes, err := ScanRoutes("../../cmd/server.go") + if err != nil { + t.Fatalf("ScanRoutes failed: %v", err) + } + if len(routes) == 0 { + t.Fatal("expected at least one route, got none") + } + expected := map[string]bool{ + "bus/list": true, + "bus/create": true, + "bus/remove": true, + "bus/{id}/list": true, + "bus/{id}/add": true, + "bus/{id}/remove": true, + "bus/{busId}/{deviceid}": true, + } + found := make(map[string]bool) + for _, r := range routes { + found[r.Path] = true + } + for p := range expected { + if !found[p] { + t.Errorf("expected route %s not found", p) + } + } + t.Log("Discovered routes (raw):") + for _, r := range routes { + data, _ := json.MarshalIndent(r, "", " ") + t.Logf("%s", data) + } + }, + }, + { + name: "EnrichRoutes classifies payload kinds correctly", + run: func(t *testing.T) { + routes, err := ScanRoutes("../../cmd/server.go") + if err != nil { + t.Fatalf("ScanRoutes failed: %v", err) + } + enriched, err := EnrichRoutesWithHandlerInfo(routes, "../../server/api/handler") + if err != nil { + t.Fatalf("EnrichRoutesWithHandlerInfo failed: %v", err) + } + seen := map[string]RouteInfo{} + for _, r := range enriched { + seen[r.Path] = r + } + assertPayload := func(path string, kind PayloadKind, required bool) { + v, ok := seen[path] + if !ok { + t.Errorf("%s missing", path) + return + } + if v.Payload.Kind != kind || v.Payload.Required != required { + t.Errorf("%s expected kind=%s required=%v got %+v", path, kind, required, v.Payload) + } + } + assertPayload("bus/{id}/add", PayloadJSON, true) + assertPayload("bus/create", PayloadNumeric, false) + assertPayload("bus/remove", PayloadNumeric, true) + assertPayload("bus/{id}/remove", PayloadString, true) + assertPayload("bus/list", PayloadNone, false) + assertPayload("bus/{id}/list", PayloadNone, false) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { tc.run(t) }) + } +} diff --git a/viiper/internal/codegen/scanner/wiretags.go b/internal/codegen/scanner/wiretags.go similarity index 96% rename from viiper/internal/codegen/scanner/wiretags.go rename to internal/codegen/scanner/wiretags.go index d4ab620d..e4c24280 100644 --- a/viiper/internal/codegen/scanner/wiretags.go +++ b/internal/codegen/scanner/wiretags.go @@ -1,136 +1,136 @@ -package scanner - -import ( - "fmt" - "go/parser" - "go/token" - "os" - "path/filepath" - "regexp" - "strings" -) - -// WireField represents a single field in a wire protocol struct -type WireField struct { - Name string `json:"name"` // Field name (e.g., "modifiers", "keys") - Type string `json:"type"` // Wire type token (e.g., "u8", "i16", may include array marker like "u8*count") - Spec string `json:"spec"` // Full spec from tag (e.g., "keys:u8*count") -} - -// WireTag represents a parsed viiper:wire comment -type WireTag struct { - Device string `json:"device"` // "keyboard", "mouse", "xbox360" - Direction string `json:"direction"` // "c2s" or "s2c" - Fields []WireField `json:"fields"` -} - -// WireTags holds all wire tags for all devices -type WireTags struct { - Tags map[string]map[string]*WireTag // device -> direction -> tag -} - -// wireTagPattern matches: viiper:wire field:type ... -var wireTagPattern = regexp.MustCompile(`viiper:wire\s+(\w+)\s+(c2s|s2c)\s+(.+)`) - -// ScanWireTags scans all device packages for viiper:wire comments -func ScanWireTags(devicePkgPaths []string) (*WireTags, error) { - result := &WireTags{ - Tags: make(map[string]map[string]*WireTag), - } - - for _, pkgPath := range devicePkgPaths { - entries, err := os.ReadDir(pkgPath) - if err != nil { - return nil, fmt.Errorf("failed to read directory %s: %w", pkgPath, err) - } - - fset := token.NewFileSet() - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue - } - - filePath := filepath.Join(pkgPath, entry.Name()) - file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) - if err != nil { - continue - } - - for _, commentGroup := range file.Comments { - for _, comment := range commentGroup.List { - if tag := parseWireTag(comment.Text); tag != nil { - if result.Tags[tag.Device] == nil { - result.Tags[tag.Device] = make(map[string]*WireTag) - } - result.Tags[tag.Device][tag.Direction] = tag - } - } - } - } - } - - return result, nil -} - -// parseWireTag parses a single viiper:wire comment line -func parseWireTag(comment string) *WireTag { - text := strings.TrimSpace(strings.TrimPrefix(comment, "//")) - text = strings.TrimSpace(strings.TrimPrefix(text, "/*")) - text = strings.TrimSpace(strings.TrimSuffix(text, "*/")) - - matches := wireTagPattern.FindStringSubmatch(text) - if matches == nil { - return nil - } - - device := matches[1] - direction := matches[2] - fieldSpecs := strings.Fields(matches[3]) - - tag := &WireTag{ - Device: device, - Direction: direction, - Fields: []WireField{}, - } - - for _, spec := range fieldSpecs { - if field := parseWireField(spec); field != nil { - tag.Fields = append(tag.Fields, *field) - } - } - - return tag -} - -func parseWireField(spec string) *WireField { - parts := strings.SplitN(spec, ":", 2) - if len(parts) != 2 { - return nil - } - - name := parts[0] - typeSpec := parts[1] - - return &WireField{ - Name: name, - Type: typeSpec, - Spec: spec, - } -} - -// HasDirection checks if a device has a wire tag for the given direction -func (wt *WireTags) HasDirection(device, direction string) bool { - if deviceTags, ok := wt.Tags[device]; ok { - _, exists := deviceTags[direction] - return exists - } - return false -} - -// GetTag retrieves the wire tag for a device and direction -func (wt *WireTags) GetTag(device, direction string) *WireTag { - if deviceTags, ok := wt.Tags[device]; ok { - return deviceTags[direction] - } - return nil -} +package scanner + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +// WireField represents a single field in a wire protocol struct +type WireField struct { + Name string `json:"name"` // Field name (e.g., "modifiers", "keys") + Type string `json:"type"` // Wire type token (e.g., "u8", "i16", may include array marker like "u8*count") + Spec string `json:"spec"` // Full spec from tag (e.g., "keys:u8*count") +} + +// WireTag represents a parsed viiper:wire comment +type WireTag struct { + Device string `json:"device"` // "keyboard", "mouse", "xbox360" + Direction string `json:"direction"` // "c2s" or "s2c" + Fields []WireField `json:"fields"` +} + +// WireTags holds all wire tags for all devices +type WireTags struct { + Tags map[string]map[string]*WireTag // device -> direction -> tag +} + +// wireTagPattern matches: viiper:wire field:type ... +var wireTagPattern = regexp.MustCompile(`viiper:wire\s+(\w+)\s+(c2s|s2c)\s+(.+)`) + +// ScanWireTags scans all device packages for viiper:wire comments +func ScanWireTags(devicePkgPaths []string) (*WireTags, error) { + result := &WireTags{ + Tags: make(map[string]map[string]*WireTag), + } + + for _, pkgPath := range devicePkgPaths { + entries, err := os.ReadDir(pkgPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", pkgPath, err) + } + + fset := token.NewFileSet() + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + + filePath := filepath.Join(pkgPath, entry.Name()) + file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + continue + } + + for _, commentGroup := range file.Comments { + for _, comment := range commentGroup.List { + if tag := parseWireTag(comment.Text); tag != nil { + if result.Tags[tag.Device] == nil { + result.Tags[tag.Device] = make(map[string]*WireTag) + } + result.Tags[tag.Device][tag.Direction] = tag + } + } + } + } + } + + return result, nil +} + +// parseWireTag parses a single viiper:wire comment line +func parseWireTag(comment string) *WireTag { + text := strings.TrimSpace(strings.TrimPrefix(comment, "//")) + text = strings.TrimSpace(strings.TrimPrefix(text, "/*")) + text = strings.TrimSpace(strings.TrimSuffix(text, "*/")) + + matches := wireTagPattern.FindStringSubmatch(text) + if matches == nil { + return nil + } + + device := matches[1] + direction := matches[2] + fieldSpecs := strings.Fields(matches[3]) + + tag := &WireTag{ + Device: device, + Direction: direction, + Fields: []WireField{}, + } + + for _, spec := range fieldSpecs { + if field := parseWireField(spec); field != nil { + tag.Fields = append(tag.Fields, *field) + } + } + + return tag +} + +func parseWireField(spec string) *WireField { + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 { + return nil + } + + name := parts[0] + typeSpec := parts[1] + + return &WireField{ + Name: name, + Type: typeSpec, + Spec: spec, + } +} + +// HasDirection checks if a device has a wire tag for the given direction +func (wt *WireTags) HasDirection(device, direction string) bool { + if deviceTags, ok := wt.Tags[device]; ok { + _, exists := deviceTags[direction] + return exists + } + return false +} + +// GetTag retrieves the wire tag for a device and direction +func (wt *WireTags) GetTag(device, direction string) *WireTag { + if deviceTags, ok := wt.Tags[device]; ok { + return deviceTags[direction] + } + return nil +} diff --git a/internal/config/codegen_command.go b/internal/config/codegen_command.go new file mode 100644 index 00000000..8761d53f --- /dev/null +++ b/internal/config/codegen_command.go @@ -0,0 +1,9 @@ +//go:build !release + +package config + +import "github.com/Alia5/VIIPER/internal/cmd" + +type codegenCommand struct { + Codegen cmd.Codegen `cmd:"" help:"Generate client libraries from server code"` +} diff --git a/internal/config/codegen_command_release.go b/internal/config/codegen_command_release.go new file mode 100644 index 00000000..fd7fd4d1 --- /dev/null +++ b/internal/config/codegen_command_release.go @@ -0,0 +1,5 @@ +//go:build release + +package config + +type codegenCommand struct{} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..1a4215cd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,36 @@ +// Package config defines the CLI structure and configuration for VIIPER. +package config + +import ( + "github.com/Alia5/VIIPER/internal/cmd" +) + +type UpdateNotify string + +const ( + UpdateNotifyNone UpdateNotify = "none" + UpdateNotifyStable UpdateNotify = "stable" + UpdateNotifyPrerelease UpdateNotify = "prerelease" +) + +type Log struct { + Level string `aliases:"l" help:"Log level: trace, debug, info, warn, error" default:"info" env:"VIIPER_LOG_LEVEL"` + File string `help:"Log file path (default: none; logs only to console)" env:"VIIPER_LOG_FILE"` + RawFile string `help:"Raw packet log file path (default: none)" env:"VIIPER_LOG_RAW_FILE"` +} + +// CLI is the root command structure for Kong CLI parsing. +type CLI struct { + // Global + ConfigPath string `help:"Path to configuration file (json|yaml|toml)" name:"config" env:"VIIPER_CONFIG"` + UpdateNotify UpdateNotify `help:"Update notification level: none, stable, prerelease" default:"stable" env:"VIIPER_UPDATE_NOTIFY"` + Log `embed:"" prefix:"log."` + codegenCommand + + Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server" default:""` + Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` + + Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` + Install cmd.Install `cmd:"" help:"Add the current VIIPER executable to system startup and runs it (creates a Systemd service on Linux)"` + Uninstall cmd.Uninstall `cmd:"" help:"Remove any VIIPER system startup configuration / Systemd service"` +} diff --git a/viiper/internal/configpaths/files.go b/internal/configpaths/files.go similarity index 91% rename from viiper/internal/configpaths/files.go rename to internal/configpaths/files.go index 67cda167..0f8f1801 100644 --- a/viiper/internal/configpaths/files.go +++ b/internal/configpaths/files.go @@ -1,104 +1,104 @@ -package configpaths - -import ( - "errors" - "os" - "path/filepath" - "runtime" -) - -// DefaultConfigDir returns the platform-specific configuration directory for VIIPER. -func DefaultConfigDir() (string, error) { - switch runtime.GOOS { - case "windows": - if appdata := os.Getenv("AppData"); appdata != "" { - return filepath.Join(appdata, "VIIPER"), nil - } - return "", errors.New("AppData not set") - default: - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "viiper"), nil - } - if home := os.Getenv("HOME"); home != "" { - return filepath.Join(home, ".config", "viiper"), nil - } - return "", errors.New("HOME not set") - } -} - -// DefaultConfigPath returns the default config file path for the given format using base name "config". -func DefaultConfigPath(format string) (string, error) { - return DefaultNamedConfigPath("config", format) -} - -// DefaultNamedConfigPath returns the default config file path for the given format and base name (e.g., "server"). -func DefaultNamedConfigPath(baseName, format string) (string, error) { - dir, err := DefaultConfigDir() - if err != nil { - return "", err - } - ext := "json" - switch format { - case "yaml", "yml": - ext = "yaml" - case "toml": - ext = "toml" - } - return filepath.Join(dir, baseName+"."+ext), nil -} - -// EnsureDir ensures the directory for a given file path exists. -func EnsureDir(filePath string) error { - dir := filepath.Dir(filePath) - return os.MkdirAll(dir, 0o755) -} - -// ConfigCandidatePaths builds candidate paths for config files per format. -// If userPath is provided, it is prioritized and routed to the matching loader by extension. -func ConfigCandidatePaths(userPath string) (jsonPaths, yamlPaths, tomlPaths []string) { - add := func(slice *[]string, p string) { *slice = append(*slice, p) } - - if userPath != "" { - switch ext := filepath.Ext(userPath); ext { - case ".json": - add(&jsonPaths, userPath) - case ".yaml", ".yml": - add(&yamlPaths, userPath) - case ".toml": - add(&tomlPaths, userPath) - default: - add(&jsonPaths, userPath) - } - } - - // Working directory candidates - wd, _ := os.Getwd() - for _, base := range []string{"viiper", "config", "server", "proxy"} { - add(&jsonPaths, filepath.Join(wd, base+".json")) - add(&yamlPaths, filepath.Join(wd, base+".yaml")) - add(&yamlPaths, filepath.Join(wd, base+".yml")) - add(&tomlPaths, filepath.Join(wd, base+".toml")) - } - - // Config home - if dir, err := DefaultConfigDir(); err == nil { - for _, base := range []string{"config", "server", "proxy"} { - add(&jsonPaths, filepath.Join(dir, base+".json")) - add(&yamlPaths, filepath.Join(dir, base+".yaml")) - add(&yamlPaths, filepath.Join(dir, base+".yml")) - add(&tomlPaths, filepath.Join(dir, base+".toml")) - } - } - - // System-wide (unix) - if runtime.GOOS != "windows" { - for _, base := range []string{"config", "server", "proxy"} { - add(&jsonPaths, filepath.Join("/etc/viiper", base+".json")) - add(&yamlPaths, filepath.Join("/etc/viiper", base+".yaml")) - add(&yamlPaths, filepath.Join("/etc/viiper", base+".yml")) - add(&tomlPaths, filepath.Join("/etc/viiper", base+".toml")) - } - } - - return -} +package configpaths + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +// DefaultConfigDir returns the platform-specific configuration directory for VIIPER. +func DefaultConfigDir() (string, error) { + switch runtime.GOOS { + case "windows": + if appdata := os.Getenv("AppData"); appdata != "" { + return filepath.Join(appdata, "VIIPER"), nil + } + return "", errors.New("AppData not set") + default: + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "github.com/Alia5/viiper"), nil + } + if home := os.Getenv("HOME"); home != "" { + return filepath.Join(home, ".config", "github.com/Alia5/viiper"), nil + } + return "", errors.New("HOME not set") + } +} + +// DefaultConfigPath returns the default config file path for the given format using base name "config". +func DefaultConfigPath(format string) (string, error) { + return DefaultNamedConfigPath("config", format) +} + +// DefaultNamedConfigPath returns the default config file path for the given format and base name (e.g., "server"). +func DefaultNamedConfigPath(baseName, format string) (string, error) { + dir, err := DefaultConfigDir() + if err != nil { + return "", err + } + ext := "json" + switch format { + case "yaml", "yml": + ext = "yaml" + case "toml": + ext = "toml" + } + return filepath.Join(dir, baseName+"."+ext), nil +} + +// EnsureDir ensures the directory for a given file path exists. +func EnsureDir(filePath string) error { + dir := filepath.Dir(filePath) + return os.MkdirAll(dir, 0o755) +} + +// ConfigCandidatePaths builds candidate paths for config files per format. +// If userPath is provided, it is prioritized and routed to the matching loader by extension. +func ConfigCandidatePaths(userPath string) (jsonPaths, yamlPaths, tomlPaths []string) { + add := func(slice *[]string, p string) { *slice = append(*slice, p) } + + if userPath != "" { + switch ext := filepath.Ext(userPath); ext { + case ".json": + add(&jsonPaths, userPath) + case ".yaml", ".yml": + add(&yamlPaths, userPath) + case ".toml": + add(&tomlPaths, userPath) + default: + add(&jsonPaths, userPath) + } + } + + // Working directory candidates + wd, _ := os.Getwd() + for _, base := range []string{"github.com/Alia5/viiper", "config", "server", "proxy"} { + add(&jsonPaths, filepath.Join(wd, base+".json")) + add(&yamlPaths, filepath.Join(wd, base+".yaml")) + add(&yamlPaths, filepath.Join(wd, base+".yml")) + add(&tomlPaths, filepath.Join(wd, base+".toml")) + } + + // Config home + if dir, err := DefaultConfigDir(); err == nil { + for _, base := range []string{"config", "server", "proxy"} { + add(&jsonPaths, filepath.Join(dir, base+".json")) + add(&yamlPaths, filepath.Join(dir, base+".yaml")) + add(&yamlPaths, filepath.Join(dir, base+".yml")) + add(&tomlPaths, filepath.Join(dir, base+".toml")) + } + } + + // System-wide (unix) + if runtime.GOOS != "windows" { + for _, base := range []string{"config", "server", "proxy"} { + add(&jsonPaths, filepath.Join("/etc/viiper", base+".json")) + add(&yamlPaths, filepath.Join("/etc/viiper", base+".yaml")) + add(&yamlPaths, filepath.Join("/etc/viiper", base+".yml")) + add(&tomlPaths, filepath.Join("/etc/viiper", base+".toml")) + } + } + + return +} diff --git a/internal/configpaths/keyfile_unix.go b/internal/configpaths/keyfile_unix.go new file mode 100644 index 00000000..e26064ab --- /dev/null +++ b/internal/configpaths/keyfile_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package configpaths + +import ( + "os" + "path/filepath" +) + +// KeyFileDir returns the directory where the API key file should be stored. +// On Unix, root services use /etc/viiper. +func KeyFileDir() (string, error) { + if os.Geteuid() == 0 { + return filepath.Join(string(os.PathSeparator), "etc", "viiper"), nil + } + return DefaultConfigDir() +} diff --git a/internal/configpaths/keyfile_windows.go b/internal/configpaths/keyfile_windows.go new file mode 100644 index 00000000..07bef71d --- /dev/null +++ b/internal/configpaths/keyfile_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package configpaths + +// KeyFileDir returns the directory where the API key file should be stored. +func KeyFileDir() (string, error) { + return DefaultConfigDir() +} diff --git a/viiper/internal/log/logging.go b/internal/log/logging.go similarity index 62% rename from viiper/internal/log/logging.go rename to internal/log/logging.go index f01af1a8..13f69dce 100644 --- a/viiper/internal/log/logging.go +++ b/internal/log/logging.go @@ -7,9 +7,11 @@ package log import ( "context" + "fmt" "io" "log/slog" "os" + "strings" ) // LevelTrace defines a custom slog level below Debug for very verbose output. @@ -32,6 +34,30 @@ func ParseLevel(s string) slog.Level { } } +// SetupLogger builds a slog.Logger with console and optional file handlers. +func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { + level := ParseLevel(logLevel) + var handlers []slog.Handler + + stdoutHandler := &colorHandler{w: os.Stdout, level: level} + handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l < slog.LevelError }, h: stdoutHandler}) + + stderrHandler := &colorHandler{w: os.Stderr, level: slog.LevelError} + handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l >= slog.LevelError }, h: stderrHandler}) + var closeFiles []io.Closer + if logFile != "" { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return nil, nil, err + } + closeFiles = append(closeFiles, f) + handlers = append(handlers, slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})) + } + logger := slog.New(MultiHandler{hs: handlers}) + slog.SetDefault(logger) + return logger, closeFiles, nil +} + // MultiHandler fans out records to multiple handlers. type MultiHandler struct{ hs []slog.Handler } @@ -92,29 +118,61 @@ func (f LevelFilter) WithGroup(name string) slog.Handler { return LevelFilter{pass: f.pass, h: f.h.WithGroup(name)} } -// SetupLogger builds a slog.Logger with console and optional file handlers. -func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { - level := ParseLevel(logLevel) - var handlers []slog.Handler +type colorHandler struct { + w io.Writer + level slog.Leveler +} - if logFile == "" { - stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l < slog.LevelError }, h: stdoutHandler}) +func (h *colorHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level.Level() +} - stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}) - handlers = append(handlers, LevelFilter{pass: func(l slog.Level) bool { return l >= slog.LevelError }, h: stderrHandler}) - } else { - handlers = append(handlers, slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) - } - var closeFiles []io.Closer - if logFile != "" { - f, err := os.OpenFile(logFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return nil, nil, err - } - closeFiles = append(closeFiles, f) - handlers = append(handlers, slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})) +func (h *colorHandler) Handle(_ context.Context, r slog.Record) error { + buf := strings.Builder{} + + buf.WriteString("\033[90m") + buf.WriteString(r.Time.Format("2006-01-02T15:04:05.000000Z07:00")) + buf.WriteString("\033[0m ") + + var color string + switch { + case r.Level >= slog.LevelError: + color = "\033[31m" + case r.Level >= slog.LevelWarn: + color = "\033[33m" + case r.Level >= slog.LevelInfo: + color = "\033[32m" + case r.Level >= slog.LevelDebug: + color = "\033[34m" + case r.Level >= LevelTrace: + color = "\033[35m" + default: + color = "\033[0m" } - logger := slog.New(MultiHandler{hs: handlers}) - return logger, closeFiles, nil + buf.WriteString(color) + fmt.Fprintf(&buf, "%5s", r.Level.String()) + buf.WriteString("\033[0m") + + buf.WriteString(" ") + buf.WriteString(r.Message) + + r.Attrs(func(a slog.Attr) bool { + buf.WriteString(" \033[90m") + buf.WriteString(a.Key) + buf.WriteString("=\033[0m") + buf.WriteString(a.Value.String()) + return true + }) + + buf.WriteString("\n") + _, err := h.w.Write([]byte(buf.String())) + return err +} + +func (h *colorHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *colorHandler) WithGroup(name string) slog.Handler { + return h } diff --git a/viiper/internal/log/rawlogger.go b/internal/log/rawlogger.go similarity index 94% rename from viiper/internal/log/rawlogger.go rename to internal/log/rawlogger.go index b2bade4a..3d8d3f88 100644 --- a/viiper/internal/log/rawlogger.go +++ b/internal/log/rawlogger.go @@ -1,61 +1,61 @@ -package log - -import ( - "bytes" - "fmt" - "io" - "sync" - "time" -) - -// RawLogger handles raw packet log with optional file output. -type RawLogger interface { - Log(in bool, data []byte) -} - -// rawLogger implements RawLogger with thread-safe log. -type rawLogger struct { - w io.Writer - mu sync.Mutex -} - -// NewRaw creates a new RawLogger. If writer is nil, returns a no-op logger. -func NewRaw(w io.Writer) RawLogger { - return &rawLogger{w: w} -} - -// Log emits a single-line raw packet log with timestamp and hex dump. -// in=true means client->server, in=false means server->client. -func (r *rawLogger) Log(in bool, data []byte) { - if len(data) == 0 { - return - } - if r.w == nil { - return - } - - dir := "S->C" - if in { - dir = "C->S" - } - - var hexbuf bytes.Buffer - const hexdigits = "0123456789abcdef" - for i, b := range data { - if i > 0 { - hexbuf.WriteByte(' ') - } - hexbuf.WriteByte(hexdigits[b>>4]) - hexbuf.WriteByte(hexdigits[b&0x0f]) - } - - line := fmt.Sprintf("%s %s chunk: %d bytes, hex: %s\n", - time.Now().Format("2006/01/02 15:04:05"), - dir, - len(data), - hexbuf.String()) - - r.mu.Lock() - _, _ = r.w.Write([]byte(line)) - r.mu.Unlock() -} +package log + +import ( + "bytes" + "fmt" + "io" + "sync" + "time" +) + +// RawLogger handles raw packet log with optional file output. +type RawLogger interface { + Log(in bool, data []byte) +} + +// rawLogger implements RawLogger with thread-safe log. +type rawLogger struct { + w io.Writer + mu sync.Mutex +} + +// NewRaw creates a new RawLogger. If writer is nil, returns a no-op logger. +func NewRaw(w io.Writer) RawLogger { + return &rawLogger{w: w} +} + +// Log emits a single-line raw packet log with timestamp and hex dump. +// in=true means client->server, in=false means server->client. +func (r *rawLogger) Log(in bool, data []byte) { + if len(data) == 0 { + return + } + if r.w == nil { + return + } + + dir := "S->C" + if in { + dir = "C->S" + } + + var hexbuf bytes.Buffer + const hexdigits = "0123456789abcdef" + for i, b := range data { + if i > 0 { + hexbuf.WriteByte(' ') + } + hexbuf.WriteByte(hexdigits[b>>4]) + hexbuf.WriteByte(hexdigits[b&0x0f]) + } + + line := fmt.Sprintf("%s %s chunk: %d bytes, hex: %s\n", + time.Now().Format("2006/01/02 15:04:05"), + dir, + len(data), + hexbuf.String()) + + r.mu.Lock() + _, _ = r.w.Write([]byte(line)) + r.mu.Unlock() +} diff --git a/internal/registry/devices.go b/internal/registry/devices.go new file mode 100644 index 00000000..141b959f --- /dev/null +++ b/internal/registry/devices.go @@ -0,0 +1,10 @@ +package registry + +import ( + _ "github.com/Alia5/VIIPER/device/dualsense" + _ "github.com/Alia5/VIIPER/device/dualshock4" + _ "github.com/Alia5/VIIPER/device/keyboard" + _ "github.com/Alia5/VIIPER/device/mouse" + _ "github.com/Alia5/VIIPER/device/ns2pro" + _ "github.com/Alia5/VIIPER/device/xbox360" +) diff --git a/internal/server/api/auth/auth.go b/internal/server/api/auth/auth.go new file mode 100644 index 00000000..4fa4c034 --- /dev/null +++ b/internal/server/api/auth/auth.go @@ -0,0 +1,55 @@ +package auth + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "errors" +) + +const ( + AutoGenKeyLength = 16 + Base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + PBKDF2Iterations = 100000 + PBKDF2Salt = "VIIPER-Key-v1" +) + +// GenerateKey creates a random 16-char base62 key +func GenerateKey() (string, error) { + randomBytes := make([]byte, AutoGenKeyLength) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + key := make([]byte, AutoGenKeyLength) + for i, b := range randomBytes { + key[i] = Base62Chars[int(b)%62] + } + + return string(key), nil +} + +// DeriveKey uses PBKDF2 to stretch any password to 32 bytes +func DeriveKey(password string) ([]byte, error) { + if password == "" { + return nil, errors.New("password cannot be empty") + } + return pbkdf2.Key( + sha256.New, + password, + []byte(PBKDF2Salt), + PBKDF2Iterations, + 32, + ) +} + +// DeriveSessionKey creates unique session key from key and nonces +// SHA mixing is used for easier client implementations +func DeriveSessionKey(key, serverNonce, clientNonce []byte) []byte { + h := sha256.New() + h.Write(key) + h.Write(serverNonce) + h.Write(clientNonce) + h.Write([]byte("VIIPER-Session-v1")) + return h.Sum(nil) +} diff --git a/internal/server/api/auth/auth_test.go b/internal/server/api/auth/auth_test.go new file mode 100644 index 00000000..4146ec66 --- /dev/null +++ b/internal/server/api/auth/auth_test.go @@ -0,0 +1,96 @@ +package auth_test + +import ( + "errors" + "testing" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/stretchr/testify/assert" +) + +func TestGenKey(t *testing.T) { + + key, err := auth.GenerateKey() + assert.NoError(t, err) + assert.Len(t, key, auth.AutoGenKeyLength) + assert.Regexp(t, "^[0-9A-Za-z]{16}$", key) + +} + +func BenchmarkGenKey(b *testing.B) { + var key string + var err error + for b.Loop() { + key, err = auth.GenerateKey() + } + assert.NoError(b, err) + assert.Len(b, key, auth.AutoGenKeyLength) +} + +func TestDeriveKey(t *testing.T) { + + type testCase struct { + name string + password string + expectedKey []byte + expectedErr error + } + + testCases := []testCase{ + { + name: "Normal Password", + password: "password123", + expectedKey: []byte{0x94, 0x50, 0x29, 0x55, 0x1, 0xd7, 0x3, 0xf, 0x4, 0x61, 0xf, 0x81, 0x6a, 0xdf, 0x43, 0x1c, 0xaf, 0x8f, 0xc8, 0x21, 0xd4, 0xc1, 0x2f, 0x2f, 0x21, 0x2c, 0x1b, 0xf8, 0x64, 0x46, 0x9, 0x82}, + }, + { + name: "Simple Password", + password: "1", + expectedKey: []byte{0xfe, 0xdf, 0xdf, 0x4d, 0xab, 0xd2, 0x5d, 0x9f, 0xfd, 0x97, 0x96, 0xec, 0x76, 0xd2, 0xa2, 0xec, 0x2, 0x4f, 0xbf, 0xeb, 0x17, 0x8c, 0x6, 0x13, 0xed, 0x4f, 0x10, 0x9e, 0x4d, 0xef, 0xd1, 0xd2}, + }, + { + name: "empty password", + password: "", + expectedKey: []byte{}, + expectedErr: errors.New("password cannot be empty"), + }, + { + name: "long password", + password: "dkfghdfg90d78h350ß8dgfjkdfg#---23489dfg!!!@!@#$$%&/()=", + expectedKey: []byte{0xb4, 0xb9, 0xf5, 0x37, 0xa6, 0xac, 0x8a, 0x35, 0xc5, 0xe7, 0x1a, 0x90, 0xf9, 0x7e, 0xab, 0x38, 0x22, 0x83, 0xd8, 0xc6, 0xa, 0xcf, 0xbf, 0x7c, 0x3d, 0xd6, 0x6c, 0xd4, 0x35, 0x3b, 0x88, 0x2c}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + derivedKey, err := auth.DeriveKey(tc.password) + if tc.expectedErr != nil { + assert.Equal(t, tc.expectedErr, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedKey, derivedKey) + }) + } +} + +func TestDeriveSessionKey(t *testing.T) { + key := make([]byte, 32) + serverNonce := make([]byte, 32) + clientNonce := make([]byte, 32) + + for i := range key { + key[i] = byte(i) + serverNonce[i] = byte(i + 10) + clientNonce[i] = byte(i + 20) + } + + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + assert.Len(t, sessionKey, 32) + + sessionKey2 := auth.DeriveSessionKey(key, serverNonce, clientNonce) + assert.Equal(t, sessionKey, sessionKey2) + + clientNonce[0] = 99 + sessionKey3 := auth.DeriveSessionKey(key, serverNonce, clientNonce) + assert.NotEqual(t, sessionKey, sessionKey3) +} diff --git a/internal/server/api/auth/conn.go b/internal/server/api/auth/conn.go new file mode 100644 index 00000000..cbc8680d --- /dev/null +++ b/internal/server/api/auth/conn.go @@ -0,0 +1,86 @@ +package auth + +import ( + "bytes" + "crypto/cipher" + "encoding/binary" + "io" + "net" + "sync" + + "golang.org/x/crypto/chacha20poly1305" +) + +type Conn struct { + net.Conn + aead cipher.AEAD + sendCtr uint64 + recvBuf bytes.Buffer + mu sync.Mutex +} + +const maxPacketSize = 2 * 1024 * 1024 // 2 MB + +func WrapConn(conn net.Conn, sessionKey []byte) (net.Conn, error) { + aead, err := chacha20poly1305.New(sessionKey) + if err != nil { + return nil, err + } + return &Conn{Conn: conn, aead: aead}, nil +} + +func (s *Conn) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + nonce := make([]byte, 12) + binary.BigEndian.PutUint64(nonce[4:], s.sendCtr) + s.sendCtr++ + + ct := s.aead.Seal(nil, nonce, p, nil) + length := uint32(len(nonce) + len(ct)) + + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], length) + + if i, err := s.Conn.Write(hdr[:]); err != nil { + return i, err + } + if i, err := s.Conn.Write(nonce); err != nil { + return i, err + } + if i, err := s.Conn.Write(ct); err != nil { + return i, err + } + + return len(p), nil +} + +func (s *Conn) Read(p []byte) (int, error) { + if s.recvBuf.Len() == 0 { + var hdr [4]byte + if i, err := io.ReadFull(s.Conn, hdr[:]); err != nil { + return i, err + } + length := binary.BigEndian.Uint32(hdr[:]) + if length > maxPacketSize { + return 0, io.ErrUnexpectedEOF + } + + pkt := make([]byte, length) + if i, err := io.ReadFull(s.Conn, pkt); err != nil { + return i, err + } + + nonce := pkt[:12] + ct := pkt[12:] + + pt, err := s.aead.Open(nil, nonce, ct, nil) + if err != nil { + return 0, err + } + + s.recvBuf.Write(pt) + } + return s.recvBuf.Read(p) +} diff --git a/internal/server/api/auth/conn_test.go b/internal/server/api/auth/conn_test.go new file mode 100644 index 00000000..7c2153c6 --- /dev/null +++ b/internal/server/api/auth/conn_test.go @@ -0,0 +1,188 @@ +package auth_test + +import ( + "errors" + "net" + "testing" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/stretchr/testify/assert" +) + +func TestConn(t *testing.T) { + + type testCase struct { + name string + wrapConn func(net.Conn, []byte) (net.Conn, error) + setupFn func(clientConn net.Conn, serverConn net.Conn) (clientKey []byte, serverKey []byte) + input []byte + expected []byte + expectedErr error + } + + testCases := []testCase{ + { + name: "valid read", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + password := "test123" + key, err := auth.DeriveKey(password) + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + return key, key + }, + input: []byte("Hello, World!"), + expected: []byte("Hello, World!"), + }, + { + name: "Differing Keys", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + key, err := auth.DeriveKey("test123") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + key2, err := auth.DeriveKey("123test") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + return key, key2 + }, + input: []byte("x"), + expected: nil, + expectedErr: errors.New("chacha20poly1305: message authentication failed"), + }, + { + name: "bad key length (client)", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + key, err := auth.DeriveKey("test123") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + return []byte{1, 2, 3}, key // invalid key length on client + }, + input: []byte("x"), + expected: nil, + expectedErr: errors.New("chacha20poly1305: bad key length"), + }, + { + name: "bad key length (server)", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + key, err := auth.DeriveKey("test123") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + return key, []byte{1, 2, 3} // invalid key length on server + }, + input: []byte("x"), + expected: nil, + expectedErr: errors.New("chacha20poly1305: bad key length"), + }, + { + name: "client closed before write", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + key, err := auth.DeriveKey("test123") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + _ = clientConn.Close() + return key, key + }, + input: []byte("x"), + expected: nil, + expectedErr: errors.New("use of closed network connection"), + }, + { + name: "server closed before read", + wrapConn: auth.WrapConn, + setupFn: func(clientConn, serverConn net.Conn) (clientKey []byte, serverKey []byte) { + key, err := auth.DeriveKey("test123") + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + _ = serverConn.Close() + return key, key + }, + input: []byte("x"), + expected: nil, + // just check for error, linux/win differ + expectedErr: errors.New(""), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start test server: %v", err) + } + clientConn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("failed to connect to test server: %v", err) + } + serverConn, err := ln.Accept() + if err != nil { + t.Fatalf("failed to accept connection: %v", err) + } + defer ln.Close() //nolint:errcheck + defer clientConn.Close() //nolint:errcheck + defer serverConn.Close() //nolint:errcheck + + var clientKey, serverKey []byte + if tc.setupFn != nil { + clientKey, serverKey = tc.setupFn(clientConn, serverConn) + } + + var wrappedServerConn net.Conn + var wrappedClientConn net.Conn + if tc.wrapConn != nil { + wrappedServerConn, err = tc.wrapConn(serverConn, serverKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap server conn: %v", err) + } + return + } + wrappedClientConn, err = tc.wrapConn(clientConn, clientKey) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap client conn: %v", err) + } + return + } + } + + _, err = wrappedClientConn.Write(tc.input) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Fatalf("failed to wrap client conn: %v", err) + } + return + } + buf := make([]byte, len(tc.expected)) + _, err = wrappedServerConn.Read(buf) + if err != nil { + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + } else { + t.Errorf("server read error: %v", err) + } + return + } + assert.Equal(t, tc.expected, buf) + + }) + } + +} diff --git a/internal/server/api/auth/handshake.go b/internal/server/api/auth/handshake.go new file mode 100644 index 00000000..0bad0b36 --- /dev/null +++ b/internal/server/api/auth/handshake.go @@ -0,0 +1,142 @@ +package auth + +import ( + "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "strings" + + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/viipertypes" +) + +const ( + HandshakeMagic = "eVI1\x00" + NonceSize = 32 + authContext = "VIIPER-Auth-v1" +) + +// ReadClientNonce reads client nonce from handshake +// Expects handshake magic already consumed, reads only the 32-byte nonce +func ReadClientNonce(r io.Reader) (clientNonce []byte, err error) { + clientNonce = make([]byte, NonceSize) + if _, err = io.ReadFull(r, clientNonce); err != nil { + return nil, fmt.Errorf("read client nonce: %w", err) + } + return clientNonce, nil +} + +// WriteServerHandshake generates server nonce and sends response +// Sends: "OK\0" + server_nonce[32] +func WriteServerHandshake(w io.Writer) (serverNonce []byte, err error) { + if w == nil { + return nil, fmt.Errorf("write response: write on nil pointer") + } + serverNonce = make([]byte, NonceSize) + if _, err = rand.Read(serverNonce); err != nil { + return nil, fmt.Errorf("generate server nonce: %w", err) + } + + response := append([]byte("OK\x00"), serverNonce...) + if _, err = w.Write(response); err != nil { + return nil, fmt.Errorf("write response: %w", err) + } + + return serverNonce, nil +} + +// IsAuthHandshake checks if the next bytes in reader match the handshake magic +func IsAuthHandshake(r *bufio.Reader) (bool, error) { + b, err := r.Peek(len(HandshakeMagic)) + if err != nil { + return false, err + } + return string(b) == HandshakeMagic, nil +} + +// HandleAuthHandshake performs the authentication handshake +func HandleAuthHandshake(r *bufio.Reader, w io.Writer, key []byte, isClient bool) (clientNonce, serverNonce []byte, err error) { + if r == nil { + return nil, nil, fmt.Errorf("handshake: nil reader") + } + if len(key) == 0 { + return nil, nil, fmt.Errorf("handshake: missing key") + } + + if isClient { + if w == nil { + return nil, nil, fmt.Errorf("handshake: nil writer") + } + clientNonce = make([]byte, NonceSize) + if _, err := rand.Read(clientNonce); err != nil { + return nil, nil, fmt.Errorf("generate client nonce: %w", err) + } + + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(authContext)) + _, _ = mac.Write(clientNonce) + clientAuth := mac.Sum(nil) + + msg := append([]byte(HandshakeMagic), clientNonce...) + msg = append(msg, clientAuth...) + if _, err := w.Write(msg); err != nil { + return nil, nil, fmt.Errorf("write handshake: %w", err) + } + + respPrefix := make([]byte, 3) + if _, err := io.ReadFull(r, respPrefix); err != nil { + return nil, nil, fmt.Errorf("read handshake response: %w", err) + } + if string(respPrefix) != "OK\x00" { + rest, _ := io.ReadAll(r) + raw := append(respPrefix, rest...) + line := strings.TrimSuffix(string(raw), "\n") + + var apiErr viipertypes.APIError + if err := json.Unmarshal([]byte(line), &apiErr); err == nil && (apiErr.Status != 0 || apiErr.Title != "") { + return nil, nil, &apiErr + } + return nil, nil, fmt.Errorf("invalid handshake response from server: %s", line) + } + + serverNonce = make([]byte, NonceSize) + if _, err := io.ReadFull(r, serverNonce); err != nil { + return nil, nil, fmt.Errorf("read server nonce: %w", err) + } + return clientNonce, serverNonce, nil + } + + _, err = r.Discard(len(HandshakeMagic)) + if err != nil { + return nil, nil, fmt.Errorf("discard handshake magic: %w", err) + } + + clientNonce, err = ReadClientNonce(r) + if err != nil { + return nil, nil, err + } + + clientAuth := make([]byte, sha256.Size) + if _, err := io.ReadFull(r, clientAuth); err != nil { + return nil, nil, fmt.Errorf("read client auth: %w", err) + } + + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(authContext)) + _, _ = mac.Write(clientNonce) + expectedAuth := mac.Sum(nil) + if !hmac.Equal(clientAuth, expectedAuth) { + return nil, nil, apierror.ErrUnauthorized("invalid password") + } + + serverNonce, err = WriteServerHandshake(w) + if err != nil { + return nil, nil, err + } + + return clientNonce, serverNonce, nil +} diff --git a/internal/server/api/auth/handshake_test.go b/internal/server/api/auth/handshake_test.go new file mode 100644 index 00000000..ff530734 --- /dev/null +++ b/internal/server/api/auth/handshake_test.go @@ -0,0 +1,270 @@ +package auth_test + +import ( + "bufio" + "bytes" + "crypto/hmac" + "crypto/sha256" + "fmt" + "io" + "testing" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/stretchr/testify/assert" +) + +func TestReadClientNonce(t *testing.T) { + type testCase struct { + name string + input []byte + expectedNonce []byte + expectedErr error + } + + validNonce := make([]byte, 32) + for i := range validNonce { + validNonce[i] = byte(i) + } + + testCases := []testCase{ + { + name: "Valid nonce", + input: validNonce, + expectedNonce: validNonce, + expectedErr: nil, + }, + { + name: "Short input", + input: []byte{1, 2, 3}, + expectedNonce: nil, + expectedErr: fmt.Errorf("read client nonce: unexpected EOF"), + }, + { + name: "Empty input", + input: []byte{}, + expectedNonce: nil, + expectedErr: fmt.Errorf("read client nonce: EOF"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buf := bytes.NewBuffer(tc.input) + nonce, err := auth.ReadClientNonce(buf) + + if tc.expectedErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedNonce, nonce) + }) + } +} + +func TestWriteServerHandshake(t *testing.T) { + type testCase struct { + name string + writer io.Writer + expectedErr error + } + + testCases := []testCase{ + { + name: "Success", + writer: bytes.NewBuffer(nil), + expectedErr: nil, + }, + { + name: "Err no writer", + writer: nil, + expectedErr: fmt.Errorf("write response: write on nil pointer"), + }, + { + name: "Err closed writer", + writer: func() io.Writer { + _, w := io.Pipe() + w.Close() // nolint + return w + }(), + expectedErr: fmt.Errorf("write response: io: read/write on closed pipe"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + serverNonce, err := auth.WriteServerHandshake(tc.writer) + + if tc.expectedErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.Len(t, serverNonce, 32) + + buf := tc.writer.(*bytes.Buffer) + expectedResponse := buf.Bytes() + assert.Equal(t, "OK\x00", string(expectedResponse[:3])) + assert.Equal(t, serverNonce, expectedResponse[3:]) + assert.Len(t, expectedResponse, 35) + }) + } +} + +func TestIsAuthHandshake(t *testing.T) { + type testCase struct { + name string + input *bufio.Reader + expectedResult bool + expectedErr error + } + testCases := []testCase{ + { + name: "IS_AUTH", + input: bufio.NewReader(bytes.NewBuffer([]byte(auth.HandshakeMagic))), + expectedResult: true, + expectedErr: nil, + }, + { + name: "NOT_AUTH", + input: bufio.NewReader(bytes.NewBuffer([]byte("HEsdffdLLO\x00"))), + expectedResult: false, + expectedErr: nil, + }, + { + name: "ERR_INCOMPLETE", + input: bufio.NewReader(bytes.NewBuffer([]byte("eV"))), + expectedResult: false, + expectedErr: fmt.Errorf("EOF"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := auth.IsAuthHandshake(tc.input) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + }) + } + +} + +func TestFullHandshake(t *testing.T) { + + type testCase struct { + name string + reader *bufio.Reader + writer io.Writer + key []byte + isClient bool + expectedErr error + } + + validKey, err := auth.DeriveKey("test123") + assert.NoError(t, err) + wrongKey, err := auth.DeriveKey("wrongpass") + assert.NoError(t, err) + + clientNonce := make([]byte, 32) + for i := range clientNonce { + clientNonce[i] = byte(i) + } + mac := hmac.New(sha256.New, validKey) + _, _ = mac.Write([]byte("VIIPER-Auth-v1")) + _, _ = mac.Write(clientNonce) + clientAuth := mac.Sum(nil) + + validHandshake := append([]byte(auth.HandshakeMagic), clientNonce...) + validHandshake = append(validHandshake, clientAuth...) + testCases := []testCase{ + { + name: "Successful Handshake", + reader: bufio.NewReader(bytes.NewBuffer(validHandshake)), + writer: bytes.NewBuffer(nil), + key: validKey, + isClient: false, + expectedErr: nil, + }, + { + name: "Err reading client nonce", + reader: bufio.NewReader(bytes.NewBuffer(append([]byte(auth.HandshakeMagic), []byte("short")...))), + writer: bytes.NewBuffer(nil), + key: validKey, + isClient: false, + expectedErr: fmt.Errorf("read client nonce: unexpected EOF"), + }, + { + name: "Err writing server handshake", + reader: bufio.NewReader(bytes.NewBuffer(validHandshake)), + writer: nil, + key: validKey, + isClient: false, + expectedErr: fmt.Errorf("write response: write on nil pointer"), + }, + { + name: "Err discarding handshake magic", + reader: bufio.NewReader(bytes.NewBuffer([]byte("sh"))), + writer: bytes.NewBuffer(nil), + key: validKey, + isClient: false, + expectedErr: fmt.Errorf("discard handshake magic: EOF"), + }, + { + name: "Err closed writer", + reader: bufio.NewReader(bytes.NewBuffer(validHandshake)), + writer: func() io.Writer { + _, w := io.Pipe() + w.Close() // nolint + return w + }(), + key: validKey, + isClient: false, + expectedErr: fmt.Errorf("write response: io: read/write on closed pipe"), + }, + { + name: "Err Tried unauthenticated handshake", + reader: bufio.NewReader(bytes.NewBuffer([]byte("NOT_A_HANDSHAKE"))), + writer: bytes.NewBuffer(nil), + key: validKey, + isClient: false, + expectedErr: fmt.Errorf("read client nonce: unexpected EOF"), + }, + { + name: "Err invalid password", + reader: bufio.NewReader(bytes.NewBuffer(validHandshake)), + writer: bytes.NewBuffer(nil), + key: wrongKey, + isClient: false, + expectedErr: apierror.ErrUnauthorized("invalid password"), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + clientNonce, serverNonce, err := auth.HandleAuthHandshake( + tc.reader, + tc.writer, + tc.key, + tc.isClient, + ) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + assert.NoError(t, err) + assert.Len(t, clientNonce, 32) + assert.Len(t, serverNonce, 32) + }) + } + +} diff --git a/internal/server/api/autoattach.go b/internal/server/api/autoattach.go new file mode 100644 index 00000000..810a18e0 --- /dev/null +++ b/internal/server/api/autoattach.go @@ -0,0 +1,12 @@ +package api + +import ( + "context" + "log/slog" + + "github.com/Alia5/VIIPER/usbip" +) + +func AttachLocalhostClient(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error { + return attachLocalhostClientImpl(ctx, deviceExportMeta, usbipServerPort, useNativeIOCTL, logger) +} diff --git a/internal/server/api/autoattach_linux.go b/internal/server/api/autoattach_linux.go new file mode 100644 index 00000000..311433e0 --- /dev/null +++ b/internal/server/api/autoattach_linux.go @@ -0,0 +1,76 @@ +//go:build linux + +package api + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strconv" + + "github.com/Alia5/VIIPER/usbip" +) + +func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, _ bool, logger *slog.Logger) error { + logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID) + + cmd := exec.CommandContext( + ctx, + "usbip", + "--tcp-port", + strconv.FormatUint(uint64(usbipServerPort), 10), + "attach", + "-r", "localhost", + "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID), + ) + output, err := cmd.CombinedOutput() + if err != nil { + logger.Error("Failed to attach device", + "error", err, + "port", usbipServerPort, + "output", string(output)) + return err + } + logger.Debug("usbip attach output", "output", string(output)) + + return nil +} + +// CheckAutoAttachPrerequisites checks if auto-attach prerequisites are met on Linux. +// Returns true if all requirements are satisfied, false otherwise with helpful log messages. +func CheckAutoAttachPrerequisites(_ bool, logger *slog.Logger) bool { + allOk := true + + if _, err := exec.LookPath("usbip"); err != nil { + logger.Warn("USB/IP tool 'usbip' not found in PATH") + logger.Warn("Auto-attach requires the usbip command-line tool") + logger.Info("Install usbip:") + logger.Info(" Ubuntu/Debian: sudo apt install linux-tools-generic") + logger.Info(" Arch Linux: sudo pacman -S usbip") + allOk = false + } else { + logger.Debug("usbip tool found in PATH") + } + + data, err := os.ReadFile("/proc/modules") + if err != nil { + logger.Debug("Could not read /proc/modules", "error", err) + // dont fail, try anyway + } else if !bytes.Contains(data, []byte("vhci_hcd")) { + logger.Warn("USB/IP kernel module 'vhci-hcd' is not loaded") + logger.Warn("Auto-attach will not work until the module is loaded") + logger.Info("To load the module now, run in another terminal:") + logger.Info(" sudo modprobe vhci-hcd") + logger.Info("") + logger.Info("To automatically load at boot:") + logger.Info(" echo 'vhci-hcd' | sudo tee /etc/modules-load.d/viiper.conf") + allOk = false + } else { + logger.Debug("vhci-hcd kernel module is loaded") + } + + return allOk +} diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go new file mode 100644 index 00000000..a3b37774 --- /dev/null +++ b/internal/server/api/autoattach_windows.go @@ -0,0 +1,330 @@ +//go:build windows + +package api + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "unsafe" + + "github.com/Alia5/VIIPER/usbip" + "golang.org/x/sys/windows" +) + +var ( + setupapi = windows.NewLazySystemDLL("setupapi.dll") + procSetupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW") + procSetupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces") + procSetupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW") + procSetupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList") +) + +const ( + DigcfPresent = 0x00000002 + DigcfDeviceInterface = 0x00000010 +) + +type SpDeviceInterfaceData struct { + CbSize uint32 + InterfaceClassGUID windows.GUID + Flags uint32 + Reserved uintptr +} + +type SpDeviceInterfaceDetailData struct { + CbSize uint32 + DevicePath [1]uint16 +} + +// Device GUID from usbip-win2 driver +var deviceGUID = windows.GUID{ + Data1: 0xB4030C06, + Data2: 0xDC5F, + Data3: 0x4FCC, + Data4: [8]byte{0x87, 0xEB, 0xE5, 0x51, 0x5A, 0x09, 0x35, 0xC0}, +} + +const ( + niMaxHost = 1025 + niMaxServ = 32 +) + +// PLUGIN_HARDWARE structure from usbip-win2 +type attachIOCTL struct { + Size uint32 + PortOutput int32 + BusID [32]byte + Service [niMaxServ]byte + Host [niMaxHost]byte +} + +const ( + fileDeviceUnknown = 0x00000022 + methodBuffered = 0 + fileReadData = 0x0001 + fileWriteData = 0x0002 + ioctlPluginHardware = (fileDeviceUnknown << 16) | ((fileReadData | fileWriteData) << 14) | (0x800 << 2) | methodBuffered +) + +func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error { + if useNativeIOCTL { + err := attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger) + if err != nil { + slog.Error("Native IOCTL auto-attach failed, falling back to command execution", "error", err) + slog.Info("Trying fallback via usbip executable") + } else { + return nil + } + } + return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger) +} + +func attachViaIOCTL(_ context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error { + logger.Info("Auto-attaching localhost client via native IOCTL", + "busID", deviceExportMeta.BusID, + "deviceID", deviceExportMeta.DevID) + + if usbipServerPort == 0 { + return fmt.Errorf("argumentValidation: invalid TCP port number (0)") + } + + devicePath, err := getDeviceInterfacePath(&deviceGUID) + if err != nil { + return fmt.Errorf("discovery: %w", err) + } + + logger.Debug("Found usbip-win2 device", "path", devicePath) + + var ioctlData attachIOCTL + ioctlData.Size = uint32(unsafe.Sizeof(ioctlData)) + + busID := fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID) + if len(busID) >= len(ioctlData.BusID) { + return fmt.Errorf("argumentValidation: bus ID too long: %s", busID) + } + copy(ioctlData.BusID[:], busID) + + service := fmt.Sprintf("%d", usbipServerPort) + if len(service) >= len(ioctlData.Service) { + return fmt.Errorf("argumentValidation: service string too long: %s", service) + } + copy(ioctlData.Service[:], service) + copy(ioctlData.Host[:], "localhost") + + devicePathUTF16, err := windows.UTF16PtrFromString(devicePath) + if err != nil { + return fmt.Errorf("open: failed to convert device path: %w", err) + } + + handle, err := windows.CreateFile( + devicePathUTF16, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return fmt.Errorf("open: failed to open usbip-win2 device: %w", err) + } + defer windows.CloseHandle(handle) // nolint + + logger.Debug("Opened device handle") + + var bytesReturned uint32 + err = windows.DeviceIoControl( + handle, + ioctlPluginHardware, + (*byte)(unsafe.Pointer(&ioctlData)), + uint32(unsafe.Sizeof(ioctlData)), + (*byte)(unsafe.Pointer(&ioctlData)), + uint32(unsafe.Sizeof(ioctlData)), + &bytesReturned, + nil, + ) + if err != nil { + return fmt.Errorf("IOControl: DeviceIoControl failed: %w", err) + } + + logger.Debug("IOCTL completed", "bytesReturned", bytesReturned, "portOutput", ioctlData.PortOutput) + + if ioctlData.PortOutput <= 0 { + return fmt.Errorf("ResponseValidation: invalid USB port returned: %d", ioctlData.PortOutput) + } + + logger.Info("Successfully attached device via IOCTL", + "busID", deviceExportMeta.BusID, + "deviceID", deviceExportMeta.DevID, + "usbPort", ioctlData.PortOutput) + + return nil +} + +func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error { + logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID) + + cmd := exec.CommandContext( + ctx, + resolveUsbipExecutable(), + "--tcp-port", + strconv.FormatUint(uint64(usbipServerPort), 10), + "attach", + "-r", "localhost", + "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID), + ) + output, err := cmd.CombinedOutput() + if err != nil { + logger.Error("Failed to attach device", + "error", err, + "port", usbipServerPort, + "output", string(output)) + return err + } + logger.Debug("usbip attach output", "output", string(output)) + + return nil +} + +func resolveUsbipExecutable() string { + if path, err := exec.LookPath("usbip"); err == nil { + return path + } + + // The usbip-win2 installer does not consistently add its directory to + // PATH for already-running services. Prefer the standard install location + // before returning the bare command and its useful original error. + seen := make(map[string]struct{}) + for _, root := range []string{ + os.Getenv("ProgramW6432"), + os.Getenv("ProgramFiles"), + os.Getenv("ProgramFiles(x86)"), + } { + if root == "" { + continue + } + + candidate := filepath.Join(root, "USBip", "usbip.exe") + key := strings.ToLower(candidate) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + + return "usbip" +} + +func getDeviceInterfacePath(guid *windows.GUID) (string, error) { + r0, _, e1 := syscall.SyscallN(procSetupDiGetClassDevsW.Addr(), + uintptr(unsafe.Pointer(guid)), + 0, + 0, + uintptr(DigcfPresent|DigcfDeviceInterface)) + + devInfo := windows.Handle(r0) + if devInfo == windows.InvalidHandle { + if e1 != 0 { + return "", fmt.Errorf("discovery: SetupDiGetClassDevsW failed: %w", e1) + } + return "", fmt.Errorf("discovery: SetupDiGetClassDevsW failed with invalid handle") + } + defer func() { + _, _, err := syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) + if err != 0 { + slog.Error("SetupDiDestroyDeviceInfoList failed", "error", err) + } + }() + + var interfaceData SpDeviceInterfaceData + interfaceData.CbSize = uint32(unsafe.Sizeof(interfaceData)) + + r1, _, e2 := syscall.SyscallN(procSetupDiEnumDeviceInterfaces.Addr(), + uintptr(devInfo), + 0, + uintptr(unsafe.Pointer(guid)), + 0, + uintptr(unsafe.Pointer(&interfaceData))) + + if r1 == 0 { + if e2 != 0 { + return "", fmt.Errorf("discovery: usbip-win2 driver not found: %w", e2) + } + return "", fmt.Errorf("discovery: usbip-win2 driver not found") + } + + var requiredSize uint32 + r2, _, err := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + 0, + 0, + uintptr(unsafe.Pointer(&requiredSize)), + 0) + if r2 == 0 && err != windows.ERROR_INSUFFICIENT_BUFFER { + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW (size query) failed: %w", err) + } + if requiredSize == 0 { + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW (size query) returned invalid required size") + } + + detailData := make([]byte, requiredSize) + detailHeader := (*SpDeviceInterfaceDetailData)(unsafe.Pointer(&detailData[0])) + detailHeader.CbSize = uint32(unsafe.Sizeof(SpDeviceInterfaceDetailData{})) + + r3, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + uintptr(unsafe.Pointer(detailHeader)), + uintptr(requiredSize), + 0, + 0) + + if r3 == 0 { + if e3 != 0 { + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW failed: %w", e3) + } + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW failed") + } + + path := windows.UTF16PtrToString(&detailHeader.DevicePath[0]) + return path, nil +} + +func CheckAutoAttachPrerequisites(useNativeIOCTL bool, logger *slog.Logger) bool { + if useNativeIOCTL { + _, err := getDeviceInterfacePath(&deviceGUID) + if err != nil { + logger.Warn("Native IOCTL auto-attach prerequisites not met", "error", err) + logger.Warn("Native IOCTL auto-attach is unavailable until discovery succeeds") + logger.Info("If usbip-win2 is not installed, download and install:") + logger.Info(" https://github.com/vadimgrn/usbip-win2") + logger.Info(" https://github.com/OSSign/vadimgrn--usbip-win2") + return false + } + logger.Debug("usbip-win2 driver found") + return true + } + + if _, err := exec.LookPath("usbip.exe"); err != nil { + logger.Warn("USB/IP tool 'usbip.exe' not found in PATH") + logger.Warn("Auto-attach requires usbip-win2") + logger.Info("Download and install usbip-win2:") + logger.Info(" https://github.com/vadimgrn/usbip-win2") + return false + } + + logger.Debug("usbip.exe tool found in PATH") + return true +} diff --git a/internal/server/api/config.go b/internal/server/api/config.go new file mode 100644 index 00000000..3c446c47 --- /dev/null +++ b/internal/server/api/config.go @@ -0,0 +1,15 @@ +package api + +import "time" + +// ServerConfig represents the server subcommand configuration. +type ServerConfig struct { + Addr string `help:"API server listen address" default:":3242" env:"VIIPER_API_ADDR"` + DeviceHandlerConnectTimeout time.Duration `help:"Time before auto-cleanup occurs when device handler has no active connection" default:"5s" env:"VIIPER_API_DEVICE_HANDLER_TIMEOUT"` + AutoAttachLocalClient bool `help:"Controls usbip-client on localhost to auto-attach devices added to the virtual bus" default:"true" env:"VIIPER_API_AUTO_ATTACH_LOCAL_CLIENT"` + RequireLocalHostAuth bool `help:"Require authentication for clients connecting from localhost" default:"false" env:"VIIPER_API_REQUIRE_LOCALHOST_AUTH"` + ConnectionTimeout time.Duration `kong:"-"` + PlatformOpts `embed:""` + // password for api (remote) server auth (ALWAYS read from file) + Password string `kong:"-"` +} diff --git a/internal/server/api/config_unix.go b/internal/server/api/config_unix.go new file mode 100644 index 00000000..1da00bfe --- /dev/null +++ b/internal/server/api/config_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package api + +type PlatformOpts struct { + AutoAttachWindowsNative bool `kong:"-"` +} diff --git a/internal/server/api/config_windows.go b/internal/server/api/config_windows.go new file mode 100644 index 00000000..1252290b --- /dev/null +++ b/internal/server/api/config_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package api + +type PlatformOpts struct { + AutoAttachWindowsNative bool `default:"true" help:"Use native IOCTL instead of usbip.exe for auto-attach" env:"VIIPER_API_AUTO_ATTACH_WINDOWS_NATIVE"` +} diff --git a/viiper/internal/server/api/device_registry.go b/internal/server/api/device_registry.go similarity index 60% rename from viiper/internal/server/api/device_registry.go rename to internal/server/api/device_registry.go index 56642bcd..97982ac0 100644 --- a/viiper/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -1,64 +1,71 @@ -package api - -import ( - "sync" - "viiper/pkg/usb" -) - -// NOTE: Stream handlers now share the unified StreamHandler type (see router.go). -// They return an error to signal terminal failures; successful completion should -// generally return nil. Handlers still own the connection lifecycle. - -// DeviceRegistration describes a device type, providing both device creation -// and stream handler registration. -type DeviceRegistration interface { - // CreateDevice returns a new device instance of this type. - CreateDevice() usb.Device - // StreamHandler returns the handler function for long-lived connections. - // The provided device (if non-nil) can be used to bind handler behavior, but handlers - // should not rely on it being set at registration time. - StreamHandler() StreamHandlerFunc -} - -var ( - deviceRegistry = make(map[string]DeviceRegistration) - deviceRegistryMu sync.RWMutex -) - -// RegisterDevice registers a device type for dynamic creation and handler dispatch. -// This should be called from device package init() functions. -// The name is case-insensitive and will be lowercased. -func RegisterDevice(name string, reg DeviceRegistration) { - deviceRegistryMu.Lock() - defer deviceRegistryMu.Unlock() - deviceRegistry[toLower(name)] = reg -} - -// GetRegistration retrieves a registered device handler by name for device creation. -// Returns nil if not found. Name lookup is case-insensitive. -func GetRegistration(name string) DeviceRegistration { - deviceRegistryMu.RLock() - defer deviceRegistryMu.RUnlock() - return deviceRegistry[toLower(name)] -} - -// GetStreamHandler retrieves the stream handler for a registered device type. -// Returns nil if not found. Name lookup is case-insensitive. -func GetStreamHandler(name string) StreamHandlerFunc { - handler := GetRegistration(name) - if handler == nil { - return nil - } - return handler.StreamHandler() -} - -func toLower(s string) string { - // Simple ASCII lowercase for device type names - b := []byte(s) - for i := range b { - if b[i] >= 'A' && b[i] <= 'Z' { - b[i] += 'a' - 'A' - } - } - return string(b) -} +package api + +import ( + "sync" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" +) + +// DeviceHandler describes a device type, providing both device creation +// and stream handler registration. +type DeviceHandler interface { + // CreateDevice returns a new device instance of this type. + CreateDevice(o *device.CreateOptions) (usb.Device, error) + // StreamHandler returns the handler function for long-lived connections. + StreamHandler() StreamHandlerFunc + UpdateMetaState(meta string, dev *usb.Device) error +} + +var ( + deviceRegistry = make(map[string]DeviceHandler) + deviceRegistryMu sync.RWMutex +) + +// RegisterDevice registers a device type for dynamic creation and handler dispatch. +// This should be called from device package init() functions. +// The name is case-insensitive and will be lowercased. +func RegisterDevice(name string, reg DeviceHandler) { + deviceRegistryMu.Lock() + defer deviceRegistryMu.Unlock() + deviceRegistry[toLower(name)] = reg +} + +// GetRegistration retrieves a registered device handler by name for device creation. +// Returns nil if not found. Name lookup is case-insensitive. +func GetRegistration(name string) DeviceHandler { + deviceRegistryMu.RLock() + defer deviceRegistryMu.RUnlock() + return deviceRegistry[toLower(name)] +} + +// ListDeviceTypes returns a list of all registered device type names. +func ListDeviceTypes() []string { + deviceRegistryMu.RLock() + defer deviceRegistryMu.RUnlock() + types := make([]string, 0, len(deviceRegistry)) + for name := range deviceRegistry { + types = append(types, name) + } + return types +} + +// GetStreamHandler retrieves the stream handler for a registered device type. +// Returns nil if not found. Name lookup is case-insensitive. +func GetStreamHandler(name string) StreamHandlerFunc { + handler := GetRegistration(name) + if handler == nil { + return nil + } + return handler.StreamHandler() +} + +func toLower(s string) string { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 'a' - 'A' + } + } + return string(b) +} diff --git a/viiper/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go similarity index 78% rename from viiper/internal/server/api/device_registry_test.go rename to internal/server/api/device_registry_test.go index 42bde971..0a13dac6 100644 --- a/viiper/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -1,174 +1,181 @@ -package api_test - -import ( - "log/slog" - "net" - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/pkg/usb" -) - -type mockDevice struct { - name string -} - -func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { - return nil -} - -type mockRegistration struct { - deviceName string - handlerFunc api.StreamHandlerFunc -} - -func (m *mockRegistration) CreateDevice() usb.Device { - return &mockDevice{name: m.deviceName} -} - -func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { - return m.handlerFunc -} - -func TestDeviceRegistry(t *testing.T) { - tests := []struct { - name string - registerName string - lookupName string - shouldFind bool - expectedDevice string - }{ - { - name: "register and retrieve exact match", - registerName: "testdevice", - lookupName: "testdevice", - shouldFind: true, - expectedDevice: "testdevice", - }, - { - name: "case insensitive lookup", - registerName: "TestDevice", - lookupName: "testdevice", - shouldFind: true, - expectedDevice: "TestDevice", - }, - { - name: "case insensitive lookup uppercase", - registerName: "mydevice", - lookupName: "MYDEVICE", - shouldFind: true, - expectedDevice: "mydevice", - }, - { - name: "lookup non-existent device", - registerName: "device1", - lookupName: "device2", - shouldFind: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testRegName := tt.name + "_" + tt.registerName - - handlerCalled := false - mockHandler := func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error { - handlerCalled = true - return nil - } - - reg := &mockRegistration{ - deviceName: tt.expectedDevice, - handlerFunc: mockHandler, - } - - api.RegisterDevice(testRegName, reg) - - testLookupName := tt.name + "_" + tt.lookupName - retrieved := api.GetRegistration(testLookupName) - - if tt.shouldFind { - assert.NotNil(t, retrieved, "expected to find registered device") - if retrieved != nil { - dev := retrieved.CreateDevice() - mockDev, ok := dev.(*mockDevice) - assert.True(t, ok, "expected mockDevice type") - if ok { - assert.Equal(t, tt.expectedDevice, mockDev.name) - } - - var dv usb.Device = &mockDevice{name: tt.expectedDevice} - handler := retrieved.StreamHandler() - assert.NotNil(t, handler, "expected handler to be non-nil") - if handler != nil { - _ = handler(nil, &dv, slog.Default()) - assert.True(t, handlerCalled, "expected handler to be called") - } - } - } else { - assert.Nil(t, retrieved, "expected not to find device") - } - }) - } -} - -func TestGetStreamHandler(t *testing.T) { - tests := []struct { - name string - registerName string - lookupName string - shouldFind bool - }{ - { - name: "get stream handler for registered device", - registerName: "streamtest1", - lookupName: "streamtest1", - shouldFind: true, - }, - { - name: "get stream handler case insensitive", - registerName: "StreamTest2", - lookupName: "streamtest2", - shouldFind: true, - }, - { - name: "get stream handler for non-existent device", - registerName: "streamtest3", - lookupName: "nonexistent", - shouldFind: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handlerCalled := false - mockHandler := func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error { - handlerCalled = true - return nil - } - - reg := &mockRegistration{ - deviceName: tt.registerName, - handlerFunc: mockHandler, - } - - testRegName := tt.name + "_" + tt.registerName - api.RegisterDevice(testRegName, reg) - - testLookupName := tt.name + "_" + tt.lookupName - handler := api.GetStreamHandler(testLookupName) - - if tt.shouldFind { - assert.NotNil(t, handler, "expected to find stream handler") - if handler != nil { - _ = handler(nil, nil, slog.Default()) - assert.True(t, handlerCalled, "expected handler to be called") - } - } else { - assert.Nil(t, handler, "expected not to find stream handler") - } - }) - } -} +package api_test + +import ( + "context" + "log/slog" + "net" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/device" + th "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" +) + +type mockDevice struct { + name string +} + +func (m *mockDevice) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + return nil +} +func (m *mockDevice) GetDescriptor() *usb.Descriptor { + return &usb.Descriptor{} +} +func (m *mockDevice) GetDeviceSpecificArgs() map[string]any { + return map[string]any{} +} + +func TestDeviceRegistry(t *testing.T) { + + // TODO: test with OPTS + + tests := []struct { + name string + registerName string + lookupName string + shouldFind bool + expectedDevice string + }{ + { + name: "register and retrieve exact match", + registerName: "testdevice", + lookupName: "testdevice", + shouldFind: true, + expectedDevice: "testdevice", + }, + { + name: "case insensitive lookup", + registerName: "TestDevice", + lookupName: "testdevice", + shouldFind: true, + expectedDevice: "TestDevice", + }, + { + name: "case insensitive lookup uppercase", + registerName: "mydevice", + lookupName: "MYDEVICE", + shouldFind: true, + expectedDevice: "mydevice", + }, + { + name: "lookup non-existent device", + registerName: "device1", + lookupName: "device2", + shouldFind: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testRegName := tt.name + "_" + tt.registerName + + handlerCalled := false + mockHandler := func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error { + handlerCalled = true + return nil + } + + reg := th.CreateMockRegistration( + t, + tt.expectedDevice, + func(o *device.CreateOptions) (usb.Device, error) { return &mockDevice{name: tt.expectedDevice}, nil }, + mockHandler, + ) + + api.RegisterDevice(testRegName, reg) + + testLookupName := tt.name + "_" + tt.lookupName + retrieved := api.GetRegistration(testLookupName) + + if tt.shouldFind { + assert.NotNil(t, retrieved, "expected to find registered device") + if retrieved != nil { + dev, err := retrieved.CreateDevice(nil) + assert.NoError(t, err) + mockDev, ok := dev.(*mockDevice) + assert.True(t, ok, "expected mockDevice type") + if ok { + assert.Equal(t, tt.expectedDevice, mockDev.name) + } + + var dv usb.Device = &mockDevice{name: tt.expectedDevice} + handler := retrieved.StreamHandler() + assert.NotNil(t, handler, "expected handler to be non-nil") + if handler != nil { + _ = handler(nil, &dv, slog.Default()) + assert.True(t, handlerCalled, "expected handler to be called") + } + } + } else { + assert.Nil(t, retrieved, "expected not to find device") + } + }) + } +} + +func TestGetStreamHandler(t *testing.T) { + + // TODO: test with OPTS + + tests := []struct { + name string + registerName string + lookupName string + shouldFind bool + }{ + { + name: "get stream handler for registered device", + registerName: "streamtest1", + lookupName: "streamtest1", + shouldFind: true, + }, + { + name: "get stream handler case insensitive", + registerName: "StreamTest2", + lookupName: "streamtest2", + shouldFind: true, + }, + { + name: "get stream handler for non-existent device", + registerName: "streamtest3", + lookupName: "nonexistent", + shouldFind: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlerCalled := false + mockHandler := func(conn net.Conn, dev *usb.Device, logger *slog.Logger) error { + handlerCalled = true + return nil + } + + reg := th.CreateMockRegistration( + t, + tt.registerName, + func(o *device.CreateOptions) (usb.Device, error) { return &mockDevice{name: tt.registerName}, nil }, + mockHandler, + ) + + testRegName := tt.name + "_" + tt.registerName + api.RegisterDevice(testRegName, reg) + + testLookupName := tt.name + "_" + tt.lookupName + handler := api.GetStreamHandler(testLookupName) + + if tt.shouldFind { + assert.NotNil(t, handler, "expected to find stream handler") + if handler != nil { + _ = handler(nil, nil, slog.Default()) + assert.True(t, handlerCalled, "expected handler to be called") + } + } else { + assert.Nil(t, handler, "expected not to find stream handler") + } + }) + } +} diff --git a/viiper/internal/server/api/device_stream_handler.go b/internal/server/api/device_stream_handler.go similarity index 81% rename from viiper/internal/server/api/device_stream_handler.go rename to internal/server/api/device_stream_handler.go index 739f5ecd..fbb4ef88 100644 --- a/viiper/internal/server/api/device_stream_handler.go +++ b/internal/server/api/device_stream_handler.go @@ -1,53 +1,54 @@ -package api - -import ( - "fmt" - "log/slog" - "net" - "path/filepath" - "reflect" - "strings" - "viiper/internal/server/usb" - pusb "viiper/pkg/usb" -) - -// DeviceStreamHandler returns a stream handler func that dynamically dispatches -// to device-specific handlers based on device type. -func DeviceStreamHandler(srv *usb.Server) StreamHandlerFunc { - return func(conn net.Conn, dev *pusb.Device, logger *slog.Logger) error { - defer conn.Close() - - if dev == nil || *dev == nil { - return fmt.Errorf("nil device") - } - - deviceType := inferDeviceType(*dev) - reg := GetRegistration(deviceType) - if reg == nil { - return fmt.Errorf("no handler for device type: %s", deviceType) - } - handler := reg.StreamHandler() - if err := handler(conn, dev, logger); err != nil { - return err - } - return nil - } -} - -func inferDeviceType(dev any) string { - if dev == nil { - return "" - } - t := reflect.TypeOf(dev) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - pkg := t.PkgPath() // e.g., "viiper/pkg/device/xbox360" - if pkg != "" { - base := filepath.Base(pkg) - if base != "." && base != string(filepath.Separator) { - return strings.ToLower(base) - } - } - return strings.ToLower(t.Name()) -} +package api + +import ( + "fmt" + "log/slog" + "net" + "path/filepath" + "reflect" + "strings" + + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" +) + +// DeviceStreamHandler returns a stream handler func that dynamically dispatches +// to device-specific handlers based on device type. +func DeviceStreamHandler(srv *usb.Server) StreamHandlerFunc { + return func(conn net.Conn, dev *pusb.Device, logger *slog.Logger) error { + defer conn.Close() //nolint:errcheck + + if dev == nil || *dev == nil { + return fmt.Errorf("nil device") + } + + deviceType := inferDeviceType(*dev) + reg := GetRegistration(deviceType) + if reg == nil { + return fmt.Errorf("no handler for device type: %s", deviceType) + } + handler := reg.StreamHandler() + if err := handler(conn, dev, logger); err != nil { + return err + } + return nil + } +} + +func inferDeviceType(dev any) string { + if dev == nil { + return "" + } + t := reflect.TypeOf(dev) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" + if pkg != "" { + base := filepath.Base(pkg) + if base != "." && base != string(filepath.Separator) { + return strings.ToLower(base) + } + } + return strings.ToLower(t.Name()) +} diff --git a/viiper/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go similarity index 57% rename from viiper/internal/server/api/device_stream_handler_test.go rename to internal/server/api/device_stream_handler_test.go index 085bed5a..a835ea93 100644 --- a/viiper/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -1,132 +1,136 @@ -package api_test - -import ( - "fmt" - "log/slog" - "net" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "viiper/internal/log" - "viiper/internal/server/api" - srvusb "viiper/internal/server/usb" - htesting "viiper/internal/testing" - "viiper/pkg/device" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" -) - -func TestDeviceStreamHandler_Dispatch(t *testing.T) { - cfg := srvusb.ServerConfig{Addr: "127.0.0.1:0"} - srv := srvusb.New(cfg, slog.Default(), log.NewRaw(nil)) - logger := slog.Default() - - bus, err := virtualbus.NewWithBusId(90001) - require.NoError(t, err) - require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New() - devCtx, err := bus.Add(dev) - require.NoError(t, err) - - meta := device.GetDeviceMeta(devCtx) - require.NotNil(t, meta) - - var dv interface{} - metas := bus.GetAllDeviceMetas() - for _, m := range metas { - var busid string - for i, b := range m.Meta.USBBusId { - if b == 0 { - busid = string(m.Meta.USBBusId[:i]) - break - } - } - if string(meta.USBBusId[:len(busid)]) == busid { - dv = m.Dev - break - } - } - require.NotNil(t, dv) - - handlerCalled := make(chan bool, 1) - testReg := &mockRegistration{ - deviceName: "xbox360", - handlerFunc: func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { - handlerCalled <- true - return nil - }, - } - api.RegisterDevice("xbox360", testReg) - - clientConn, serverConn := net.Pipe() - defer clientConn.Close() - - handler := api.DeviceStreamHandler(srv) - dvUSB := dv.(pusb.Device) - go func() { - err := handler(serverConn, &dvUSB, logger) - require.NoError(t, err) - }() - - select { - case <-handlerCalled: - // ok - case <-time.After(1 * time.Second): - t.Fatal("handler was not called within timeout") - } -} - -func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { - addr, srv, done := htesting.StartAPIServer(t, func(r *api.Router, s *srvusb.Server, apiSrv *api.Server) { - r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s)) - }) - defer done() - - bus, err := virtualbus.NewWithBusId(70001) - require.NoError(t, err) - require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New() - devCtx, err := bus.Add(dev) - require.NoError(t, err) - meta := device.GetDeviceMeta(devCtx) - require.NotNil(t, meta) - - var deviceID string - for i, b := range meta.USBBusId { - if b == 0 { - fullId := string(meta.USBBusId[:i]) - splits := strings.Split(fullId, "-") - deviceID = splits[1] - break - } - } - require.NotEmpty(t, deviceID) - - handlerCalled := make(chan struct{}, 1) - testReg := &mockRegistration{ - deviceName: "xbox360", - handlerFunc: func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { - handlerCalled <- struct{}{} - return nil - }, - } - api.RegisterDevice("xbox360", testReg) - - c, err := net.Dial("tcp", addr) - require.NoError(t, err) - defer c.Close() - - _, err = fmt.Fprintf(c, "bus/%d/%s\n", bus.BusID(), deviceID) - require.NoError(t, err) - - select { - case <-handlerCalled: - // ok - case <-time.After(1 * time.Second): - t.Fatal("stream handler was not called within timeout") - } -} +package api_test + +import ( + "fmt" + "log/slog" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/keyboard" + htesting "github.com/Alia5/VIIPER/internal/_testing" // nolint + th "github.com/Alia5/VIIPER/internal/_testing" // nolint + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestDeviceStreamHandler_Dispatch(t *testing.T) { + cfg := srvusb.ServerConfig{Addr: "127.0.0.1:0"} + srv := srvusb.New(cfg, slog.Default(), log.NewRaw(nil)) + logger := slog.Default() + + bus, err := virtualbus.NewWithBusID(90001) + require.NoError(t, err) + require.NoError(t, srv.AddBus(bus)) + dev, err := keyboard.New(nil) + require.NoError(t, err) + devCtx, err := bus.Add(dev) + require.NoError(t, err) + + meta := device.GetDeviceMeta(devCtx) + require.NotNil(t, meta) + + var dv interface{} + metas := bus.GetAllDeviceMetas() + for _, m := range metas { + var busid string + for i, b := range m.Meta.USBBusID { + if b == 0 { + busid = string(m.Meta.USBBusID[:i]) + break + } + } + if string(meta.USBBusID[:len(busid)]) == busid { + dv = m.Dev + break + } + } + require.NotNil(t, dv) + + handlerCalled := make(chan bool, 1) + testReg := th.CreateMockRegistration(t, "keyboard", + func(o *device.CreateOptions) (pusb.Device, error) { return keyboard.New(o) }, + func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { + handlerCalled <- true + return nil + }, + ) + + api.RegisterDevice("keyboard", testReg) + + clientConn, serverConn := net.Pipe() + defer clientConn.Close() //nolint:errcheck + + handler := api.DeviceStreamHandler(srv) + dvUSB := dv.(pusb.Device) + go func() { + err := handler(serverConn, &dvUSB, logger) + require.NoError(t, err) + }() + + select { + case <-handlerCalled: + // ok + case <-time.After(1 * time.Second): + t.Fatal("handler was not called within timeout") + } +} + +func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { + addr, srv, done := htesting.StartAPIServer(t, func(r *api.Router, s *srvusb.Server, apiSrv *api.Server) { + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s)) + }) + defer done() + + bus, err := virtualbus.NewWithBusID(70001) + require.NoError(t, err) + require.NoError(t, srv.AddBus(bus)) + dev, err := keyboard.New(nil) + require.NoError(t, err) + devCtx, err := bus.Add(dev) + require.NoError(t, err) + meta := device.GetDeviceMeta(devCtx) + require.NotNil(t, meta) + + var deviceID string + for i, b := range meta.USBBusID { + if b == 0 { + fullId := string(meta.USBBusID[:i]) + splits := strings.Split(fullId, "-") + deviceID = splits[1] + break + } + } + require.NotEmpty(t, deviceID) + + handlerCalled := make(chan struct{}, 1) + testReg := th.CreateMockRegistration(t, "keyboard", + func(o *device.CreateOptions) (pusb.Device, error) { return keyboard.New(o) }, + func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { + handlerCalled <- struct{}{} + return nil + }, + ) + api.RegisterDevice("keyboard", testReg) + + c, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer c.Close() //nolint:errcheck + + _, err = fmt.Fprintf(c, "bus/%d/%s\x00", bus.BusID(), deviceID) + require.NoError(t, err) + + select { + case <-handlerCalled: + // ok + case <-time.After(1 * time.Second): + t.Fatal("stream handler was not called within timeout") + } +} diff --git a/internal/server/api/device_stream_ownership.go b/internal/server/api/device_stream_ownership.go new file mode 100644 index 00000000..7094c1a1 --- /dev/null +++ b/internal/server/api/device_stream_ownership.go @@ -0,0 +1,263 @@ +package api + +import ( + "context" + "net" + "sync" + "time" +) + +// deviceStreamKey identifies the lifetime of one virtual device. Bus and +// device identifiers can eventually be reused, so the monotonically increasing +// generation in deviceStreamOwnership remains authoritative across reconnects. +type deviceStreamKey struct { + busID uint32 + devID string +} + +// deviceStreamCoordinator gives each virtual device exactly one current API +// stream. A replacement that overlaps the old transport claims ownership first, +// closes that transport, and waits for its handler to finish. A replacement +// opened just after the old transport closes cancels its deferred finalization +// during the reconnect grace instead. +// +// Besides preventing two clients from concurrently mutating one device, this +// ordering is important for audio devices: an old handler must not clear the +// callback or reset the microphone buffer after its replacement has started. +type deviceStreamCoordinator struct { + mu sync.Mutex + streams map[deviceStreamKey]*deviceStreamOwnership +} + +type deviceStreamOwnership struct { + generation uint64 + active bool + conn net.Conn + done chan struct{} + finalizeTimer *time.Timer + cleanupTimer *time.Timer + finalized bool +} + +type deviceStreamLease struct { + coordinator *deviceStreamCoordinator + key deviceStreamKey + generation uint64 + done chan struct{} + previousDone <-chan struct{} + finishOnce sync.Once +} + +func (c *deviceStreamCoordinator) claim(key deviceStreamKey, + conn net.Conn) *deviceStreamLease { + c.mu.Lock() + if c.streams == nil { + c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) + } + state := c.streams[key] + if state == nil { + state = &deviceStreamOwnership{} + c.streams[key] = state + } + + if state.cleanupTimer != nil { + state.cleanupTimer.Stop() + state.cleanupTimer = nil + } + if state.finalizeTimer != nil { + state.finalizeTimer.Stop() + state.finalizeTimer = nil + } + + previousConn := state.conn + previousDone := state.done + state.generation++ + done := make(chan struct{}) + state.active = true + state.conn = conn + state.done = done + state.finalized = false + lease := &deviceStreamLease{ + coordinator: c, + key: key, generation: state.generation, + done: done, previousDone: previousDone, + } + c.mu.Unlock() + + // Closing the displaced connection unblocks every built-in handler's read + // loop. Do this outside the coordinator lock because Close can enter an + // authentication wrapper or operating-system transport. + if previousConn != nil && previousDone != nil { + _ = previousConn.Close() + } + + return lease +} + +// waitForTurn waits until the displaced handler has returned. It reports false +// when an even newer stream superseded this lease while it was waiting. +func (l *deviceStreamLease) waitForTurn(ctx context.Context) bool { + if l.previousDone != nil { + select { + case <-l.previousDone: + case <-ctx.Done(): + return false + } + } + + c := l.coordinator + c.mu.Lock() + defer c.mu.Unlock() + state := c.streams[l.key] + return state != nil && state.active && + state.generation == l.generation && state.done == l.done +} + +// finish closes this lease's completion signal exactly once. Only the current +// generation is allowed to finalize stream-owned device state and arm device +// cleanup; a superseded handler merely releases the next waiter. +// +// finalizeCurrent is deferred for reconnectGrace so the common close-old then +// open-new reconnect ordering retains buffered microphone audio. A same-device +// claim cancels both timers and advances the generation, so even a timer that +// has already fired but is waiting on the lock cannot finalize replacement +// state. Cleanup forces any still-pending finalization before device removal. +// Both callbacks run while the coordinator lock is held and must not call back +// into this coordinator. +func (l *deviceStreamLease) finish(reconnectGrace, cleanupDelay time.Duration, + deviceContext context.Context, finalizeCurrent, cleanup func()) { + l.finishOnce.Do(func() { + c := l.coordinator + c.mu.Lock() + state := c.streams[l.key] + current := state != nil && state.active && + state.generation == l.generation && state.done == l.done + if !current { + close(l.done) + c.mu.Unlock() + return + } + + state.active = false + state.conn = nil + state.done = nil + generation := state.generation + close(l.done) + state.finalizeTimer = time.AfterFunc(reconnectGrace, func() { + c.mu.Lock() + defer c.mu.Unlock() + currentState := c.streams[l.key] + if currentState == nil || currentState.active || + currentState.generation != generation || currentState.finalized { + return + } + currentState.finalizeTimer = nil + currentState.finalized = true + if deviceContext != nil { + select { + case <-deviceContext.Done(): + return + default: + } + } + if finalizeCurrent != nil { + finalizeCurrent() + } + }) + state.cleanupTimer = time.AfterFunc(cleanupDelay, func() { + c.mu.Lock() + defer c.mu.Unlock() + currentState := c.streams[l.key] + if currentState == nil || currentState.active || + currentState.generation != generation { + return + } + currentState.cleanupTimer = nil + if !currentState.finalized { + if currentState.finalizeTimer != nil { + currentState.finalizeTimer.Stop() + currentState.finalizeTimer = nil + } + currentState.finalized = true + if finalizeCurrent != nil { + finalizeCurrent() + } + } + if deviceContext != nil { + select { + case <-deviceContext.Done(): + return + default: + } + } + if cleanup != nil { + cleanup() + } + }) + c.mu.Unlock() + }) +} + +// abandon releases waiters without scheduling cleanup. It is used by a stream +// that was superseded before its device handler began, or whose device context +// was already removed. +func (l *deviceStreamLease) abandon() { + l.finishOnce.Do(func() { + c := l.coordinator + c.mu.Lock() + state := c.streams[l.key] + current := state != nil && state.active && + state.generation == l.generation && state.done == l.done + close(l.done) + if current { + state.active = false + state.conn = nil + state.done = nil + } + c.mu.Unlock() + }) +} + +// scheduleCleanup schedules the initial no-client cleanup using the same +// generation gate as reconnect cleanup. A stream claim cancels this timer. +func (c *deviceStreamCoordinator) scheduleCleanup(key deviceStreamKey, + delay time.Duration, deviceContext context.Context, cleanup func()) { + c.mu.Lock() + if c.streams == nil { + c.streams = make(map[deviceStreamKey]*deviceStreamOwnership) + } + state := c.streams[key] + if state == nil { + state = &deviceStreamOwnership{} + c.streams[key] = state + } + if state.active { + c.mu.Unlock() + return + } + if state.cleanupTimer != nil { + state.cleanupTimer.Stop() + } + generation := state.generation + state.cleanupTimer = time.AfterFunc(delay, func() { + c.mu.Lock() + defer c.mu.Unlock() + current := c.streams[key] + if current == nil || current.active || + current.generation != generation { + return + } + current.cleanupTimer = nil + if deviceContext != nil { + select { + case <-deviceContext.Done(): + return + default: + } + } + if cleanup != nil { + cleanup() + } + }) + c.mu.Unlock() +} diff --git a/internal/server/api/device_stream_ownership_test.go b/internal/server/api/device_stream_ownership_test.go new file mode 100644 index 00000000..2c2bc382 --- /dev/null +++ b/internal/server/api/device_stream_ownership_test.go @@ -0,0 +1,316 @@ +package api + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDeviceStreamReplacementWaitsForDisplacedHandlerCleanup(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 17, devID: "4"} + firstServer, firstClient := net.Pipe() + defer firstClient.Close() //nolint:errcheck + first := coordinator.claim(key, firstServer) + require.True(t, first.waitForTurn(context.Background())) + + secondServer, secondClient := net.Pipe() + defer secondClient.Close() //nolint:errcheck + second := coordinator.claim(key, secondServer) + + // Claiming the replacement closes the displaced transport immediately. + readDone := make(chan error, 1) + go func() { + var one [1]byte + _, err := firstClient.Read(one[:]) + readDone <- err + }() + select { + case err := <-readDone: + require.Error(t, err) + case <-time.After(time.Second): + t.Fatal("displaced stream transport was not closed") + } + + secondTurn := make(chan bool, 1) + go func() { + secondTurn <- second.waitForTurn(context.Background()) + }() + select { + case <-secondTurn: + t.Fatal("replacement entered before displaced handler cleanup completed") + case <-time.After(25 * time.Millisecond): + } + + var staleFinalize atomic.Int32 + var staleCleanup atomic.Int32 + first.finish(time.Millisecond, time.Hour, context.Background(), func() { + staleFinalize.Add(1) + }, func() { staleCleanup.Add(1) }) + require.True(t, <-secondTurn) + + // The superseded generation must neither finalize shared stream state nor + // arm its cleanup callback. + time.Sleep(20 * time.Millisecond) + require.Zero(t, staleFinalize.Load()) + require.Zero(t, staleCleanup.Load()) + second.abandon() +} + +func TestDeviceStreamCurrentGenerationFinalizesBeforeLaterClaim(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 21, devID: "3"} + firstServer, firstClient := net.Pipe() + defer firstServer.Close() //nolint:errcheck + defer firstClient.Close() //nolint:errcheck + first := coordinator.claim(key, firstServer) + require.True(t, first.waitForTurn(context.Background())) + + finalizeStarted := make(chan struct{}) + allowFinalize := make(chan struct{}) + var finalizeCalls atomic.Int32 + first.finish(0, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + close(finalizeStarted) + <-allowFinalize + }, nil) + <-finalizeStarted + + secondServer, secondClient := net.Pipe() + defer secondServer.Close() //nolint:errcheck + defer secondClient.Close() //nolint:errcheck + claimed := make(chan *deviceStreamLease, 1) + go func() { claimed <- coordinator.claim(key, secondServer) }() + + select { + case <-claimed: + t.Fatal("later generation claimed device before current finalization completed") + case <-time.After(25 * time.Millisecond): + } + + close(allowFinalize) + second := <-claimed + require.Equal(t, int32(1), finalizeCalls.Load()) + require.True(t, second.waitForTurn(context.Background())) + + // finish is idempotent even if multiple teardown paths converge on it. + first.finish(0, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + }, nil) + require.Equal(t, int32(1), finalizeCalls.Load()) + second.abandon() +} + +func TestDeviceStreamRapidReplacementOnlyFinalizesNewestGeneration(t *testing.T) { + const generationCount = 64 + + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 22, devID: "9"} + leases := make([]*deviceStreamLease, 0, generationCount) + clients := make([]net.Conn, 0, generationCount) + + for generation := 0; generation < generationCount; generation++ { + server, client := net.Pipe() + clients = append(clients, client) + leases = append(leases, coordinator.claim(key, server)) + } + defer func() { + for _, client := range clients { + _ = client.Close() + } + }() + + var finalizeCalls atomic.Int32 + for generation := 0; generation < generationCount-1; generation++ { + leases[generation].finish(time.Millisecond, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + }, nil) + } + require.Zero(t, finalizeCalls.Load(), + "a displaced generation finalized shared stream state") + + latest := leases[generationCount-1] + require.True(t, latest.waitForTurn(context.Background())) + latest.finish(0, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + }, nil) + require.Eventually(t, func() bool { + return finalizeCalls.Load() == 1 + }, time.Second, time.Millisecond) +} + +func TestDeviceStreamNewestWaitingGenerationWins(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 18, devID: "1"} + firstServer, firstClient := net.Pipe() + defer firstClient.Close() //nolint:errcheck + first := coordinator.claim(key, firstServer) + require.True(t, first.waitForTurn(context.Background())) + + secondServer, secondClient := net.Pipe() + defer secondClient.Close() //nolint:errcheck + second := coordinator.claim(key, secondServer) + thirdServer, thirdClient := net.Pipe() + defer thirdClient.Close() //nolint:errcheck + third := coordinator.claim(key, thirdServer) + + secondTurn := make(chan bool, 1) + go func() { secondTurn <- second.waitForTurn(context.Background()) }() + thirdTurn := make(chan bool, 1) + go func() { thirdTurn <- third.waitForTurn(context.Background()) }() + + first.abandon() + require.False(t, <-secondTurn) + second.abandon() + require.True(t, <-thirdTurn) + third.abandon() +} + +func TestDeviceStreamReconnectCancelsPendingCleanup(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 19, devID: "2"} + firstServer, firstClient := net.Pipe() + defer firstClient.Close() //nolint:errcheck + first := coordinator.claim(key, firstServer) + require.True(t, first.waitForTurn(context.Background())) + + var cleanupCalls atomic.Int32 + first.finish(time.Hour, 40*time.Millisecond, context.Background(), nil, func() { + cleanupCalls.Add(1) + }) + + secondServer, secondClient := net.Pipe() + defer secondClient.Close() //nolint:errcheck + second := coordinator.claim(key, secondServer) + require.True(t, second.waitForTurn(context.Background())) + time.Sleep(75 * time.Millisecond) + require.Zero(t, cleanupCalls.Load()) + second.abandon() +} + +func TestInitialCleanupCannotRemoveActivelyClaimedDevice(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 20, devID: "7"} + var cleanupCalls atomic.Int32 + coordinator.scheduleCleanup(key, 40*time.Millisecond, + context.Background(), func() { cleanupCalls.Add(1) }) + + server, client := net.Pipe() + defer client.Close() //nolint:errcheck + lease := coordinator.claim(key, server) + require.True(t, lease.waitForTurn(context.Background())) + time.Sleep(75 * time.Millisecond) + require.Zero(t, cleanupCalls.Load()) + lease.abandon() +} + +func TestDeviceStreamCloseFirstReconnectCancelsPendingFinalization(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 23, devID: "5"} + firstServer, firstClient := net.Pipe() + defer firstServer.Close() //nolint:errcheck + defer firstClient.Close() //nolint:errcheck + first := coordinator.claim(key, firstServer) + require.True(t, first.waitForTurn(context.Background())) + + var finalizeCalls atomic.Int32 + first.finish(75*time.Millisecond, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + }, nil) + + // This is the natural reconnect order: the old stream has completely + // finished before the replacement claims the same virtual device. + time.Sleep(10 * time.Millisecond) + secondServer, secondClient := net.Pipe() + defer secondServer.Close() //nolint:errcheck + defer secondClient.Close() //nolint:errcheck + second := coordinator.claim(key, secondServer) + require.True(t, second.waitForTurn(context.Background())) + + time.Sleep(100 * time.Millisecond) + require.Zero(t, finalizeCalls.Load(), + "old close-first timer finalized replacement-owned state") + second.abandon() +} + +func TestDeviceStreamNoReplacementFinalizesAfterReconnectGrace(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 24, devID: "6"} + server, client := net.Pipe() + defer server.Close() //nolint:errcheck + defer client.Close() //nolint:errcheck + lease := coordinator.claim(key, server) + require.True(t, lease.waitForTurn(context.Background())) + + var finalizeCalls atomic.Int32 + lease.finish(30*time.Millisecond, time.Hour, context.Background(), func() { + finalizeCalls.Add(1) + }, nil) + require.Zero(t, finalizeCalls.Load(), "finalized before reconnect grace") + require.Eventually(t, func() bool { + return finalizeCalls.Load() == 1 + }, time.Second, time.Millisecond) +} + +func TestDeviceStreamCleanupForcesPendingFinalizationExactlyOnce(t *testing.T) { + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 25, devID: "8"} + server, client := net.Pipe() + defer server.Close() //nolint:errcheck + defer client.Close() //nolint:errcheck + lease := coordinator.claim(key, server) + require.True(t, lease.waitForTurn(context.Background())) + + var finalizeCalls atomic.Int32 + var cleanupCalls atomic.Int32 + var cleanupBeforeFinalize atomic.Bool + lease.finish(time.Hour, 25*time.Millisecond, context.Background(), func() { + finalizeCalls.Add(1) + }, func() { + if finalizeCalls.Load() != 1 { + cleanupBeforeFinalize.Store(true) + } + cleanupCalls.Add(1) + }) + require.Eventually(t, func() bool { + return cleanupCalls.Load() == 1 + }, time.Second, time.Millisecond) + require.False(t, cleanupBeforeFinalize.Load(), + "cleanup ran before pending stream finalization") + require.Equal(t, int32(1), finalizeCalls.Load()) + time.Sleep(50 * time.Millisecond) + require.Equal(t, int32(1), finalizeCalls.Load()) +} + +func TestDeviceStreamCloseFirstReconnectStressRejectsStaleTimers(t *testing.T) { + const generations = 64 + + var coordinator deviceStreamCoordinator + key := deviceStreamKey{busID: 26, devID: "10"} + server, client := net.Pipe() + defer client.Close() //nolint:errcheck + lease := coordinator.claim(key, server) + require.True(t, lease.waitForTurn(context.Background())) + + var staleFinalizations atomic.Int32 + for generation := 0; generation < generations; generation++ { + lease.finish(75*time.Millisecond, time.Hour, context.Background(), func() { + staleFinalizations.Add(1) + }, nil) + require.NoError(t, server.Close()) + + server, client = net.Pipe() + defer client.Close() //nolint:errcheck + lease = coordinator.claim(key, server) + require.True(t, lease.waitForTurn(context.Background())) + } + + time.Sleep(100 * time.Millisecond) + require.Zero(t, staleFinalizations.Load()) + lease.abandon() + require.NoError(t, server.Close()) +} diff --git a/internal/server/api/error/apierror.go b/internal/server/api/error/apierror.go new file mode 100644 index 00000000..ff108fde --- /dev/null +++ b/internal/server/api/error/apierror.go @@ -0,0 +1,31 @@ +package apierror + +import "github.com/Alia5/VIIPER/viipertypes" + +func ErrBadRequest(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 400, Title: "Bad Request", Detail: detail} +} +func ErrNotFound(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 404, Title: "Not Found", Detail: detail} +} +func ErrConflict(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 409, Title: "Conflict", Detail: detail} +} +func ErrInternal(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 500, Title: "Internal Server Error", Detail: detail} +} +func ErrUnauthorized(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 401, Title: "Unauthorized", Detail: detail} +} + +// WrapError normalizes any error into viipertypes.ApiError. +func WrapError(err error) viipertypes.APIError { + if ae, ok := err.(*viipertypes.APIError); ok { + return *ae + } + if ae, ok := err.(viipertypes.APIError); ok { + return ae + } + // Default wrap as internal error + return ErrInternal(err.Error()) +} diff --git a/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go new file mode 100644 index 00000000..5a3f8efc --- /dev/null +++ b/internal/server/api/handler/bus_create.go @@ -0,0 +1,56 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" + "github.com/Alia5/VIIPER/virtualbus" +) + +// BusCreate returns a handler that creates a new bus. +func BusCreate(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + if req.Payload != "" { + busID, err := strconv.ParseUint(req.Payload, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + + if busID == 0 { + busID = uint64(s.NextFreeBusID()) + } + + b, err := virtualbus.NewWithBusID(uint32(busID)) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + if err := s.AddBus(b); err != nil { + return apierror.ErrConflict(fmt.Sprintf("bus %d already exists", busID)) + } + out, err := json.Marshal(viipertypes.BusCreateResponse{BusID: b.BusID()}) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } + + busID := s.NextFreeBusID() + b := virtualbus.New(busID) + if err := s.AddBus(b); err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to add bus: %v", err)) + } + out, err := json.Marshal(viipertypes.BusCreateResponse{BusID: b.BusID()}) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } +} diff --git a/viiper/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go similarity index 60% rename from viiper/internal/server/api/handler/bus_create_test.go rename to internal/server/api/handler/bus_create_test.go index 06efec08..bcfd4a15 100644 --- a/viiper/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -1,89 +1,99 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/virtualbus" -) - -func TestBusCreate(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - payload any - expectedResponse string - }{ - { - name: "valid create", - setup: nil, - payload: "60001", - expectedResponse: `{"busId":60001}`, - }, - { - name: "duplicate bus", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60002) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - payload: "60002", - expectedResponse: `{"error":"bus number 60002 already allocated"}`, - }, - { - name: "create after remove allows reuse", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60003) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if err := s.RemoveBus(60003); err != nil { - t.Fatalf("remove bus failed: %v", err) - } - }, - payload: "60003", - expectedResponse: `{"busId":60003}`, - }, - { - name: "invalid bus number", - setup: nil, - payload: "foo", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"foo\": invalid syntax"}`, - }, - { - name: "negative bus number", - setup: nil, - payload: "-1", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"-1\": invalid syntax"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/create", handler.BusCreate(s)) - }) - defer done() - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/create", tt.payload, nil) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusCreate(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server) + payload any + expectedResponse string + }{ + { + name: "valid create", + setup: nil, + payload: "60001", + expectedResponse: `{"busId":60001}`, + }, + { + name: "duplicate bus", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60002) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + payload: "60002", + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: bus number 60002 already allocated"}`, + }, + { + name: "create after remove allows reuse", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60003) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + if err := s.RemoveBus(60003); err != nil { + t.Fatalf("remove bus failed: %v", err) + } + }, + payload: "60003", + expectedResponse: `{"busId":60003}`, + }, + { + name: "invalid bus number", + setup: nil, + payload: "foo", + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"foo\": invalid syntax"}`, + }, + { + name: "0 bus number chooses next free", + setup: nil, + payload: "0", + expectedResponse: `{"busId":1}`, + }, + { + name: "negative bus number", + setup: nil, + payload: "-1", + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"-1\": invalid syntax"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/create", handler.BusCreate(s)) + }) + defer done() + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv) + } + line, err := c.Do("bus/create", tt.payload, nil) + assert.NoError(t, err) + if tt.expectedResponse[0] == '{' { + assert.JSONEq(t, tt.expectedResponse, line) + } else { + assert.Equal(t, tt.expectedResponse, line) + } + }) + } +} diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go new file mode 100644 index 00000000..c22487fe --- /dev/null +++ b/internal/server/api/handler/bus_device_add.go @@ -0,0 +1,111 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + usbs "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusDeviceAdd returns a handler to add devices to a bus. +func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + idStr, ok := req.Params["id"] + if !ok { + return apierror.ErrBadRequest("missing id parameter") + } + busID, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + b := s.GetBus(uint32(busID)) + if b == nil { + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + } + if req.Payload == "" { + return apierror.ErrBadRequest("missing payload") + } + var deviceCreateReq viipertypes.DeviceCreateRequest + err = json.Unmarshal([]byte(req.Payload), &deviceCreateReq) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + } + if deviceCreateReq.Type == nil { + return apierror.ErrBadRequest("missing device type") + } + + name := strings.ToLower(*deviceCreateReq.Type) + + reg := api.GetRegistration(name) + if reg == nil { + return apierror.ErrBadRequest(fmt.Sprintf("unknown device type: %s", name)) + } + + opts := device.CreateOptions{ + IDVendor: deviceCreateReq.IDVendor, + IDProduct: deviceCreateReq.IDProduct, + } + if deviceCreateReq.DeviceSpecific != nil { + b, err := json.Marshal(deviceCreateReq.DeviceSpecific) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid deviceSpecific JSON: %v", err)) + } + opts.DeviceSpecific = string(b) + } + + dev, err := reg.CreateDevice(&opts) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("failed to create device: %v", err)) + } + devCtx, err := b.Add(dev) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) + } + + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return apierror.ErrInternal("failed to get device metadata from context") + } + + apiSrv.ScheduleDeviceCleanup(uint32(busID), + fmt.Sprintf("%d", exportMeta.DevID), devCtx) + + if apiSrv.Config().AutoAttachLocalClient { + err := api.AttachLocalhostClient( + req.Ctx, + exportMeta, + s.GetListenPort(), + apiSrv.Config().AutoAttachWindowsNative, + logger, + ) + if err != nil { + logger.Error("failed to auto-attach localhost client", "error", err) + return apierror.ErrConflict(fmt.Sprintf( + "Failed to auto-attach device: %v", err, + )) + } + } + + payload, err := json.Marshal(viipertypes.Device{ + BusID: uint32(busID), + DevID: fmt.Sprintf("%d", exportMeta.DevID), + Vid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDVendor), + Pid: fmt.Sprintf("0x%04x", dev.GetDescriptor().Device.IDProduct), + Type: name, + DeviceSpecific: dev.GetDeviceSpecificArgs(), + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + + res.JSON = string(payload) + return nil + } +} diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go new file mode 100644 index 00000000..b0393b1b --- /dev/null +++ b/internal/server/api/handler/bus_device_add_test.go @@ -0,0 +1,250 @@ +package handler_test + +import ( + "encoding/json" + "log/slog" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + th "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusDeviceAdd(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server, as *api.Server) + pathParams map[string]string + payload any + expectedResponse string + extraChecks func(t *testing.T, response string, srv *usb.Server, apiSrv *api.Server) + }{ + { + name: "add device to existing bus", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(80001) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "80001"}, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + }, + { + name: "add device to existing bus with device specific args", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(80001) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "80001"}, + payload: `{"type": "xbox360", "deviceSpecific":{"subType": 7}}`, + expectedResponse: `{"busId":80001, "devId": "1", "deviceSpecific": {"subType": 7}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + }, + { + name: "invalid device specific args", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(80001) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "80001"}, + payload: `{"type": "xbox360", "deviceSpecific":{"subType": "a"}}`, + expectedResponse: `{"detail":"failed to create device: invalid JSON payload: json: cannot unmarshal string into Go struct field Xbox360CreateOptions.subType of type uint8", "status":400, "title":"Bad Request"}`, + }, + { + name: "add device to non-existing bus", + setup: nil, + pathParams: map[string]string{"id": "99999"}, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 99999 not found"}`, + }, + { + name: "invalid bus number", + setup: nil, + pathParams: map[string]string{"id": "baz"}, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"baz\": invalid syntax"}`, + }, + { + name: "invalid json", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(2) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "2"}, + payload: `xbox360`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid JSON payload: invalid character 'x' looking for beginning of value"}`, + }, + { + name: "invalid payload", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(3) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "3"}, + payload: `{"tpe": "xbox360"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"missing device type"}`, + }, + { + name: "correct device id after add/remove", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + b, err := virtualbus.NewWithBusID(80005) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + dev, err := xbox360.New(nil) + if err != nil { + t.Fatalf("create device failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device failed: %v", err) + } + if err := b.RemoveDeviceByID("1"); err != nil { + t.Fatalf("remove device failed: %v", err) + } + }, + pathParams: map[string]string{"id": "80005"}, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + }, + { + name: "autoattach fails returns error", + setup: func(t *testing.T, s *usb.Server, as *api.Server) { + as.Config().AutoAttachLocalClient = true + b, err := virtualbus.NewWithBusID(80250) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "80250"}, + payload: `{"type": "xbox360"}`, + extraChecks: func(t *testing.T, response string, srv *usb.Server, apiSrv *api.Server) { + var errResp map[string]interface{} + err := json.Unmarshal([]byte(response), &errResp) + require.NoError(t, err) + require.Equal(t, float64(409), errResp["status"]) + require.Equal(t, "Conflict", errResp["title"]) + detail := errResp["detail"].(string) + require.Contains(t, detail, "Failed to auto-attach device:") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var as *api.Server + addr, srv, done := th.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/create", handler.BusCreate(s)) + r.Register("bus/{id}/add", handler.BusDeviceAdd(s, apiSrv)) + as = apiSrv + }) + defer done() + + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv, as) + } + line, err := c.Do("bus/{id}/add", tt.payload, tt.pathParams) + assert.NoError(t, err) + + if tt.expectedResponse != "" { + assert.JSONEq(t, tt.expectedResponse, line) + } + + if tt.extraChecks != nil { + tt.extraChecks(t, line, srv, as) + } + }) + } +} + +// Verify that a device added via API is auto-removed if no stream connects within the configured timeout. +func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { + usbSrv := usb.New(usb.ServerConfig{ + Addr: "127.0.0.1:0", + ConnectionTimeout: time.Millisecond * 500, + BusCleanupTimeout: time.Millisecond * 500, + }, slog.Default(), log.NewRaw(nil)) + + b, err := virtualbus.NewWithBusID(80100) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(b)) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + _ = ln.Close() + + apiCfg := api.ServerConfig{Addr: addr, DeviceHandlerConnectTimeout: 500 * time.Millisecond} + apiSrv := api.New(usbSrv, addr, apiCfg, slog.Default()) + r := apiSrv.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) + r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) + require.NoError(t, apiSrv.Start()) + defer apiSrv.Close() //nolint:errcheck + + testReg := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, + func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { return nil }, + ) + + api.RegisterDevice("xbox360", testReg) + + c := viiperclient.New(addr) + _, err = c.DeviceAdd(80100, "xbox360", nil) + require.NoError(t, err) + + list, err := c.DevicesList(80100) + require.NoError(t, err) + require.Len(t, list.Devices, 1) + + require.Eventually(t, func() bool { + list, _ := c.DevicesList(80100) + return list != nil && len(list.Devices) == 0 + }, 3*time.Second, 50*time.Millisecond) + + require.Eventually(t, func() bool { + return len(usbSrv.ListBuses()) == 0 + }, 3*time.Second, 50*time.Millisecond) +} diff --git a/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go new file mode 100644 index 00000000..d4ab4c3d --- /dev/null +++ b/internal/server/api/handler/bus_device_remove.go @@ -0,0 +1,46 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusDeviceRemove returns a handler that removes a device by device number. +func BusDeviceRemove(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + idStr, ok := req.Params["id"] + if !ok { + return apierror.ErrBadRequest("missing id parameter") + } + busID, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + if req.Payload == "" { + return apierror.ErrBadRequest("missing device number") + } + deviceID := req.Payload + + b := s.GetBus(uint32(busID)) + if b == nil { + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + } + if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { + return apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) + } + + j, err := json.Marshal(viipertypes.DeviceRemoveResponse{BusID: uint32(busID), DevID: deviceID}) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(j) + return nil + } +} diff --git a/viiper/internal/server/api/handler/bus_device_remove_test.go b/internal/server/api/handler/bus_device_remove_test.go similarity index 62% rename from viiper/internal/server/api/handler/bus_device_remove_test.go rename to internal/server/api/handler/bus_device_remove_test.go index 286e71d7..9377eb04 100644 --- a/viiper/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -1,90 +1,98 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" - "viiper/pkg/virtualbus" -) - -func TestBusDeviceRemove(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - pathParams map[string]string - payload any - expectedResponse string - }{ - { - name: "remove existing device", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(90001) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device failed: %v", err) - } - }, - pathParams: map[string]string{"id": "90001"}, - payload: "1", - expectedResponse: `{"busId":90001,"devId":"1"}`, - }, - { - name: "remove from non-existing bus", - setup: nil, - pathParams: map[string]string{"id": "90001"}, - payload: "1", - expectedResponse: `{"error":"bus 90001 not found"}`, - }, - { - name: "remove non-existing device", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(90002) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - pathParams: map[string]string{"id": "90002"}, - payload: "1", - expectedResponse: `{"error":"device with id 1 not found on bus 90002"}`, - }, - { - name: "invalid bus number", - setup: nil, - pathParams: map[string]string{"id": "abc"}, - payload: "1", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"abc\": invalid syntax"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/{id}/remove", handler.BusDeviceRemove(s)) - }) - defer done() - - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/{id}/remove", tt.payload, tt.pathParams) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/device/xbox360" + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusDeviceRemove(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server) + pathParams map[string]string + payload any + expectedResponse string + }{ + { + name: "remove existing device", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(90001) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + dev, err := xbox360.New(nil) + if err != nil { + t.Fatalf("create device failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device failed: %v", err) + } + }, + pathParams: map[string]string{"id": "90001"}, + payload: "1", + expectedResponse: `{"busId":90001,"devId":"1"}`, + }, + { + name: "remove from non-existing bus", + setup: nil, + pathParams: map[string]string{"id": "90001"}, + payload: "1", + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 90001 not found"}`, + }, + { + name: "remove non-existing device", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(90002) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "90002"}, + payload: "1", + expectedResponse: `{"status":404,"title":"Not Found","detail":"device 1 not found on bus 90002"}`, + }, + { + name: "invalid bus number", + setup: nil, + pathParams: map[string]string{"id": "abc"}, + payload: "1", + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"abc\": invalid syntax"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/{id}/remove", handler.BusDeviceRemove(s)) + }) + defer done() + + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv) + } + line, err := c.Do("bus/{id}/remove", tt.payload, tt.pathParams) + assert.NoError(t, err) + if tt.expectedResponse[0] == '{' { + assert.JSONEq(t, tt.expectedResponse, line) + } else { + assert.Equal(t, tt.expectedResponse, line) + } + }) + } +} diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go new file mode 100644 index 00000000..8a495411 --- /dev/null +++ b/internal/server/api/handler/bus_devices_list.go @@ -0,0 +1,74 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusDevicesList returns a handler that lists devices on a bus. +func BusDevicesList(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + idStr, ok := req.Params["id"] + if !ok { + return apierror.ErrBadRequest("missing id parameter") + } + busID, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + b := s.GetBus(uint32(busID)) + if b == nil { + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + } + metas := b.GetAllDeviceMetas() + out := make([]viipertypes.Device, 0, len(metas)) + for _, m := range metas { + dtype := inferDeviceType(m.Dev) + out = append(out, viipertypes.Device{ + BusID: m.Meta.BusID, + DevID: fmt.Sprintf("%d", m.Meta.DevID), + Vid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDVendor), + Pid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDProduct), + Type: dtype, + DeviceSpecific: m.Dev.GetDeviceSpecificArgs(), + }) + } + payload, err := json.Marshal(viipertypes.DevicesListResponse{Devices: out}) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(payload) + return nil + } +} + +// inferDeviceType attempts to derive a friendly device type name from the concrete type. +// For devices under /devices/, we return the last path element (e.g., "xbox360"). +// Fallback to the lowercased concrete type name if the package path is unavailable. +func inferDeviceType(dev any) string { + if dev == nil { + return "" + } + t := reflect.TypeOf(dev) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" + if pkg != "" { + base := filepath.Base(pkg) + if base != "." && base != string(filepath.Separator) { + return strings.ToLower(base) + } + } + return strings.ToLower(t.Name()) +} diff --git a/viiper/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go similarity index 55% rename from viiper/internal/server/api/handler/bus_devices_list_test.go rename to internal/server/api/handler/bus_devices_list_test.go index c7e3e0b4..fe9e80ba 100644 --- a/viiper/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -1,105 +1,121 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" - "viiper/pkg/virtualbus" -) - -func TestBusDevicesList(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - pathParams map[string]string - expectedResponse string - }{ - { - name: "list devices on existing bus", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60008) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - pathParams: map[string]string{"id": "60008"}, - expectedResponse: `{"devices":[]}`, - }, - { - name: "list devices after adding one", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60009) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device failed: %v", err) - } - }, - pathParams: map[string]string{"id": "60009"}, - expectedResponse: `{"devices":[{"busId":60009,"devId":"1","vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, - }, - { - name: "list devices with multiple additions", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60010) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device 1 failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device 2 failed: %v", err) - } - }, - pathParams: map[string]string{"id": "60010"}, - expectedResponse: `{"devices":[{"busId":60010,"devId":"1","vid":"0x045e","pid":"0x028e","type":"xbox360"},{"busId":60010,"devId":"2","vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, - }, - { - name: "list devices on non-existing bus", - setup: nil, - pathParams: map[string]string{"id": "99999"}, - expectedResponse: `{"error":"unknown bus"}`, - }, - { - name: "invalid bus number", - setup: nil, - pathParams: map[string]string{"id": "abc"}, - expectedResponse: `{"error":"strconv.ParseUint: parsing \"abc\": invalid syntax"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/{id}/list", handler.BusDevicesList(s)) - }) - defer done() - - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/{id}/list", nil, tt.pathParams) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/device/xbox360" + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusDevicesList(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server) + pathParams map[string]string + expectedResponse string + }{ + { + name: "list devices on existing bus", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60008) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + pathParams: map[string]string{"id": "60008"}, + expectedResponse: `{"devices":[]}`, + }, + { + name: "list devices after adding one", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60009) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + dev, err := xbox360.New(nil) + if err != nil { + t.Fatalf("create device failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device failed: %v", err) + } + }, + pathParams: map[string]string{"id": "60009"}, + expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + }, + { + name: "list devices with multiple additions", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60010) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + dev, err := xbox360.New(nil) + if err != nil { + t.Fatalf("create device 1 failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device 1 failed: %v", err) + } + dev, err = xbox360.New(nil) + if err != nil { + t.Fatalf("create device 2 failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device 2 failed: %v", err) + } + }, + pathParams: map[string]string{"id": "60010"}, + expectedResponse: `{"devices":[{"busId":60010,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"},{"busId":60010,"devId":"2","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + }, + { + name: "list devices on non-existing bus", + setup: nil, + pathParams: map[string]string{"id": "99999"}, + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 99999 not found"}`, + }, + { + name: "invalid bus number", + setup: nil, + pathParams: map[string]string{"id": "abc"}, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"abc\": invalid syntax"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/{id}/list", handler.BusDevicesList(s)) + }) + defer done() + + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv) + } + line, err := c.Do("bus/{id}/list", nil, tt.pathParams) + assert.NoError(t, err) + if tt.expectedResponse[0] == '{' { + assert.JSONEq(t, tt.expectedResponse, line) + } else { + assert.Equal(t, tt.expectedResponse, line) + } + }) + } +} diff --git a/viiper/internal/server/api/handler/bus_list.go b/internal/server/api/handler/bus_list.go similarity index 69% rename from viiper/internal/server/api/handler/bus_list.go rename to internal/server/api/handler/bus_list.go index fb97a75a..a5e850aa 100644 --- a/viiper/internal/server/api/handler/bus_list.go +++ b/internal/server/api/handler/bus_list.go @@ -3,9 +3,10 @@ package handler import ( "encoding/json" "log/slog" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" + + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" ) // BusList returns a handler that lists registered busses. @@ -13,7 +14,7 @@ import ( func BusList(s *usb.Server) api.HandlerFunc { return func(req *api.Request, res *api.Response, logger *slog.Logger) error { buses := s.ListBuses() - payload := apitypes.BusListResponse{Buses: buses} + payload := viipertypes.BusListResponse{Buses: buses} b, err := json.Marshal(payload) if err != nil { return err diff --git a/viiper/internal/server/api/handler/bus_list_test.go b/internal/server/api/handler/bus_list_test.go similarity index 74% rename from viiper/internal/server/api/handler/bus_list_test.go rename to internal/server/api/handler/bus_list_test.go index e19ebbf1..24d0099c 100644 --- a/viiper/internal/server/api/handler/bus_list_test.go +++ b/internal/server/api/handler/bus_list_test.go @@ -1,58 +1,58 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/virtualbus" -) - -func TestBusList(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - expectedResponse string - }{ - { - name: "empty list", - setup: nil, - expectedResponse: `{"buses":[]}`, - }, - { - name: "list with one bus", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60005) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - expectedResponse: `{"buses":[60005]}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/list", handler.BusList(s)) - }) - defer done() - - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/list", nil, nil) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusList(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server) + expectedResponse string + }{ + { + name: "empty list", + setup: nil, + expectedResponse: `{"buses":[]}`, + }, + { + name: "list with one bus", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(60005) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + expectedResponse: `{"buses":[60005]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/list", handler.BusList(s)) + }) + defer done() + + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv) + } + line, err := c.Do("bus/list", nil, nil) + assert.NoError(t, err) + assert.Equal(t, tt.expectedResponse, line) + }) + } +} diff --git a/internal/server/api/handler/bus_remove.go b/internal/server/api/handler/bus_remove.go new file mode 100644 index 00000000..845c2b6f --- /dev/null +++ b/internal/server/api/handler/bus_remove.go @@ -0,0 +1,35 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viipertypes" +) + +// BusRemove returns a handler that removes a bus. +func BusRemove(s *usb.Server) api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + if req.Payload == "" { + return apierror.ErrBadRequest("missing busId") + } + busID, err := strconv.ParseUint(req.Payload, 10, 32) + if err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + } + if err := s.RemoveBus(uint32(busID)); err != nil { + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + } + out, err := json.Marshal(viipertypes.BusRemoveResponse{BusID: uint32(busID)}) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } +} diff --git a/viiper/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go similarity index 64% rename from viiper/internal/server/api/handler/bus_remove_test.go rename to internal/server/api/handler/bus_remove_test.go index 507b4202..410745cf 100644 --- a/viiper/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -1,110 +1,122 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" - "viiper/pkg/virtualbus" -) - -func TestBusRemove(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - payload any - expectedResponse string - }{ - { - name: "remove existing bus", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70001) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - payload: "70001", - expectedResponse: `{"busId":70001}`, - }, - { - name: "remove bus and reuse bus number", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70002) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - payload: "70002", - expectedResponse: `{"busId":70002}`, - }, - { - name: "remove non-existing bus", - setup: nil, - payload: "99999", - expectedResponse: `{"error":"bus 99999 not found"}`, - }, - { - name: "remove bus with devices attached", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70004) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device 1 failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device 2 failed: %v", err) - } - }, - payload: "70004", - expectedResponse: `{"busId":70004}`, - }, - { - name: "invalid bus number", - setup: nil, - payload: "bar", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"bar\": invalid syntax"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/create", handler.BusCreate(s)) - r.Register("bus/remove", handler.BusRemove(s)) - }) - defer done() - - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/remove", tt.payload, nil) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - - if tt.name == "remove bus and reuse bus number" { - b, err := virtualbus.NewWithBusId(70002) - assert.NoError(t, err, "should be able to reuse bus number after removal") - err = srv.AddBus(b) - assert.NoError(t, err, "should be able to add bus with reused number") - } - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/device/xbox360" + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestBusRemove(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *usb.Server) + payload any + expectedResponse string + }{ + { + name: "remove existing bus", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(70001) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + payload: "70001", + expectedResponse: `{"busId":70001}`, + }, + { + name: "remove bus and reuse bus number", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(70002) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + }, + payload: "70002", + expectedResponse: `{"busId":70002}`, + }, + { + name: "remove non-existing bus", + setup: nil, + payload: "99999", + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 99999 not found"}`, + }, + { + name: "remove bus with devices attached", + setup: func(t *testing.T, s *usb.Server) { + b, err := virtualbus.NewWithBusID(70004) + if err != nil { + t.Fatalf("create bus failed: %v", err) + } + if err := s.AddBus(b); err != nil { + t.Fatalf("add bus failed: %v", err) + } + dev, err := xbox360.New(nil) + if err != nil { + t.Fatalf("create device 1 failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device 1 failed: %v", err) + } + dev, err = xbox360.New(nil) + if err != nil { + t.Fatalf("create device 2 failed: %v", err) + } + if _, err := b.Add(dev); err != nil { + t.Fatalf("add device 2 failed: %v", err) + } + }, + payload: "70004", + expectedResponse: `{"busId":70004}`, + }, + { + name: "invalid bus number", + setup: nil, + payload: "bar", + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"bar\": invalid syntax"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("bus/create", handler.BusCreate(s)) + r.Register("bus/remove", handler.BusRemove(s)) + }) + defer done() + + c := viiperclient.NewTransport(addr) + if tt.setup != nil { + tt.setup(t, srv) + } + line, err := c.Do("bus/remove", tt.payload, nil) + assert.NoError(t, err) + if tt.expectedResponse[0] == '{' { + assert.JSONEq(t, tt.expectedResponse, line) + } else { + assert.Equal(t, tt.expectedResponse, line) + } + + if tt.name == "remove bus and reuse bus number" { + b, err := virtualbus.NewWithBusID(70002) + assert.NoError(t, err, "should be able to reuse bus number after removal") + err = srv.AddBus(b) + assert.NoError(t, err, "should be able to add bus with reused number") + } + }) + } +} diff --git a/internal/server/api/handler/dualsense_traffic.go b/internal/server/api/handler/dualsense_traffic.go new file mode 100644 index 00000000..6d126527 --- /dev/null +++ b/internal/server/api/handler/dualsense_traffic.go @@ -0,0 +1,91 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/viipertypes" +) + +func DualSenseTrafficSet() api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + var payload viipertypes.DualSenseTrafficSetRequest + if req.Payload != "" { + if err := json.Unmarshal([]byte(req.Payload), &payload); err != nil { + return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + } + } + + dualsense.SetTrafficDiagnosticsEnabled(payload.Enabled, payload.Clear) + logger.Info("DualSense traffic diagnostics set", "enabled", payload.Enabled, "clear", payload.Clear) + events := dualsense.TrafficDiagnosticsSnapshot() + out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{ + Enabled: dualsense.TrafficDiagnosticsEnabled(), + Count: len(events), + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } +} + +func DualSenseTrafficGet() api.HandlerFunc { + return func(_ *api.Request, res *api.Response, _ *slog.Logger) error { + events := dualsense.TrafficDiagnosticsSnapshot() + out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{ + Enabled: dualsense.TrafficDiagnosticsEnabled(), + Count: len(events), + Events: makeDualSenseTrafficEvents(events), + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } +} + +func DualSenseTrafficClear() api.HandlerFunc { + return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { + dualsense.ClearTrafficDiagnostics() + logger.Info("DualSense traffic diagnostics cleared") + events := dualsense.TrafficDiagnosticsSnapshot() + out, err := json.Marshal(viipertypes.DualSenseTrafficResponse{ + Enabled: dualsense.TrafficDiagnosticsEnabled(), + Count: len(events), + }) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil + } +} + +func makeDualSenseTrafficEvents(events []dualsense.TrafficEvent) []viipertypes.DualSenseTrafficEvent { + out := make([]viipertypes.DualSenseTrafficEvent, len(events)) + for idx, event := range events { + out[idx] = viipertypes.DualSenseTrafficEvent{ + TimeUTC: event.TimeUTC, + Direction: event.Direction, + Source: event.Source, + ReportType: event.ReportType, + ReportID: event.ReportID, + Request: event.Request, + Value: event.Value, + Index: event.Index, + Length: event.Length, + Hex: event.Hex, + Summary: event.Summary, + DecodedOutput: event.DecodedOutput, + } + } + + return out +} diff --git a/internal/server/api/handler/ping.go b/internal/server/api/handler/ping.go new file mode 100644 index 00000000..b4773b49 --- /dev/null +++ b/internal/server/api/handler/ping.go @@ -0,0 +1,33 @@ +package handler + +import ( + "encoding/json" + "log/slog" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/viipertypes" +) + +// Ping returns a handler for the "ping" endpoint. +// It provides a minimal identity + version response. +func Ping() api.HandlerFunc { + return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { + ver, err := common.GetVersion() + if err != nil { + ver = common.Version + if ver == "" { + ver = "dev" + } + logger.Error("ping: invalid version format", "error", err, "version", ver) + } + + payload := viipertypes.PingResponse{Server: "VIIPER", Version: ver} + b, err := json.Marshal(payload) + if err != nil { + return err + } + res.JSON = string(b) + return nil + } +} diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go new file mode 100644 index 00000000..74bf3748 --- /dev/null +++ b/internal/server/api/handler/ping_test.go @@ -0,0 +1,32 @@ +package handler_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + handlerTest "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" +) + +func TestPing(t *testing.T) { + addr, _, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { + r.Register("ping", handler.Ping()) + }) + defer done() + + c := viiperclient.NewTransport(addr) + line, err := c.Do("ping", nil, nil) + assert.NoError(t, err) + + var out viipertypes.PingResponse + err = json.Unmarshal([]byte(line), &out) + assert.NoError(t, err) + assert.Equal(t, "VIIPER", out.Server) + assert.NotEmpty(t, out.Version) +} diff --git a/viiper/internal/server/api/router.go b/internal/server/api/router.go similarity index 97% rename from viiper/internal/server/api/router.go rename to internal/server/api/router.go index da20bd29..5e37e89e 100644 --- a/viiper/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -1,16 +1,19 @@ package api import ( + "context" "log/slog" "net" "strings" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/usb" ) // Request contains route parameters and additional args from the command. type Request struct { - Params map[string]string - Args []string + Ctx context.Context + Params map[string]string + Payload string } // Response holds the JSON string to return to the client. diff --git a/internal/server/api/server.go b/internal/server/api/server.go new file mode 100644 index 00000000..eb0b1d65 --- /dev/null +++ b/internal/server/api/server.go @@ -0,0 +1,386 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "regexp" + "strconv" + "strings" + "time" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/viipertypes" +) + +// Server implements a small TCP API for managing virtual bus topology. +type Server struct { + usbs *usb.Server + addr string + ln net.Listener + logger *slog.Logger + router *Router + config *ServerConfig + deviceStreams deviceStreamCoordinator +} + +// microphonePCMResetter is implemented by audio-capable virtual controllers. +// Its reset is coordinated with stream ownership instead of individual device +// handlers so a same-device replacement can retain already-buffered capture. +type microphonePCMResetter interface { + ResetMicrophonePCM() +} + +// deviceStreamReconnectGrace covers the natural client lifecycle in which the +// old stream is closed immediately before its same-device replacement opens. +// Keeping this separate from the longer removal timeout preserves live capture +// audio without retaining stale transport state for the full device lifetime. +const deviceStreamReconnectGrace = 250 * time.Millisecond + +// New creates a new ApiServer bound to a server.Server instance. +func New(s *usb.Server, addr string, config ServerConfig, logger *slog.Logger) *Server { + cfg := config + a := &Server{ + usbs: s, + addr: addr, + logger: logger, + config: &cfg, + } + a.router = NewRouter() + return a +} + +// Router returns the router used by the API server so callers can register handlers. +func (s *Server) Router() *Router { return s.router } + +// USB returns the underlying USB server. +func (s *Server) USB() *usb.Server { return s.usbs } + +// Config returns the server configuration. +func (s *Server) Config() *ServerConfig { return s.config } + +// ScheduleDeviceCleanup arms the initial no-stream cleanup through the same +// generation owner used for reconnects. A stream that claims the device before +// the timeout atomically cancels this cleanup. +func (s *Server) ScheduleDeviceCleanup(busID uint32, devID string, + deviceContext context.Context) { + key := deviceStreamKey{busID: busID, devID: devID} + s.deviceStreams.scheduleCleanup(key, + s.config.DeviceHandlerConnectTimeout, deviceContext, func() { + if err := s.usbs.RemoveDeviceByID(busID, devID); err != nil { + s.logger.Error("timeout: failed to remove device", + "busID", busID, "deviceID", devID, "error", err) + } else { + s.logger.Info("timeout: removed device (no connection)", + "busID", busID, "deviceID", devID) + } + }) +} + +// Addr returns the actual address the server is listening on. +// If Start hasn't been called yet, it returns the configured address. +func (s *Server) Addr() string { + if s.ln != nil { + return s.ln.Addr().String() + } + return s.addr +} + +// Start listens on the configured address and serves incoming API commands. +func (s *Server) Start() error { + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return err + } + s.ln = ln + + s.addr = ln.Addr().String() + s.config.Addr = s.addr + s.logger.Info("API listening", "addr", s.addr) + go s.serve() + return nil +} + +// Close stops the API server. +func (s *Server) Close() { + if s.ln != nil { + _ = s.ln.Close() + } +} + +func (s *Server) serve() { + for { + c, err := s.ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { + s.logger.Info("API server stopped") + return + } + s.logger.Info("API accept error", "error", err) + return + } + if tcpConn, ok := c.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + s.logger.Warn("failed to set TCP_NODELAY", "error", err) + } + } + go s.handleConn(c) + } +} + +func (s *Server) writeError(w io.Writer, err error) { + apiErr := apierror.WrapError(err) + problemJSON, _ := json.Marshal(apiErr) + _, err = fmt.Fprintf(w, "%s\n", string(problemJSON)) + if err != nil { + s.logger.Error("failed to write error response", "error", err) + } +} + +func (s *Server) writeOK(w io.Writer, rest string) { + if rest == "" { + _, err := fmt.Fprintln(w) + if err != nil { + s.logger.Error("failed to write OK response", "error", err) + } + } else { + _, err := fmt.Fprintf(w, "%s\n", rest) + if err != nil { + s.logger.Error("failed to write OK response", "error", err) + } + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() //nolint:errcheck + + connCtx, connCancel := context.WithCancel(context.Background()) + defer connCancel() + + connLogger := s.logger.With("remote", conn.RemoteAddr().String()) + r := bufio.NewReader(conn) + w := conn + + isAuth, err := auth.IsAuthHandshake(r) + if err != nil { + connLogger.Error("api handshake check", "error", err) + // continue as unauthenticated + } + + if !isAuth && s.requiresAuth(conn.RemoteAddr()) { + connLogger.Error("authentication required") + s.writeError(w, apierror.ErrUnauthorized("authentication required")) + return + } + + if isAuth { + connLogger.Debug("Detected auth attempt") + key, err := auth.DeriveKey(s.config.Password) + if err != nil { + connLogger.Error("derive key failed", "error", err) + return + } + + clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, w, key, false) + if err != nil { + if apiErr, ok := errors.AsType[viipertypes.APIError](err); ok { + connLogger.Error("auth handshake failed", "error", apiErr) + s.writeError(w, apiErr) + return + } + connLogger.Error("auth handshake failed", "error", err) + return + } + + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + secConn, err := auth.WrapConn(conn, sessionKey) + if err != nil { + connLogger.Error("wrap secure conn failed", "error", err) + return + } + conn = secConn + r = bufio.NewReader(conn) + w = conn + + connLogger.Debug("authenticated connection established") + } else { + connLogger.Debug("continuing unauthenticated connection") + } + + // Read until null terminator + reqData, err := r.ReadString('\x00') + if err != nil { + if err == io.EOF { + connLogger.Error("api incomplete request (no null terminator)") + } else { + connLogger.Error("read api data", "error", err) + } + return + } + // Remove null terminator + reqData = strings.TrimSuffix(reqData, "\x00") + + if reqData == "" { + connLogger.Error("api empty command") + s.writeError(w, apierror.ErrBadRequest("empty request")) + return + } + + // Split on first whitespace character using regex \s + wsRegex := regexp.MustCompile(`\s`) + loc := wsRegex.FindStringIndex(reqData) + + var path, payload string + if loc != nil { + path = reqData[:loc[0]] + payload = reqData[loc[1]:] + } else { + path = reqData + payload = "" + } + + if path == "" { + connLogger.Error("api empty path") + s.writeError(w, apierror.ErrBadRequest("empty path")) + return + } + + path = strings.ToLower(path) + connLogger.Info("api cmd", "path", path) + + if h, params := s.router.Match(path); h != nil { + req := &Request{Ctx: connCtx, Params: params, Payload: payload} + res := &Response{} + if err := h(req, res, connLogger); err != nil { + connLogger.Error("api handler error", "path", path, "error", err) + s.writeError(w, err) + return + } + connLogger.Debug("api handler success", "path", path) + s.writeOK(w, res.JSON) + return + } else if sh, params := s.router.MatchStream(path); sh != nil { + connLogger.Info("api stream begin", "path", path) + // ReadString can legally buffer bytes sent immediately after the stream + // path. Keep that reader in front of the connection for the device + // handler; otherwise the first input/microphone frame of a reconnect can + // disappear in the handshake reader and stall framing indefinitely. + streamConn := &bufferedReadConn{Conn: conn, reader: r} + busIDStr, ok := params["busId"] + if !ok { + s.writeError(w, apierror.ErrBadRequest("missing busId parameter")) + return + } + devIDStr, ok := params["deviceid"] + if !ok { + s.writeError(w, apierror.ErrBadRequest("missing deviceid parameter")) + return + } + + busID, err := strconv.ParseUint(busIDStr, 10, 32) + if err != nil { + s.writeError(w, apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err))) + return + } + bus := s.usbs.GetBus(uint32(busID)) + if bus == nil { + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID))) + return + } + var dev pusb.Device + var devCtx context.Context + metas := bus.GetAllDeviceMetas() + for _, meta := range metas { + if fmt.Sprintf("%d", meta.Meta.DevID) == devIDStr { + dev = meta.Dev + devCtx = bus.GetDeviceContext(dev) + break + } + } + if dev == nil || devCtx == nil { + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) + return + } + + streamKey := deviceStreamKey{busID: uint32(busID), devID: devIDStr} + lease := s.deviceStreams.claim(streamKey, streamConn) + handlerStarted := false + defer func() { + if !handlerStarted { + lease.abandon() + return + } + lease.finish(deviceStreamReconnectGrace, + s.config.DeviceHandlerConnectTimeout, devCtx, func() { + if resetter, ok := dev.(microphonePCMResetter); ok { + resetter.ResetMicrophonePCM() + } + }, func() { + if err := bus.RemoveDeviceByID(devIDStr); err != nil { + connLogger.Error("disconnect timeout: failed to remove device", + "busID", busID, "deviceID", devIDStr, "error", err) + } else { + connLogger.Info("disconnect timeout: removed device (no reconnection)", + "busID", busID, "deviceID", devIDStr) + } + }) + }() + + if !lease.waitForTurn(devCtx) { + return + } + select { + case <-devCtx.Done(): + return + default: + } + + // Stream handler takes ownership of connection + handlerStarted = true + if err := sh(streamConn, &dev, connLogger); err != nil { + connLogger.Error("api stream handler error", "path", path, "error", err) + } + connLogger.Info("api stream end", "path", path) + + return + } + connLogger.Error("api unknown path", "path", path) + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("unknown path: %s", path))) +} + +type bufferedReadConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedReadConn) Read(buffer []byte) (int, error) { + return c.reader.Read(buffer) +} + +func (s *Server) isLocalHostClient(addr net.Addr) bool { + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + return false + } + switch host { + case "localhost", "127.0.0.1", "[::1]", "::1": + return true + } + + return false +} + +func (s *Server) requiresAuth(addr net.Addr) bool { + if s.isLocalHostClient(addr) { + return s.config.RequireLocalHostAuth + } + return true +} diff --git a/internal/server/api/server_test.go b/internal/server/api/server_test.go new file mode 100644 index 00000000..1912517b --- /dev/null +++ b/internal/server/api/server_test.go @@ -0,0 +1,266 @@ +package api_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + th "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/log" + _ "github.com/Alia5/VIIPER/internal/registry" // Register devices + "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/internal/server/api/handler" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { + cfg := srvusb.ServerConfig{Addr: "127.0.0.1:0"} + usbSrv := srvusb.New(cfg, slog.Default(), log.NewRaw(nil)) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + _ = ln.Close() + + apiSrv := api.New(usbSrv, addr, api.ServerConfig{Addr: addr}, slog.Default()) + r := apiSrv.Router() + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) + require.NoError(t, apiSrv.Start()) + defer apiSrv.Close() //nolint:errcheck + + bus, err := virtualbus.NewWithBusID(70002) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(bus)) + dev, err := xbox360.New(nil) + require.NoError(t, err) + _, err = bus.Add(dev) + require.NoError(t, err) + + var devID string + metas := bus.GetAllDeviceMetas() + require.Greater(t, len(metas), 0) + for _, m := range metas { + devID = fmt.Sprintf("%d", m.Meta.DevID) + } + require.NotEmpty(t, devID) + + sentinel := fmt.Errorf("boom") + mr := th.CreateMockRegistration(t, "xbox360_error_stream", + func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, + func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, + ) + + api.RegisterDevice("xbox360_error_stream", mr) + c, err := net.Dial("tcp", addr) + require.NoError(t, err) + _, err = fmt.Fprintf(c, "bus/%d/%s\n", bus.BusID(), devID) + require.NoError(t, err) + + buf := make([]byte, 1) + _ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, readErr := c.Read(buf) + require.Error(t, readErr) + _ = c.Close() +} + +func TestAPIServer_WrappedConn(t *testing.T) { + + type testCase struct { + name string + requireLocalAuth bool + serverPass string + clientPass string + expectedResponse string + expectedErr error + + inputState xbox360.InputState + expectedReport []byte + rumbleState xbox360.XRumbleState + outPacket []byte + } + + tests := []testCase{ + { + name: "SUCCESS unauthenticated", + requireLocalAuth: false, + serverPass: "", + clientPass: "", + expectedResponse: "xbox360", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadUp, + }, + expectedReport: []byte{0x00, 0x14, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + rumbleState: xbox360.XRumbleState{ + LeftMotor: 236, + RightMotor: 65, + }, + outPacket: []byte{0x00, 0x08, 0x00, 236, 65, 0x00, 0x00, 0x00}, + }, + { + name: "SUCCESS authenticated (required)", + requireLocalAuth: true, + serverPass: "test123", + clientPass: "test123", + expectedResponse: "xbox360", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadUp, + }, + expectedReport: []byte{0x00, 0x14, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + rumbleState: xbox360.XRumbleState{ + LeftMotor: 236, + RightMotor: 65, + }, + outPacket: []byte{0x00, 0x08, 0x00, 236, 65, 0x00, 0x00, 0x00}, + }, + { + name: "SUCCESS authenticated (optional)", + requireLocalAuth: false, + serverPass: "test123", + clientPass: "test123", + expectedResponse: "xbox360", + inputState: xbox360.InputState{ + Buttons: xbox360.ButtonDPadUp, + }, + expectedReport: []byte{0x00, 0x14, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + rumbleState: xbox360.XRumbleState{ + LeftMotor: 236, + RightMotor: 65, + }, + outPacket: []byte{0x00, 0x08, 0x00, 236, 65, 0x00, 0x00, 0x00}, + }, + { + name: "Invalid password", + requireLocalAuth: false, + serverPass: "test123", + clientPass: "wrongpass", + expectedErr: apierror.ErrUnauthorized("invalid password"), + }, + { + name: "Auth Required", + requireLocalAuth: true, + serverPass: "test123", + clientPass: "", + expectedErr: apierror.ErrUnauthorized("authentication required"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + testSrvConfig := viiperTesting.TestServerConfig(t) + + testSrvConfig.Server.APIServerConfig.RequireLocalHostAuth = tc.requireLocalAuth + testSrvConfig.Server.APIServerConfig.Password = tc.serverPass + + s := viiperTesting.NewTestServerWithConfig(t, testSrvConfig) + defer s.ApiServer.Close() //nolint:errcheck + defer s.UsbServer.Close() //nolint:errcheck + + r := s.ApiServer.Router() + r.Register("bus/{id}/add", handler.BusDeviceAdd(s.UsbServer, s.ApiServer)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(s.UsbServer)) + + if err := s.ApiServer.Start(); err != nil { + t.Fatalf("Failed to start API server: %v", err) + } + time.Sleep(50 * time.Millisecond) + + b, err := virtualbus.NewWithBusID(1) + if err != nil { + t.Fatalf("Failed to create virtual bus: %v", err) + } + defer b.Close() //nolint:errcheck + _ = s.UsbServer.AddBus(b) + time.Sleep(50 * time.Millisecond) + + client := viiperclient.NewWithPassword(s.ApiServer.Addr(), tc.clientPass) + + time.Sleep(50 * time.Millisecond) + + resp, err := client.DeviceAdd(b.BusID(), "xbox360", nil) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } else { + if !assert.NoError(t, err) { + return + } + } + + assert.Equal(t, tc.expectedResponse, resp.Type) + + time.Sleep(50 * time.Millisecond) + + stream, err := client.OpenStream(context.Background(), b.BusID(), resp.DevID) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } else { + if !assert.NoError(t, err) { + return + } + } + defer stream.Close() //nolint:errcheck + time.Sleep(50 * time.Millisecond) + + usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) + devs, err := usbipClient.ListDevices() + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, devs, 1) { + return + } + imp, err := usbipClient.AttachDevice(devs[0].BusID) + if !assert.NoError(t, err) { + return + } + if imp != nil && imp.Conn != nil { + defer imp.Conn.Close() //nolint:errcheck + } + + time.Sleep(50 * time.Millisecond) + + assert.Equal(t, tc.expectedReport, tc.inputState.BuildReport()) + if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { + return + } + + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.expectedReport, got) + + if !assert.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 1, tc.outPacket, nil)) { + return + } + var buf [2]byte + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) + _, err = io.ReadFull(stream, buf[:]) + if !assert.NoError(t, err) { + return + } + gotOut := xbox360.XRumbleState{LeftMotor: buf[0], RightMotor: buf[1]} + assert.Equal(t, tc.rumbleState, gotOut) + + }) + } + +} diff --git a/internal/server/api/stream_reconnect_test.go b/internal/server/api/stream_reconnect_test.go new file mode 100644 index 00000000..31c91313 --- /dev/null +++ b/internal/server/api/stream_reconnect_test.go @@ -0,0 +1,231 @@ +package api_test + +import ( + "context" + "encoding/binary" + "hash/crc32" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/internal/log" + _ "github.com/Alia5/VIIPER/internal/registry" // Register devices. + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/virtualbus" +) + +// TestAPIServer_DeviceStreamCloseFirstReconnectKeepsDS4MicrophoneQueue proves +// the complete lifecycle contract used by DS4Windows recovery, including its +// natural close-old then open-new ordering. The replacement connection retains +// buffered capture, while a final disconnect still resets it after the grace. +func TestAPIServer_DeviceStreamCloseFirstReconnectKeepsDS4MicrophoneQueue(t *testing.T) { + const ( + busID = uint32(71004) + cleanupTimeout = 750 * time.Millisecond + ) + + usbServer := srvusb.New(srvusb.ServerConfig{Addr: "127.0.0.1:0"}, + slog.Default(), log.NewRaw(nil)) + apiServer := api.New(usbServer, "127.0.0.1:0", api.ServerConfig{ + Addr: "127.0.0.1:0", + DeviceHandlerConnectTimeout: cleanupTimeout, + }, slog.Default()) + apiServer.Router().Register("bus/{id}/add", + handler.BusDeviceAdd(usbServer, apiServer)) + apiServer.Router().RegisterStream("bus/{busId}/{deviceid}", + api.DeviceStreamHandler(usbServer)) + require.NoError(t, apiServer.Start()) + defer apiServer.Close() + defer usbServer.Close() //nolint:errcheck + + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + defer bus.Close() //nolint:errcheck + require.NoError(t, usbServer.AddBus(bus)) + + client := viiperclient.New(apiServer.Addr()) + created, err := client.DeviceAdd(busID, "dualshock4micv2", nil) + require.NoError(t, err) + require.NotNil(t, created) + + devices := bus.Devices() + require.Len(t, devices, 1) + ds4, ok := devices[0].(*dualshock4.DualShock4) + require.True(t, ok) + ds4.SetInterfaceAltSetting(dualshock4.InterfaceMicrophone, 1) + + first, err := client.OpenStream(context.Background(), busID, created.DevID) + require.NoError(t, err) + defer first.Close() //nolint:errcheck + writeDS4MicrophoneFrames(t, first, 6, 0x11) + require.Eventually(t, func() bool { + state := ds4.GetDeviceSpecificArgs() + return state["microphoneQueuePrimed"] == true + }, time.Second, 5*time.Millisecond) + + require.NoError(t, first.Close()) + // Give the server enough time to observe EOF and finish the old generation; + // this deliberately exercises close-first rather than displacement by claim. + time.Sleep(50 * time.Millisecond) + require.Equal(t, dualshock4.USBMicrophoneClientFrameSize*6, + ds4.GetDeviceSpecificArgs()["queuedMicrophoneBytes"]) + + second, err := client.OpenStream(context.Background(), busID, created.DevID) + require.NoError(t, err) + defer second.Close() //nolint:errcheck + writeDS4MicrophoneFrames(t, second, 1, 0x44) + + // Wait beyond the old generation's 250 ms finalization deadline. Its stale + // timer must be unable to erase either the retained or replacement frame. + time.Sleep(300 * time.Millisecond) + state := ds4.GetDeviceSpecificArgs() + require.Equal(t, dualshock4.USBMicrophoneClientFrameSize*7, + state["queuedMicrophoneBytes"]) + require.Equal(t, true, state["microphoneQueuePrimed"]) + require.Len(t, bus.Devices(), 1, + "displaced stream generation removed the live virtual device") + + // With no next replacement, the final current stream performs the one + // definitive reset after the reconnect grace, before device removal. + require.NoError(t, second.Close()) + require.Eventually(t, func() bool { + return ds4.GetDeviceSpecificArgs()["queuedMicrophoneBytes"] == 0 + }, 500*time.Millisecond, 5*time.Millisecond) + require.Len(t, bus.Devices(), 1) +} + +func writeDS4MicrophoneFrames(t *testing.T, stream *viiperclient.DeviceStream, + count uint32, value byte) { + t.Helper() + for sequence := uint32(0); sequence < count; sequence++ { + payload := make([]byte, dualshock4.USBMicrophoneClientFrameSize) + for index := range payload { + payload[index] = value + } + frame := makeDS4StreamFrame(sequence, payload) + written, err := stream.Write(frame) + require.NoError(t, err) + require.Equal(t, len(frame), written) + } +} + +func makeDS4StreamFrame(sequence uint32, payload []byte) []byte { + const headerSize = 16 + header := make([]byte, headerSize) + copy(header[0:4], []byte{'V', 'P', 'C', 'M'}) + header[4] = dualshock4.StreamFrameVersionV2 + header[5] = dualshock4.StreamFrameMicrophonePCM + binary.LittleEndian.PutUint16(header[6:8], uint16(len(payload))) + binary.LittleEndian.PutUint32(header[8:12], sequence) + hash := crc32.NewIEEE() + _, _ = hash.Write(header[4:12]) + _, _ = hash.Write(payload) + binary.LittleEndian.PutUint32(header[12:16], hash.Sum32()) + return append(header, payload...) +} + +func TestAPIServer_DeviceStreamCloseFirstReconnectPreservesDualSenseMicrophoneQueue(t *testing.T) { + const ( + busID = uint32(71005) + cleanupTimeout = 750 * time.Millisecond + ) + + usbServer := srvusb.New(srvusb.ServerConfig{Addr: "127.0.0.1:0"}, + slog.Default(), log.NewRaw(nil)) + apiServer := api.New(usbServer, "127.0.0.1:0", api.ServerConfig{ + Addr: "127.0.0.1:0", + DeviceHandlerConnectTimeout: cleanupTimeout, + }, slog.Default()) + apiServer.Router().Register("bus/{id}/add", + handler.BusDeviceAdd(usbServer, apiServer)) + apiServer.Router().RegisterStream("bus/{busId}/{deviceid}", + api.DeviceStreamHandler(usbServer)) + require.NoError(t, apiServer.Start()) + defer apiServer.Close() + defer usbServer.Close() //nolint:errcheck + + bus, err := virtualbus.NewWithBusID(busID) + require.NoError(t, err) + defer bus.Close() //nolint:errcheck + require.NoError(t, usbServer.AddBus(bus)) + + client := viiperclient.New(apiServer.Addr()) + created, err := client.DeviceAdd(busID, "dualsensecombinedmicv2", nil) + require.NoError(t, err) + require.NotNil(t, created) + + devices := bus.Devices() + require.Len(t, devices, 1) + ds, ok := devices[0].(*dualsense.DualSense) + require.True(t, ok) + ds.SetInterfaceAltSetting(dualsense.InterfaceMicrophone, 1) + + first, err := client.OpenStream(context.Background(), busID, created.DevID) + require.NoError(t, err) + defer first.Close() //nolint:errcheck + writeDualSenseMicrophoneFrames(t, first, 6, 0x22) + require.Eventually(t, func() bool { + return ds.GetDeviceSpecificArgs()["microphoneQueuePrimed"] == true + }, time.Second, 5*time.Millisecond) + + require.NoError(t, first.Close()) + time.Sleep(50 * time.Millisecond) + require.Equal(t, dualsense.USBMicrophoneClientFrameSize*6, + ds.GetDeviceSpecificArgs()["queuedMicrophoneBytes"]) + + second, err := client.OpenStream(context.Background(), busID, created.DevID) + require.NoError(t, err) + defer second.Close() //nolint:errcheck + writeDualSenseMicrophoneFrames(t, second, 1, 0x55) + + time.Sleep(300 * time.Millisecond) + state := ds.GetDeviceSpecificArgs() + require.Equal(t, dualsense.USBMicrophoneClientFrameSize*7, + state["queuedMicrophoneBytes"]) + require.Equal(t, true, state["microphoneQueuePrimed"]) + require.Len(t, bus.Devices(), 1, + "displaced stream generation removed the live virtual device") + + require.NoError(t, second.Close()) + require.Eventually(t, func() bool { + return ds.GetDeviceSpecificArgs()["queuedMicrophoneBytes"] == 0 + }, 500*time.Millisecond, 5*time.Millisecond) + require.Len(t, bus.Devices(), 1) +} + +func writeDualSenseMicrophoneFrames(t *testing.T, + stream *viiperclient.DeviceStream, count uint32, value byte) { + t.Helper() + for sequence := uint32(0); sequence < count; sequence++ { + payload := make([]byte, dualsense.USBMicrophoneClientFrameSize) + for index := range payload { + payload[index] = value + } + frame := makeDualSenseStreamFrame(sequence, payload) + written, err := stream.Write(frame) + require.NoError(t, err) + require.Equal(t, len(frame), written) + } +} + +func makeDualSenseStreamFrame(sequence uint32, payload []byte) []byte { + const headerSize = 16 + header := make([]byte, headerSize) + copy(header[0:4], []byte{'V', 'P', 'C', 'M'}) + header[4] = dualsense.StreamFrameVersionV2 + header[5] = dualsense.StreamFrameMicrophonePCM + binary.LittleEndian.PutUint16(header[6:8], uint16(len(payload))) + binary.LittleEndian.PutUint32(header[8:12], sequence) + hash := crc32.NewIEEE() + _, _ = hash.Write(header[4:12]) + _, _ = hash.Write(payload) + binary.LittleEndian.PutUint32(header[12:16], hash.Sum32()) + return append(header, payload...) +} diff --git a/viiper/internal/server/proxy/parser.go b/internal/server/proxy/parser.go similarity index 96% rename from viiper/internal/server/proxy/parser.go rename to internal/server/proxy/parser.go index 0b6972bb..82c9cadc 100644 --- a/viiper/internal/server/proxy/parser.go +++ b/internal/server/proxy/parser.go @@ -1,338 +1,339 @@ -package proxy - -import ( - "bytes" - "encoding/binary" - "fmt" - "log/slog" - "viiper/pkg/usbip" -) - -// Parser handles USB-IP packet parsing for structured logging. -type Parser struct { - logger *slog.Logger - buf bytes.Buffer -} - -func NewParser(logger *slog.Logger) *Parser { - return &Parser{ - logger: logger, - } -} - -// Parse processes incoming data and logs USB-IP protocol information. -func (p *Parser) Parse(data []byte, clientToServer bool) { - p.buf.Write(data) - - for p.buf.Len() >= 8 { - peek := p.buf.Bytes() - - ver := binary.BigEndian.Uint16(peek[0:2]) - code := binary.BigEndian.Uint16(peek[2:4]) - - if ver == usbip.Version { - switch code { - case usbip.OpReqDevlist: - if p.buf.Len() >= 8 { - p.logMgmtOp("OP_REQ_DEVLIST", clientToServer) - p.buf.Next(8) - continue - } - return - - case usbip.OpRepDevlist: - if consumed := p.parseOpRepDevlist(peek, clientToServer); consumed > 0 { - p.buf.Next(consumed) - continue - } - return - - case usbip.OpReqImport: - if p.buf.Len() >= 40 { // 8 byte header + 32 byte busid - busid := peek[8:40] - end := bytes.IndexByte(busid, 0) - if end == -1 { - end = len(busid) - } - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "OP_REQ_IMPORT", - "busid", string(busid[:end])) - p.buf.Next(40) - continue - } - return - - case usbip.OpRepImport: - if consumed := p.parseOpRepImport(peek, clientToServer); consumed > 0 { - p.buf.Next(consumed) - continue - } - return - } - } - - if p.buf.Len() >= 48 { // 0x30 bytes - cmd := binary.BigEndian.Uint32(peek[0:4]) - - switch cmd { - case usbip.CmdSubmitCode: - p.parseCmdSubmit(peek, clientToServer) - p.buf.Next(48) - dir := binary.BigEndian.Uint32(peek[12:16]) - xferLen := binary.BigEndian.Uint32(peek[24:28]) - if dir == usbip.DirOut && xferLen > 0 && uint32(p.buf.Len()) >= xferLen { - p.buf.Next(int(xferLen)) - } - continue - - case usbip.RetSubmitCode: - p.parseRetSubmit(peek, clientToServer) - p.buf.Next(48) - actualLen := binary.BigEndian.Uint32(peek[24:28]) - if actualLen > 0 && uint32(p.buf.Len()) >= actualLen { - p.buf.Next(int(actualLen)) - } - continue - - case usbip.CmdUnlinkCode: - p.parseCmdUnlink(peek, clientToServer) - p.buf.Next(48) - continue - - case usbip.RetUnlinkCode: - p.parseRetUnlink(peek, clientToServer) - p.buf.Next(48) - continue - } - } - - if p.buf.Len() > 64*1024 { - p.logger.Warn("Parser buffer overflow, resetting") - p.buf.Reset() - } - return - } -} - -func (p *Parser) parseCmdSubmit(data []byte, clientToServer bool) { - seqnum := binary.BigEndian.Uint32(data[4:8]) - devid := binary.BigEndian.Uint32(data[8:12]) - dir := binary.BigEndian.Uint32(data[12:16]) - ep := binary.BigEndian.Uint32(data[16:20]) - xferLen := binary.BigEndian.Uint32(data[24:28]) - setup := data[40:48] - - args := []any{ - "dir", dirString(clientToServer), - "op", "CMD_SUBMIT", - "seq", seqnum, - "devid", devid, - "ep", ep, - "urb_dir", urbDirString(dir), - "len", xferLen, - } - - if ep == 0 { - args = append(args, "setup", fmt.Sprintf("[%02x %02x %02x %02x %02x %02x %02x %02x]", - setup[0], setup[1], setup[2], setup[3], setup[4], setup[5], setup[6], setup[7])) - } - - p.logger.Info("USBIP packet", args...) -} - -func (p *Parser) parseRetSubmit(data []byte, clientToServer bool) { - seqnum := binary.BigEndian.Uint32(data[4:8]) - status := int32(binary.BigEndian.Uint32(data[20:24])) - actualLen := binary.BigEndian.Uint32(data[24:28]) - - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "RET_SUBMIT", - "seq", seqnum, - "status", status, - "actual_len", actualLen) -} - -func (p *Parser) parseCmdUnlink(data []byte, clientToServer bool) { - seqnum := binary.BigEndian.Uint32(data[4:8]) - unlinkSeq := binary.BigEndian.Uint32(data[20:24]) - - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "CMD_UNLINK", - "seq", seqnum, - "unlink_seq", unlinkSeq) -} - -func (p *Parser) parseRetUnlink(data []byte, clientToServer bool) { - seqnum := binary.BigEndian.Uint32(data[4:8]) - status := int32(binary.BigEndian.Uint32(data[20:24])) - - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "RET_UNLINK", - "seq", seqnum, - "status", status) -} - -func (p *Parser) logMgmtOp(op string, clientToServer bool) { - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", op) -} - -func (p *Parser) parseOpRepDevlist(data []byte, clientToServer bool) int { - if len(data) < 12 { - return 0 - } - - nDevices := binary.BigEndian.Uint32(data[8:12]) - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "OP_REP_DEVLIST", - "nDevices", nDevices) - - offset := 12 - for i := uint32(0); i < nDevices; i++ { - // Device entry: 312 bytes base (path[256] + busid[32] + busid(4) + devid(4) + speed(4) + ids(8) + class(6)) - // Plus 4 bytes per interface (class, subclass, protocol, padding) - if len(data) < offset+312 { - return 0 // Need more data - } - - path := data[offset : offset+256] - pathEnd := bytes.IndexByte(path, 0) - if pathEnd == -1 { - pathEnd = len(path) - } - pathStr := string(path[:pathEnd]) - - busid := data[offset+256 : offset+288] - busidEnd := bytes.IndexByte(busid, 0) - if busidEnd == -1 { - busidEnd = len(busid) - } - busidStr := string(busid[:busidEnd]) - - busnum := binary.BigEndian.Uint32(data[offset+288 : offset+292]) - devnum := binary.BigEndian.Uint32(data[offset+292 : offset+296]) - speed := binary.BigEndian.Uint32(data[offset+296 : offset+300]) - idVendor := binary.BigEndian.Uint16(data[offset+300 : offset+302]) - idProduct := binary.BigEndian.Uint16(data[offset+302 : offset+304]) - bcdDevice := binary.BigEndian.Uint16(data[offset+304 : offset+306]) - bDeviceClass := data[offset+306] - bDeviceSubClass := data[offset+307] - bDeviceProtocol := data[offset+308] - bConfigurationValue := data[offset+309] - bNumConfigurations := data[offset+310] - bNumInterfaces := data[offset+311] - - p.logger.Info(" Device", - "path", pathStr, - "busid", busidStr, - "bus", busnum, - "dev", devnum, - "speed", speed, - "vid", fmt.Sprintf("%04x", idVendor), - "pid", fmt.Sprintf("%04x", idProduct), - "bcd", fmt.Sprintf("%04x", bcdDevice), - "class", fmt.Sprintf("%02x", bDeviceClass), - "subclass", fmt.Sprintf("%02x", bDeviceSubClass), - "protocol", fmt.Sprintf("%02x", bDeviceProtocol), - "config", bConfigurationValue, - "nConfigs", bNumConfigurations, - "nInterfaces", bNumInterfaces) - - offset += 312 - - // Parse interfaces (4 bytes each) - for j := uint8(0); j < bNumInterfaces; j++ { - if len(data) < offset+4 { - return 0 - } - ifClass := data[offset] - ifSubClass := data[offset+1] - ifProtocol := data[offset+2] - p.logger.Info(" Interface", - "num", j, - "class", fmt.Sprintf("%02x", ifClass), - "subclass", fmt.Sprintf("%02x", ifSubClass), - "protocol", fmt.Sprintf("%02x", ifProtocol)) - offset += 4 - } - } - - return offset -} - -func (p *Parser) parseOpRepImport(data []byte, clientToServer bool) int { - // OP_REP_IMPORT: 8 byte header + 312 byte device descriptor (same as devlist, but without interfaces) - if len(data) < 320 { - return 0 - } - - status := binary.BigEndian.Uint32(data[4:8]) - - path := data[8 : 8+256] - pathEnd := bytes.IndexByte(path, 0) - if pathEnd == -1 { - pathEnd = len(path) - } - pathStr := string(path[:pathEnd]) - - busid := data[264 : 264+32] - busidEnd := bytes.IndexByte(busid, 0) - if busidEnd == -1 { - busidEnd = len(busid) - } - busidStr := string(busid[:busidEnd]) - - busnum := binary.BigEndian.Uint32(data[296:300]) - devnum := binary.BigEndian.Uint32(data[300:304]) - speed := binary.BigEndian.Uint32(data[304:308]) - idVendor := binary.BigEndian.Uint16(data[308:310]) - idProduct := binary.BigEndian.Uint16(data[310:312]) - bcdDevice := binary.BigEndian.Uint16(data[312:314]) - bDeviceClass := data[314] - bDeviceSubClass := data[315] - bDeviceProtocol := data[316] - bConfigurationValue := data[317] - bNumConfigurations := data[318] - bNumInterfaces := data[319] - - p.logger.Info("USBIP packet", - "dir", dirString(clientToServer), - "op", "OP_REP_IMPORT", - "status", status, - "path", pathStr, - "busid", busidStr, - "bus", busnum, - "dev", devnum, - "speed", speed, - "vid", fmt.Sprintf("%04x", idVendor), - "pid", fmt.Sprintf("%04x", idProduct), - "bcd", fmt.Sprintf("%04x", bcdDevice), - "class", fmt.Sprintf("%02x", bDeviceClass), - "subclass", fmt.Sprintf("%02x", bDeviceSubClass), - "protocol", fmt.Sprintf("%02x", bDeviceProtocol), - "config", bConfigurationValue, - "nConfigs", bNumConfigurations, - "nInterfaces", bNumInterfaces) - - return 320 -} - -func dirString(clientToServer bool) string { - if clientToServer { - return "C→S" - } - return "S→C" -} - -func urbDirString(dir uint32) string { - if dir == usbip.DirOut { - return "OUT" - } - return "IN" -} +package proxy + +import ( + "bytes" + "encoding/binary" + "fmt" + "log/slog" + + "github.com/Alia5/VIIPER/usbip" +) + +// Parser handles USB-IP packet parsing for structured logging. +type Parser struct { + logger *slog.Logger + buf bytes.Buffer +} + +func NewParser(logger *slog.Logger) *Parser { + return &Parser{ + logger: logger, + } +} + +// Parse processes incoming data and logs USB-IP protocol information. +func (p *Parser) Parse(data []byte, clientToServer bool) { + p.buf.Write(data) + + for p.buf.Len() >= 8 { + peek := p.buf.Bytes() + + ver := binary.BigEndian.Uint16(peek[0:2]) + code := binary.BigEndian.Uint16(peek[2:4]) + + if ver == usbip.Version { + switch code { + case usbip.OpReqDevlist: + if p.buf.Len() >= 8 { + p.logMgmtOp("OP_REQ_DEVLIST", clientToServer) + p.buf.Next(8) + continue + } + return + + case usbip.OpRepDevlist: + if consumed := p.parseOpRepDevlist(peek, clientToServer); consumed > 0 { + p.buf.Next(consumed) + continue + } + return + + case usbip.OpReqImport: + if p.buf.Len() >= 40 { // 8 byte header + 32 byte busid + busid := peek[8:40] + end := bytes.IndexByte(busid, 0) + if end == -1 { + end = len(busid) + } + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "OP_REQ_IMPORT", + "busid", string(busid[:end])) + p.buf.Next(40) + continue + } + return + + case usbip.OpRepImport: + if consumed := p.parseOpRepImport(peek, clientToServer); consumed > 0 { + p.buf.Next(consumed) + continue + } + return + } + } + + if p.buf.Len() >= 48 { // 0x30 bytes + cmd := binary.BigEndian.Uint32(peek[0:4]) + + switch cmd { + case usbip.CmdSubmitCode: + p.parseCmdSubmit(peek, clientToServer) + p.buf.Next(48) + dir := binary.BigEndian.Uint32(peek[12:16]) + xferLen := binary.BigEndian.Uint32(peek[24:28]) + if dir == usbip.DirOut && xferLen > 0 && uint32(p.buf.Len()) >= xferLen { + p.buf.Next(int(xferLen)) + } + continue + + case usbip.RetSubmitCode: + p.parseRetSubmit(peek, clientToServer) + p.buf.Next(48) + actualLen := binary.BigEndian.Uint32(peek[24:28]) + if actualLen > 0 && uint32(p.buf.Len()) >= actualLen { + p.buf.Next(int(actualLen)) + } + continue + + case usbip.CmdUnlinkCode: + p.parseCmdUnlink(peek, clientToServer) + p.buf.Next(48) + continue + + case usbip.RetUnlinkCode: + p.parseRetUnlink(peek, clientToServer) + p.buf.Next(48) + continue + } + } + + if p.buf.Len() > 64*1024 { + p.logger.Warn("Parser buffer overflow, resetting") + p.buf.Reset() + } + return + } +} + +func (p *Parser) parseCmdSubmit(data []byte, clientToServer bool) { + seqnum := binary.BigEndian.Uint32(data[4:8]) + devid := binary.BigEndian.Uint32(data[8:12]) + dir := binary.BigEndian.Uint32(data[12:16]) + ep := binary.BigEndian.Uint32(data[16:20]) + xferLen := binary.BigEndian.Uint32(data[24:28]) + setup := data[40:48] + + args := []any{ + "dir", dirString(clientToServer), + "op", "CMD_SUBMIT", + "seq", seqnum, + "devid", devid, + "ep", ep, + "urb_dir", urbDirString(dir), + "len", xferLen, + } + + if ep == 0 { + args = append(args, "setup", fmt.Sprintf("[%02x %02x %02x %02x %02x %02x %02x %02x]", + setup[0], setup[1], setup[2], setup[3], setup[4], setup[5], setup[6], setup[7])) + } + + p.logger.Info("USBIP packet", args...) +} + +func (p *Parser) parseRetSubmit(data []byte, clientToServer bool) { + seqnum := binary.BigEndian.Uint32(data[4:8]) + status := int32(binary.BigEndian.Uint32(data[20:24])) + actualLen := binary.BigEndian.Uint32(data[24:28]) + + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "RET_SUBMIT", + "seq", seqnum, + "status", status, + "actual_len", actualLen) +} + +func (p *Parser) parseCmdUnlink(data []byte, clientToServer bool) { + seqnum := binary.BigEndian.Uint32(data[4:8]) + unlinkSeq := binary.BigEndian.Uint32(data[20:24]) + + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "CMD_UNLINK", + "seq", seqnum, + "unlink_seq", unlinkSeq) +} + +func (p *Parser) parseRetUnlink(data []byte, clientToServer bool) { + seqnum := binary.BigEndian.Uint32(data[4:8]) + status := int32(binary.BigEndian.Uint32(data[20:24])) + + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "RET_UNLINK", + "seq", seqnum, + "status", status) +} + +func (p *Parser) logMgmtOp(op string, clientToServer bool) { + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", op) +} + +func (p *Parser) parseOpRepDevlist(data []byte, clientToServer bool) int { + if len(data) < 12 { + return 0 + } + + nDevices := binary.BigEndian.Uint32(data[8:12]) + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "OP_REP_DEVLIST", + "nDevices", nDevices) + + offset := 12 + for i := uint32(0); i < nDevices; i++ { + // Device entry: 312 bytes base (path[256] + busid[32] + busid(4) + devid(4) + speed(4) + ids(8) + class(6)) + // Plus 4 bytes per interface (class, subclass, protocol, padding) + if len(data) < offset+312 { + return 0 // Need more data + } + + path := data[offset : offset+256] + pathEnd := bytes.IndexByte(path, 0) + if pathEnd == -1 { + pathEnd = len(path) + } + pathStr := string(path[:pathEnd]) + + busid := data[offset+256 : offset+288] + busidEnd := bytes.IndexByte(busid, 0) + if busidEnd == -1 { + busidEnd = len(busid) + } + busidStr := string(busid[:busidEnd]) + + busnum := binary.BigEndian.Uint32(data[offset+288 : offset+292]) + devnum := binary.BigEndian.Uint32(data[offset+292 : offset+296]) + speed := binary.BigEndian.Uint32(data[offset+296 : offset+300]) + idVendor := binary.BigEndian.Uint16(data[offset+300 : offset+302]) + idProduct := binary.BigEndian.Uint16(data[offset+302 : offset+304]) + bcdDevice := binary.BigEndian.Uint16(data[offset+304 : offset+306]) + bDeviceClass := data[offset+306] + bDeviceSubClass := data[offset+307] + bDeviceProtocol := data[offset+308] + bConfigurationValue := data[offset+309] + bNumConfigurations := data[offset+310] + bNumInterfaces := data[offset+311] + + p.logger.Info(" Device", + "path", pathStr, + "busid", busidStr, + "bus", busnum, + "dev", devnum, + "speed", speed, + "vid", fmt.Sprintf("%04x", idVendor), + "pid", fmt.Sprintf("%04x", idProduct), + "bcd", fmt.Sprintf("%04x", bcdDevice), + "class", fmt.Sprintf("%02x", bDeviceClass), + "subclass", fmt.Sprintf("%02x", bDeviceSubClass), + "protocol", fmt.Sprintf("%02x", bDeviceProtocol), + "config", bConfigurationValue, + "nConfigs", bNumConfigurations, + "nInterfaces", bNumInterfaces) + + offset += 312 + + // Parse interfaces (4 bytes each) + for j := uint8(0); j < bNumInterfaces; j++ { + if len(data) < offset+4 { + return 0 + } + ifClass := data[offset] + ifSubClass := data[offset+1] + ifProtocol := data[offset+2] + p.logger.Info(" Interface", + "num", j, + "class", fmt.Sprintf("%02x", ifClass), + "subclass", fmt.Sprintf("%02x", ifSubClass), + "protocol", fmt.Sprintf("%02x", ifProtocol)) + offset += 4 + } + } + + return offset +} + +func (p *Parser) parseOpRepImport(data []byte, clientToServer bool) int { + // OP_REP_IMPORT: 8 byte header + 312 byte device descriptor (same as devlist, but without interfaces) + if len(data) < 320 { + return 0 + } + + status := binary.BigEndian.Uint32(data[4:8]) + + path := data[8 : 8+256] + pathEnd := bytes.IndexByte(path, 0) + if pathEnd == -1 { + pathEnd = len(path) + } + pathStr := string(path[:pathEnd]) + + busid := data[264 : 264+32] + busidEnd := bytes.IndexByte(busid, 0) + if busidEnd == -1 { + busidEnd = len(busid) + } + busidStr := string(busid[:busidEnd]) + + busnum := binary.BigEndian.Uint32(data[296:300]) + devnum := binary.BigEndian.Uint32(data[300:304]) + speed := binary.BigEndian.Uint32(data[304:308]) + idVendor := binary.BigEndian.Uint16(data[308:310]) + idProduct := binary.BigEndian.Uint16(data[310:312]) + bcdDevice := binary.BigEndian.Uint16(data[312:314]) + bDeviceClass := data[314] + bDeviceSubClass := data[315] + bDeviceProtocol := data[316] + bConfigurationValue := data[317] + bNumConfigurations := data[318] + bNumInterfaces := data[319] + + p.logger.Info("USBIP packet", + "dir", dirString(clientToServer), + "op", "OP_REP_IMPORT", + "status", status, + "path", pathStr, + "busid", busidStr, + "bus", busnum, + "dev", devnum, + "speed", speed, + "vid", fmt.Sprintf("%04x", idVendor), + "pid", fmt.Sprintf("%04x", idProduct), + "bcd", fmt.Sprintf("%04x", bcdDevice), + "class", fmt.Sprintf("%02x", bDeviceClass), + "subclass", fmt.Sprintf("%02x", bDeviceSubClass), + "protocol", fmt.Sprintf("%02x", bDeviceProtocol), + "config", bConfigurationValue, + "nConfigs", bNumConfigurations, + "nInterfaces", bNumInterfaces) + + return 320 +} + +func dirString(clientToServer bool) string { + if clientToServer { + return "C→S" + } + return "S→C" +} + +func urbDirString(dir uint32) string { + if dir == usbip.DirOut { + return "OUT" + } + return "IN" +} diff --git a/viiper/internal/server/proxy/server.go b/internal/server/proxy/server.go similarity index 93% rename from viiper/internal/server/proxy/server.go rename to internal/server/proxy/server.go index 2c46127c..c9a85f5d 100644 --- a/viiper/internal/server/proxy/server.go +++ b/internal/server/proxy/server.go @@ -1,183 +1,189 @@ -package proxy - -import ( - "errors" - "fmt" - "io" - "log/slog" - "net" - "strings" - "sync" - "time" - "viiper/internal/log" -) - -type Server struct { - listenAddr string - upstreamAddr string - connectionTimeout time.Duration - logger *slog.Logger - rawLogger log.RawLogger - ln net.Listener -} - -func New(listenAddr, upstreamAddr string, connectionTimeout time.Duration, logger *slog.Logger, rawLogger log.RawLogger) *Server { - return &Server{ - listenAddr: listenAddr, - upstreamAddr: upstreamAddr, - connectionTimeout: connectionTimeout, - logger: logger, - rawLogger: rawLogger, - } -} - -func (s *Server) ListenAndServe() error { - ln, err := net.Listen("tcp", s.listenAddr) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", s.listenAddr, err) - } - s.ln = ln - s.logger.Info("USB-IP proxy listening", "addr", s.listenAddr) - - for { - clientConn, err := ln.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { - s.logger.Info("Proxy server stopped") - return nil - } - s.logger.Error("Accept error", "error", err) - continue - } - s.logger.Info("Client connected", "remote", clientConn.RemoteAddr()) - go s.handleProxy(clientConn) - } -} - -func (s *Server) Close() error { - if s.ln != nil { - return s.ln.Close() - } - return nil -} - -func (s *Server) handleProxy(clientConn net.Conn) { - defer clientConn.Close() - - upstreamConn, err := net.DialTimeout("tcp", s.upstreamAddr, s.connectionTimeout) - if err != nil { - s.logger.Error("Failed to connect to upstream", "upstream", s.upstreamAddr, "error", err) - return - } - defer upstreamConn.Close() - - s.logger.Info("Proxying connection", "client", clientConn.RemoteAddr(), "upstream", upstreamConn.RemoteAddr()) - - err = clientConn.SetDeadline(time.Now().Add(s.connectionTimeout)) - if err != nil { - s.logger.Error("Failed to set client deadline", "error", err) - return - } - err = upstreamConn.SetDeadline(time.Now().Add(s.connectionTimeout)) - if err != nil { - s.logger.Error("Failed to set upstream deadline", "error", err) - return - } - - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - bytes, err := s.copyWithLogging(upstreamConn, clientConn, true) - if err != nil && !isExpectedDisconnect(err) { - s.logger.Debug("Client->Server copy error", "error", err) - } - s.logger.Debug("Client->Server stream ended", "bytes", bytes) - halfClose(upstreamConn, true) - halfClose(clientConn, false) - }() - - go func() { - defer wg.Done() - bytes, err := s.copyWithLogging(clientConn, upstreamConn, false) - if err != nil && !isExpectedDisconnect(err) { - s.logger.Debug("Server->Client copy error", "error", err) - } - s.logger.Debug("Server->Client stream ended", "bytes", bytes) - halfClose(clientConn, true) - halfClose(upstreamConn, false) - }() - - wg.Wait() - s.logger.Info("Connection closed", "client", clientConn.RemoteAddr()) -} - -func (s *Server) copyWithLogging(dst net.Conn, src net.Conn, clientToServer bool) (int64, error) { - buf := make([]byte, 32*1024) - var total int64 - parser := NewParser(s.logger) - firstPacket := true - - for { - n, rerr := src.Read(buf) - if n > 0 { - s.rawLogger.Log(clientToServer, buf[:n]) - - parser.Parse(buf[:n], clientToServer) - - if firstPacket { - err := src.SetDeadline(time.Time{}) - if err != nil { - s.logger.Error("Failed to clear source deadline", "error", err) - return total, err - } - err = dst.SetDeadline(time.Time{}) - if err != nil { - s.logger.Error("Failed to clear destination deadline", "error", err) - return total, err - } - firstPacket = false - } - - wn, werr := dst.Write(buf[:n]) - total += int64(wn) - if werr != nil { - return total, werr - } - if wn != n { - return total, fmt.Errorf("short write: wrote %d of %d", wn, n) - } - } - - if rerr != nil { - if ne, ok := rerr.(net.Error); ok && ne.Timeout() { - continue - } - if rerr == io.EOF { - return total, nil - } - return total, rerr - } - } -} - -func halfClose(conn net.Conn, write bool) { - if tc, ok := conn.(*net.TCPConn); ok { - if write { - _ = tc.CloseWrite() - } else { - _ = tc.CloseRead() - } - } -} - -func isExpectedDisconnect(err error) bool { - if err == nil || err == io.EOF { - return true - } - e := strings.ToLower(err.Error()) - return strings.Contains(e, "connection reset") || - strings.Contains(e, "broken pipe") || - strings.Contains(e, "forcibly closed") -} +package proxy + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net" + "strings" + "sync" + "time" + + "github.com/Alia5/VIIPER/internal/log" +) + +type Server struct { + listenAddr string + upstreamAddr string + connectionTimeout time.Duration + logger *slog.Logger + rawLogger log.RawLogger + ln net.Listener +} + +func New(listenAddr, upstreamAddr string, connectionTimeout time.Duration, logger *slog.Logger, rawLogger log.RawLogger) *Server { + return &Server{ + listenAddr: listenAddr, + upstreamAddr: upstreamAddr, + connectionTimeout: connectionTimeout, + logger: logger, + rawLogger: rawLogger, + } +} + +func (s *Server) ListenAndServe() error { + ln, err := net.Listen("tcp", s.listenAddr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", s.listenAddr, err) + } + s.ln = ln + s.logger.Info("USB-IP proxy listening", "addr", s.listenAddr) + + for { + clientConn, err := ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { + s.logger.Info("Proxy server stopped") + return nil + } + s.logger.Error("Accept error", "error", err) + continue + } + if tcpConn, ok := clientConn.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + s.logger.Warn("failed to set TCP_NODELAY", "error", err) + } + } + s.logger.Info("Client connected", "remote", clientConn.RemoteAddr()) + go s.handleProxy(clientConn) + } +} + +func (s *Server) Close() error { + if s.ln != nil { + return s.ln.Close() + } + return nil +} + +func (s *Server) handleProxy(clientConn net.Conn) { + defer clientConn.Close() //nolint:errcheck + + upstreamConn, err := net.DialTimeout("tcp", s.upstreamAddr, s.connectionTimeout) + if err != nil { + s.logger.Error("Failed to connect to upstream", "upstream", s.upstreamAddr, "error", err) + return + } + defer upstreamConn.Close() //nolint:errcheck + + s.logger.Info("Proxying connection", "client", clientConn.RemoteAddr(), "upstream", upstreamConn.RemoteAddr()) + + err = clientConn.SetDeadline(time.Now().Add(s.connectionTimeout)) + if err != nil { + s.logger.Error("Failed to set client deadline", "error", err) + return + } + err = upstreamConn.SetDeadline(time.Now().Add(s.connectionTimeout)) + if err != nil { + s.logger.Error("Failed to set upstream deadline", "error", err) + return + } + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + bytes, err := s.copyWithLogging(upstreamConn, clientConn, true) + if err != nil && !isExpectedDisconnect(err) { + s.logger.Debug("Client->Server copy error", "error", err) + } + s.logger.Debug("Client->Server stream ended", "bytes", bytes) + halfClose(upstreamConn, true) + halfClose(clientConn, false) + }() + + go func() { + defer wg.Done() + bytes, err := s.copyWithLogging(clientConn, upstreamConn, false) + if err != nil && !isExpectedDisconnect(err) { + s.logger.Debug("Server->Client copy error", "error", err) + } + s.logger.Debug("Server->Client stream ended", "bytes", bytes) + halfClose(clientConn, true) + halfClose(upstreamConn, false) + }() + + wg.Wait() + s.logger.Info("Connection closed", "client", clientConn.RemoteAddr()) +} + +func (s *Server) copyWithLogging(dst net.Conn, src net.Conn, clientToServer bool) (int64, error) { + buf := make([]byte, 32*1024) + var total int64 + parser := NewParser(s.logger) + firstPacket := true + + for { + n, rerr := src.Read(buf) + if n > 0 { + s.rawLogger.Log(clientToServer, buf[:n]) + + parser.Parse(buf[:n], clientToServer) + + if firstPacket { + err := src.SetDeadline(time.Time{}) + if err != nil { + s.logger.Error("Failed to clear source deadline", "error", err) + return total, err + } + err = dst.SetDeadline(time.Time{}) + if err != nil { + s.logger.Error("Failed to clear destination deadline", "error", err) + return total, err + } + firstPacket = false + } + + wn, werr := dst.Write(buf[:n]) + total += int64(wn) + if werr != nil { + return total, werr + } + if wn != n { + return total, fmt.Errorf("short write: wrote %d of %d", wn, n) + } + } + + if rerr != nil { + if ne, ok := rerr.(net.Error); ok && ne.Timeout() { + continue + } + if rerr == io.EOF { + return total, nil + } + return total, rerr + } + } +} + +func halfClose(conn net.Conn, write bool) { + if tc, ok := conn.(*net.TCPConn); ok { + if write { + _ = tc.CloseWrite() + } else { + _ = tc.CloseRead() + } + } +} + +func isExpectedDisconnect(err error) bool { + if err == nil || err == io.EOF { + return true + } + e := strings.ToLower(err.Error()) + return strings.Contains(e, "connection reset") || + strings.Contains(e, "broken pipe") || + strings.Contains(e, "forcibly closed") +} diff --git a/internal/server/usb/config.go b/internal/server/usb/config.go new file mode 100644 index 00000000..1a417a83 --- /dev/null +++ b/internal/server/usb/config.go @@ -0,0 +1,11 @@ +package usb + +import "time" + +// ServerConfig represents the server subcommand configuration. +type ServerConfig struct { + Addr string `help:"USB-IP server listen address" default:":3241" env:"VIIPER_USB_ADDR"` + ConnectionTimeout time.Duration `kong:"-"` + BusCleanupTimeout time.Duration `help:"-"` + WriteBatchFlushInterval time.Duration `default:"0" help:"Interval to flush write batches to clients; default: disabled / immediate updates" env:"VIIPER_USB_WRITE_BATCH_FLUSH_INTERVAL"` +} diff --git a/internal/server/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go new file mode 100644 index 00000000..c8df3b40 --- /dev/null +++ b/internal/server/usb/descriptor_test.go @@ -0,0 +1,309 @@ +package usb + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "log/slog" + "net" + "testing" + "time" + + usbdesc "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/virtualbus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type altSettingTestDevice struct { + desc *usbdesc.Descriptor + altEvents [][2]uint8 + transferCalls int +} + +type controlLifecycleTestDevice struct { + *altSettingTestDevice + controlCalls int + resetEndpoints []uint8 +} + +func (d *controlLifecycleTestDevice) HandleControl( + uint8, uint8, uint16, uint16, uint16, []byte, +) ([]byte, bool) { + d.controlCalls++ + return nil, false +} + +func (d *controlLifecycleTestDevice) ResetEndpoint(endpointAddress uint8) { + d.resetEndpoints = append(d.resetEndpoints, endpointAddress) +} + +func (d *altSettingTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + d.transferCalls++ + return nil +} + +func (d *altSettingTestDevice) GetDescriptor() *usbdesc.Descriptor { + return d.desc +} + +func (d *altSettingTestDevice) GetDeviceSpecificArgs() map[string]any { + return nil +} + +func (d *altSettingTestDevice) SetInterfaceAltSetting(iface, alt uint8) { + d.altEvents = append(d.altEvents, [2]uint8{iface, alt}) +} + +func TestBuildConfigDescriptorSupportsIADAndAlternateSettings(t *testing.T) { + desc := &usbdesc.Descriptor{ + Configuration: usbdesc.ConfigurationDescriptor{ + BConfigurationValue: 0x01, + IConfiguration: 0x04, + BMAttributes: 0xC0, + BMaxPower: 0xFA, + }, + Associations: []usbdesc.InterfaceAssociationDescriptor{ + {BFirstInterface: 0, BInterfaceCount: 1, BFunctionClass: 0x03}, + {BFirstInterface: 1, BInterfaceCount: 2, BFunctionClass: 0x01, BFunctionSubClass: 0x01}, + }, + Interfaces: []usbdesc.InterfaceConfig{ + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 0, BNumEndpoints: 1, BInterfaceClass: 0x03, + }, + Endpoints: []usbdesc.EndpointDescriptor{{BEndpointAddress: 0x81, BMAttributes: 0x03, WMaxPacketSize: 64, BInterval: 4}}, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 1, BAlternateSetting: 0, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 1, BAlternateSetting: 1, BNumEndpoints: 1, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x03, + BMAttributes: 0x0D, + WMaxPacketSize: 0x00C0, + BInterval: 1, + Trailing: usbdesc.Data{0x00, 0x00}, + ClassDescriptors: []usbdesc.ClassSpecificDescriptor{ + {DescriptorType: 0x25, Payload: usbdesc.Data{0x01, 0x00, 0x00, 0x00, 0x00}}, + }, + }}, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 0, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + }, + } + + got := (&Server{}).buildConfigDescriptor(desc) + require.NotEmpty(t, got) + assert.Equal(t, uint16(len(got)), binary.LittleEndian.Uint16(got[2:4])) + assert.Equal(t, byte(3), got[4]) + assert.Equal(t, byte(0x01), got[5]) + assert.Equal(t, byte(0x04), got[6]) + assert.Equal(t, byte(0xC0), got[7]) + assert.Equal(t, byte(0xFA), got[8]) + + assert.Equal(t, []byte{0x08, 0x0B, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00}, got[9:17]) + assert.True(t, bytes.Contains(got, []byte{0x09, 0x05, 0x03, 0x0D, 0xC0, 0x00, 0x01, 0x00, 0x00})) + assert.True(t, bytes.Contains(got, []byte{0x07, 0x25, 0x01, 0x00, 0x00, 0x00, 0x00})) +} + +func TestProcessSubmitTracksInterfaceAlternateSetting(t *testing.T) { + desc := &usbdesc.Descriptor{ + Interfaces: []usbdesc.InterfaceConfig{ + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 0, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BNumEndpoints: 1, BInterfaceClass: 0x01, BInterfaceSubClass: 0x02, + }, + }, + }, + } + dev := &altSettingTestDevice{desc: desc} + server := New(ServerConfig{}, nil, nil) + + getAlt := []byte{0x81, usbReqGetInterface, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00} + setAltOne := []byte{0x01, usbReqSetInterface, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00} + setConfig := []byte{0x00, usbReqSetConfiguration, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00} + + assert.Equal(t, []byte{0x00}, server.processSubmit(context.Background(), dev, 0, 0, getAlt, nil)) + server.processSubmit(context.Background(), dev, 0, 0, setAltOne, nil) + assert.Equal(t, []byte{0x01}, server.processSubmit(context.Background(), dev, 0, 0, getAlt, nil)) + assert.Equal(t, [][2]uint8{{2, 1}}, dev.altEvents) + server.processSubmit(context.Background(), dev, 0, 0, setConfig, nil) + assert.Equal(t, []byte{0x00}, server.processSubmit(context.Background(), dev, 0, 0, getAlt, nil)) + assert.Equal(t, [][2]uint8{{2, 1}, {2, 0}}, dev.altEvents) +} + +func TestProcessSubmitResolvesLogicalHIDInterfaceBeforeDeviceDispatch(t *testing.T) { + desc := &usbdesc.Descriptor{Interfaces: []usbdesc.InterfaceConfig{ + {Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 1, BAlternateSetting: 0, BInterfaceClass: 0x01, + }}, + {Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 1, BAlternateSetting: 1, BInterfaceClass: 0x01, + }}, + {Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 0, BInterfaceClass: 0x01, + }}, + {Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 3, BAlternateSetting: 0, BInterfaceClass: usbInterfaceClassHID, + }}, + }} + dev := &controlLifecycleTestDevice{altSettingTestDevice: &altSettingTestDevice{desc: desc}} + server := New(ServerConfig{}, nil, nil) + + setIdle := []byte{hidReqTypeOut, hidReqSetIdle, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00} + if response := server.processSubmit(context.Background(), dev, 0, 0, setIdle, nil); response != nil { + t.Fatalf("SET_IDLE returned unexpected payload: % x", response) + } + getIdle := []byte{hidReqTypeIn, hidReqGetIdle, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00} + if response := server.processSubmit(context.Background(), dev, 0, 0, getIdle, nil); !bytes.Equal(response, []byte{0}) { + t.Fatalf("GET_IDLE returned unexpected payload: % x", response) + } + if dev.controlCalls != 0 { + t.Fatalf("common HID requests reached controller-specific dispatch %d times", dev.controlCalls) + } +} + +func TestProcessSubmitClearFeatureResetsOnlyKnownEndpoint(t *testing.T) { + desc := &usbdesc.Descriptor{Interfaces: []usbdesc.InterfaceConfig{ + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 1, BAlternateSetting: 1, BInterfaceClass: 0x01, + }, + Endpoints: []usbdesc.EndpointDescriptor{{BEndpointAddress: 0x01, BMAttributes: 0x05}}, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, BAlternateSetting: 1, BInterfaceClass: 0x01, + }, + Endpoints: []usbdesc.EndpointDescriptor{{BEndpointAddress: 0x82, BMAttributes: 0x05}}, + }, + }} + dev := &controlLifecycleTestDevice{altSettingTestDevice: &altSettingTestDevice{desc: desc}} + server := New(ServerConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + server.setInterfaceAlt(dev, 1, 1) + server.setInterfaceAlt(dev, 2, 1) + + for _, endpoint := range []uint8{0x01, 0x82} { + clearHalt := []byte{usbReqTypeStandardToEndpoint, usbReqClearFeature, + 0x00, 0x00, endpoint, 0x00, 0x00, 0x00} + server.processSubmit(context.Background(), dev, 0, 0, clearHalt, nil) + } + if !assert.ObjectsAreEqual([]uint8{0x01, 0x82}, dev.resetEndpoints) { + t.Fatalf("known endpoint reset mismatch: % x", dev.resetEndpoints) + } + if got := server.getInterfaceAlt(dev, 1); got != 1 { + t.Fatalf("speaker endpoint reset changed interface alt setting: %d", got) + } + if got := server.getInterfaceAlt(dev, 2); got != 1 { + t.Fatalf("microphone endpoint reset changed interface alt setting: %d", got) + } + + clearUnknown := []byte{usbReqTypeStandardToEndpoint, usbReqClearFeature, + 0x00, 0x00, 0x83, 0x00, 0x00, 0x00} + server.processSubmit(context.Background(), dev, 0, 0, clearUnknown, nil) + if !assert.ObjectsAreEqual([]uint8{0x01, 0x82}, dev.resetEndpoints) { + t.Fatalf("unknown endpoint triggered reset: % x", dev.resetEndpoints) + } +} + +func TestEndpointIsIsochronousUsesDescriptorDirection(t *testing.T) { + desc := &usbdesc.Descriptor{Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{ + {BEndpointAddress: 0x82, BMAttributes: 0x05}, + {BEndpointAddress: 0x02, BMAttributes: 0x02}, + }, + }}} + + assert.True(t, endpointIsIsochronous(desc, 2, usbip.DirIn)) + assert.False(t, endpointIsIsochronous(desc, 2, usbip.DirOut)) + assert.False(t, endpointIsIsochronous(desc, 0, usbip.DirIn)) +} + +func TestUrbStreamMalformedIsoInDoesNotConsumePCMAndResetsAlternateSettings(t *testing.T) { + for i, packetCount := range []int32{-1, 0} { + name := "non_iso_marker" + if packetCount == 0 { + name = "zero_packets" + } + t.Run(name, func(t *testing.T) { + desc := &usbdesc.Descriptor{Interfaces: []usbdesc.InterfaceConfig{ + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, + BAlternateSetting: 0, + }, + }, + { + Descriptor: usbdesc.InterfaceDescriptor{ + BInterfaceNumber: 2, + BAlternateSetting: 1, + }, + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x82, + BMAttributes: 0x05, + WMaxPacketSize: 192, + BInterval: 1, + }}, + }, + }} + dev := &altSettingTestDevice{desc: desc} + bus := virtualbus.New(uint32(240 + i)) + defer bus.Close() //nolint:errcheck + _, err := bus.Add(dev) + require.NoError(t, err) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := New(ServerConfig{}, logger, nil) + require.NoError(t, server.AddBus(bus)) + server.setInterfaceAlt(dev, 2, 1) + server.notifyInterfaceAlt(dev, 2, 1) + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() //nolint:errcheck + require.NoError(t, clientConn.SetDeadline(time.Now().Add(2*time.Second))) + errCh := make(chan error, 1) + go func() { errCh <- server.handleUrbStream(serverConn, dev) }() + + cmd := usbip.CmdSubmit{ + Basic: usbip.HeaderBasic{ + Command: usbip.CmdSubmitCode, + Seqnum: 17, + Dir: usbip.DirIn, + Ep: 2, + }, + TransferBufferLen: 192, + NumberOfPackets: packetCount, + } + require.NoError(t, cmd.Write(clientConn)) + var response [retSubmitHeaderSize]byte + require.NoError(t, usbip.ReadExactly(clientConn, response[:])) + assert.Equal(t, uint32(usbip.RetSubmitCode), binary.BigEndian.Uint32(response[0:4])) + assert.Equal(t, uint32(17), binary.BigEndian.Uint32(response[4:8])) + assert.Zero(t, binary.BigEndian.Uint32(response[24:28])) + assert.Zero(t, int32(binary.BigEndian.Uint32(response[32:36]))) + assert.Zero(t, dev.transferCalls, "malformed ISO IN consumed microphone PCM") + + require.NoError(t, clientConn.Close()) + require.Error(t, <-errCh) + assert.Zero(t, server.getInterfaceAlt(dev, 2)) + assert.Equal(t, [][2]uint8{{2, 1}, {2, 0}}, dev.altEvents) + }) + } +} diff --git a/internal/server/usb/iso_test.go b/internal/server/usb/iso_test.go new file mode 100644 index 00000000..a1701042 --- /dev/null +++ b/internal/server/usb/iso_test.go @@ -0,0 +1,241 @@ +package usb + +import ( + "bytes" + "context" + "testing" + "time" + + usbdesc "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type isoInTestDevice struct { + desc *usbdesc.Descriptor + payloads [][]byte + delays []time.Duration + calls int + callTimes []time.Time +} + +func (d *isoInTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + d.callTimes = append(d.callTimes, time.Now()) + if d.calls < len(d.delays) && d.delays[d.calls] > 0 { + time.Sleep(d.delays[d.calls]) + } + payload := d.payloads[d.calls] + d.calls++ + return payload +} + +func TestBuildIsoInResponseConsumesPacketsAtServiceCadence(t *testing.T) { + desc := &usbdesc.Descriptor{ + Device: usbdesc.DeviceDescriptor{Speed: 3}, + Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x82, + BMAttributes: 0x05, + BInterval: 4, + }}, + }}, + } + dev := &isoInTestDevice{ + desc: desc, + payloads: [][]byte{ + bytes.Repeat([]byte{0x11}, 32), + bytes.Repeat([]byte{0x22}, 32), + bytes.Repeat([]byte{0x33}, 32), + bytes.Repeat([]byte{0x44}, 32), + }, + } + submitted := []usbip.IsoPacketDescriptor{ + {Offset: 0, Length: 32}, + {Offset: 32, Length: 32}, + {Offset: 64, Length: 32}, + {Offset: 96, Length: 32}, + } + + start := time.Now().Add(2 * time.Millisecond) + response, completed, _ := (&Server{}).buildIsoInResponse( + context.Background(), dev, 2, usbip.DirIn, submitted, start) + + if len(response) != 128 || len(completed) != len(submitted) { + t.Fatalf("unexpected ISO response sizes: payload=%d packets=%d", + len(response), len(completed)) + } + if len(dev.callTimes) != len(submitted) { + t.Fatalf("unexpected transfer calls: got %d want %d", + len(dev.callTimes), len(submitted)) + } + for i := range dev.callTimes { + earlierThanSchedule := start.Add(time.Duration(i) * time.Millisecond). + Sub(dev.callTimes[i]) + if earlierThanSchedule > 100*time.Microsecond { + t.Fatalf("ISO packet %d was consumed before its service slot by %s", + i, earlierThanSchedule) + } + } +} + +func (d *isoInTestDevice) GetDescriptor() *usbdesc.Descriptor { + return d.desc +} + +func (d *isoInTestDevice) GetDeviceSpecificArgs() map[string]any { + return nil +} + +func TestCompleteIsoPacketsPreservesOffsetsAndLengths(t *testing.T) { + completed := completeIsoPackets([]usbip.IsoPacketDescriptor{ + {Offset: 0, Length: 384}, + {Offset: 384, Length: 384}, + {Offset: 768, Length: 384}, + }, 900) + + if len(completed) != 3 { + t.Fatalf("unexpected completed packet count: got %d want 3", len(completed)) + } + if completed[0].ActualLength != 384 || completed[1].ActualLength != 384 || completed[2].ActualLength != 132 { + t.Fatalf("unexpected completed ISO lengths: %#v", completed) + } + for _, packet := range completed { + if packet.Status != 0 { + t.Fatalf("unexpected ISO status: %#v", packet) + } + } +} + +func TestBuildIsoInResponseCompactsPacketPadding(t *testing.T) { + const ( + packetLength = 196 + actualLength = 192 + ) + desc := &usbdesc.Descriptor{ + Device: usbdesc.DeviceDescriptor{Speed: 3}, + Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x82, + BMAttributes: 0x05, + BInterval: 4, + }}, + }}, + } + dev := &isoInTestDevice{ + desc: desc, + payloads: [][]byte{ + bytes.Repeat([]byte{0x11}, actualLength), + bytes.Repeat([]byte{0x22}, actualLength), + bytes.Repeat([]byte{0x33}, actualLength), + }, + } + submitted := []usbip.IsoPacketDescriptor{ + {Offset: 0, Length: packetLength}, + {Offset: packetLength, Length: packetLength}, + {Offset: 2 * packetLength, Length: packetLength}, + } + + response, completed, _ := (&Server{}).buildIsoInResponse( + context.Background(), dev, 2, usbip.DirIn, submitted, time.Now()) + + want := append(bytes.Repeat([]byte{0x11}, actualLength), + bytes.Repeat([]byte{0x22}, actualLength)...) + want = append(want, bytes.Repeat([]byte{0x33}, actualLength)...) + if !bytes.Equal(response, want) { + t.Fatalf("ISO IN response was not packed without descriptor padding: got %d bytes want %d", + len(response), len(want)) + } + for i, packet := range completed { + if packet.Offset != submitted[i].Offset || packet.ActualLength != actualLength { + t.Fatalf("unexpected completed ISO descriptor %d: %#v", i, packet) + } + } +} + +func TestIsoCompletionDelayUsesHighSpeedMicroframes(t *testing.T) { + desc := &usbdesc.Descriptor{ + Device: usbdesc.DeviceDescriptor{Speed: 3}, + Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x01, + BMAttributes: 0x09, + BInterval: 4, + }}, + }}, + } + + if got := isoCompletionDelay(desc, 1, 8); got != 8*time.Millisecond { + t.Fatalf("unexpected high-speed ISO delay: got %s want 8ms", got) + } +} + +func TestUSBServiceIntervalUsesHighSpeedMicroframes(t *testing.T) { + if got := usbServiceInterval(3, 6); got != 4*time.Millisecond { + t.Fatalf("unexpected high-speed interrupt interval: got %s want 4ms", got) + } +} + +func TestReanchorIsoServiceWindowDoesNotReplayExpiredSlots(t *testing.T) { + planned := time.Unix(100, 0) + now := planned.Add(7 * time.Millisecond) + start, end := reanchorIsoServiceWindow( + planned, time.Time{}, 8*time.Millisecond, time.Millisecond, now) + if !start.Equal(now) { + t.Fatalf("late service window was not re-anchored: got %s want %s", start, now) + } + if !end.Equal(now.Add(8 * time.Millisecond)) { + t.Fatalf("unexpected re-anchored service end: got %s", end) + } +} + +func TestReanchorIsoServiceWindowPreservesFutureSlot(t *testing.T) { + planned := time.Unix(100, 0) + now := planned.Add(-2 * time.Millisecond) + start, end := reanchorIsoServiceWindow( + planned, time.Time{}, 8*time.Millisecond, time.Millisecond, now) + if !start.Equal(planned) || !end.Equal(planned.Add(8*time.Millisecond)) { + t.Fatalf("future service window changed: start=%s end=%s", start, end) + } +} + +func TestReanchorIsoServiceWindowCarriesPreviousEndWithoutAddingJitter(t *testing.T) { + planned := time.Unix(100, 0) + previousEnd := planned.Add(7 * time.Millisecond) + now := previousEnd.Add(100 * time.Microsecond) + start, end := reanchorIsoServiceWindow( + planned, previousEnd, 8*time.Millisecond, time.Millisecond, now) + if !start.Equal(previousEnd) || !end.Equal(previousEnd.Add(8*time.Millisecond)) { + t.Fatalf("previous service clock was not propagated: start=%s end=%s", start, end) + } +} + +func TestBuildIsoInResponseReanchorsAfterMissedPacketSlot(t *testing.T) { + desc := &usbdesc.Descriptor{ + Device: usbdesc.DeviceDescriptor{Speed: 3}, + Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x82, + BMAttributes: 0x05, + BInterval: 4, + }}, + }}, + } + dev := &isoInTestDevice{ + desc: desc, + payloads: [][]byte{ + bytes.Repeat([]byte{0x11}, 32), + bytes.Repeat([]byte{0x22}, 32), + bytes.Repeat([]byte{0x33}, 32), + }, + delays: []time.Duration{3 * time.Millisecond}, + } + submitted := []usbip.IsoPacketDescriptor{ + {Length: 32}, {Length: 32}, {Length: 32}, + } + + _, _, _ = (&Server{}).buildIsoInResponse( + context.Background(), dev, 2, usbip.DirIn, submitted, + time.Now()) + if gap := dev.callTimes[2].Sub(dev.callTimes[1]); gap < 700*time.Microsecond { + t.Fatalf("slot after a mid-URB stall was replayed in a burst: gap=%s", gap) + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go new file mode 100644 index 00000000..deb6199c --- /dev/null +++ b/internal/server/usb/server.go @@ -0,0 +1,1693 @@ +package usb + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "net" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/virtualbus" +) + +type batchingWriter struct { + mu sync.Mutex + w *bufio.Writer + flushEvery time.Duration + flushAtBytes int + stopCh chan struct{} + closeOnce sync.Once + err error +} + +const ( + retSubmitHeaderSize = 0x30 + + // avoid windows socket overhead while keeping latency very low. + writeBatcherBufferSize = 256 * 1024 + writeBatcherFlushAtBytes = 64 * 1024 +) + +func newBatchingWriter(dst io.Writer, bufSize int, flushEvery time.Duration, flushAtBytes int) *batchingWriter { + if bufSize <= 0 { + bufSize = writeBatcherBufferSize + } + if flushAtBytes < 0 { + flushAtBytes = 0 + } + if flushAtBytes > bufSize { + flushAtBytes = bufSize + } + bw := &batchingWriter{ + w: bufio.NewWriterSize(dst, bufSize), + flushEvery: flushEvery, + flushAtBytes: flushAtBytes, + stopCh: make(chan struct{}), + } + if flushEvery > 0 { + go bw.flushLoop() + } + return bw +} + +func (b *batchingWriter) flushLoop() { + t := time.NewTicker(b.flushEvery) + defer t.Stop() + for { + select { + case <-t.C: + _ = b.Flush() + case <-b.stopCh: + return + } + } +} + +func (b *batchingWriter) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.err != nil { + return 0, b.err + } + + n, err := b.w.Write(p) + if err != nil { + b.err = err + return n, err + } + if b.flushAtBytes > 0 && b.w.Buffered() >= b.flushAtBytes { + if err := b.w.Flush(); err != nil { + b.err = err + return n, err + } + } + return n, nil +} + +func (b *batchingWriter) Flush() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.err != nil { + return b.err + } + if err := b.w.Flush(); err != nil { + b.err = err + return err + } + return nil +} + +func (b *batchingWriter) Close() error { + b.closeOnce.Do(func() { + close(b.stopCh) + }) + return b.Flush() +} + +const ( + // USB standard request codes + usbReqGetStatus = 0x00 + usbReqClearFeature = 0x01 + usbReqSetFeature = 0x03 + usbReqSetAddress = 0x05 + usbReqGetDescriptor = 0x06 + usbReqSetDescriptor = 0x07 + usbReqGetConfiguration = 0x08 + usbReqSetConfiguration = 0x09 + usbReqGetInterface = 0x0A + usbReqSetInterface = 0x0B + + // USB descriptor types + usbDescTypeDevice = 0x01 + usbDescTypeConfiguration = 0x02 + usbDescTypeString = 0x03 + usbDescTypeHID = 0x21 + usbDescTypeHIDReport = 0x22 + + // USB request types (bmRequestType) + usbReqTypeStandardToDevice = 0x00 + usbReqTypeStandardFromInterface = 0x01 + usbReqTypeStandardToEndpoint = 0x02 + usbReqTypeStandardToInterface = 0x81 + usbReqTypeStandardFromDevice = 0x80 + usbReqTypeMask = 0x60 + usbReqTypeClass = 0x20 + + // USB interface classes + usbInterfaceClassHID = 0x03 + + // HID class requests (bRequest) + hidReqGetReport = 0x01 + hidReqGetIdle = 0x02 + hidReqGetProtocol = 0x03 + hidReqSetReport = 0x09 + hidReqSetIdle = 0x0A + hidReqSetProtocol = 0x0B + + // HID class request types (bmRequestType) + hidReqTypeIn = 0xA1 + hidReqTypeOut = 0x21 + + // wIndex low-byte interface selector mask. + usbIfaceIndexMask = 0x00FF + + // USB configuration values + usbConfigValueDefault = 1 + usbConfigAttrBusPowered = 0x80 + usbConfigMaxPower100mA = 50 // In units of 2mA + + // URB header field offsets + urbHdrSize = 0x30 + urbHdrOffsetCommand = 0x00 + urbHdrOffsetSeqnum = 0x04 + urbHdrOffsetDevid = 0x08 + urbHdrOffsetDir = 0x0c + urbHdrOffsetEp = 0x10 + urbHdrOffsetUnlink = 0x14 + urbHdrOffsetFlags = 0x14 + urbHdrOffsetLength = 0x18 + urbHdrOffsetPackets = 0x20 + urbHdrOffsetSetup = 0x28 + maxIsoPackets = 1024 + + // Standard header peek size + headerPeekSize = 8 + + // BUSID buffer size for import + busIDSize = 32 + + // Error codes + errConnReset = -104 // -ECONNRESET +) + +type Server struct { + config *ServerConfig + logger *slog.Logger + rawLogger log.RawLogger + busses map[uint32]*virtualbus.VirtualBus + busesMu sync.Mutex + alts map[usb.Device]map[uint8]uint8 + altsMu sync.Mutex + ready chan struct{} + readyOnce sync.Once + ln net.Listener +} + +func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Server { + return &Server{ + config: &config, + logger: logger, + rawLogger: rawLogger, + busses: make(map[uint32]*virtualbus.VirtualBus), + alts: make(map[usb.Device]map[uint8]uint8), + ready: make(chan struct{}), + } +} + +// AddBus registers a bus with the server. If the bus number is already present, +// an error is returned. +func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { + s.busesMu.Lock() + defer s.busesMu.Unlock() + if bus == nil { + return fmt.Errorf("bus is nil") + } + if _, ok := s.busses[bus.BusID()]; ok { + return fmt.Errorf("bus %d already registered", bus.BusID()) + } + s.busses[bus.BusID()] = bus + return nil +} + +// RemoveBus unregisters a bus from the server. +func (s *Server) RemoveBus(busID uint32) error { + s.busesMu.Lock() + bus, ok := s.busses[busID] + if !ok { + s.busesMu.Unlock() + return fmt.Errorf("bus %d not found", busID) + } + + devices := bus.Devices() + s.busesMu.Unlock() + + if len(devices) > 0 { + s.logger.Warn(fmt.Sprintf("Removing non-empty bus %d with %d device(s) attached; removing devices", busID, len(devices))) + for _, dev := range devices { + _ = bus.Remove(dev) + } + } + + s.busesMu.Lock() + delete(s.busses, busID) + s.busesMu.Unlock() + + return bus.Close() +} + +// RemoveDeviceByID removes a device by busId and cancels its connections. +func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { + s.busesMu.Lock() + bus, ok := s.busses[busID] + s.busesMu.Unlock() + + if !ok { + return fmt.Errorf("bus %d not found", busID) + } + err := bus.RemoveDeviceByID(deviceID) + if err != nil { + return err + } + + if emptyCtx := bus.GetBusEmptyContext(); emptyCtx != nil { + go func() { + slog.Debug("Started bus cleanup goroutine (RemoveDeviceByID)") + select { + case <-emptyCtx.Done(): + // Cancelled - a new device was added + return + case <-time.After(s.config.BusCleanupTimeout): + if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { + if err := s.RemoveBus(busID); err != nil { + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } else { + s.logger.Info("timeout: removed empty bus", "busID", busID) + } + } + } + }() + } else { + s.logger.Debug("No bus empty context; Cleaning bus immediately") + if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { + if err := s.RemoveBus(busID); err != nil { + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } else { + s.logger.Info("timeout: removed empty bus", "busID", busID) + } + } + } + + return nil +} + +// ListBuses returns a snapshot of active bus numbers. +func (s *Server) ListBuses() []uint32 { + s.busesMu.Lock() + defer s.busesMu.Unlock() + out := make([]uint32, 0, len(s.busses)) + for k := range s.busses { + out = append(out, k) + } + return out +} + +// GetBus returns a bus by ID or nil if not present. +func (s *Server) GetBus(busID uint32) *virtualbus.VirtualBus { + s.busesMu.Lock() + defer s.busesMu.Unlock() + return s.busses[busID] +} + +func (s *Server) NextFreeBusID() uint32 { + s.busesMu.Lock() + defer s.busesMu.Unlock() + var id uint32 = 1 + for { + if _, exists := s.busses[id]; !exists { + return id + } + id++ + } +} + +func (s *Server) Addr() string { + if s.ln != nil { + return s.ln.Addr().String() + } + if s.config != nil { + return s.config.Addr + } + return "" +} + +// ListenAndServe starts the USB-IP server and handles incoming connections. +func (s *Server) ListenAndServe() error { + ln, err := net.Listen("tcp", s.config.Addr) + if err != nil { + return err + } + s.ln = ln + s.config.Addr = ln.Addr().String() + s.readyOnce.Do(func() { close(s.ready) }) + s.logger.Info("USBIP server listening", "addr", s.config.Addr) + for { + c, err := ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { + s.logger.Info("USBIP server stopped") + return nil + } + s.logger.Error("Accept error", "error", err) + continue + } + if tcpConn, ok := c.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + s.logger.Warn("failed to set TCP_NODELAY", "error", err) + } + } + s.logger.Info("Client connected", "remote", c.RemoteAddr()) + go func() { + if err := s.handleConn(c); err != nil { + if isClientDisconnect(err) { + s.logger.Info("Client disconnected", "error", err) + } else { + s.logger.Error("Connection handler error", "error", err) + } + } + }() + } +} + +// Ready returns a channel that is closed once the server has successfully bound +// to its listen address and is ready to accept connections. +func (s *Server) Ready() <-chan struct{} { return s.ready } + +// Close stops the USB server by closing its listener. +func (s *Server) Close() error { + if s.ln != nil { + return s.ln.Close() + } + return nil +} + +// GetListenPort extracts and returns the port number from the server's listen address. +func (s *Server) GetListenPort() uint16 { + addr := s.Addr() + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return 0 + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return 0 + } + return uint16(port) +} + +// -- + +func (s *Server) handleConn(conn net.Conn) error { + defer conn.Close() //nolint:errcheck + conn = &logConn{Conn: conn, s: s} + if err := conn.SetDeadline(time.Now().Add(s.config.ConnectionTimeout)); err != nil { + s.logger.Warn("Failed to set deadline", "error", err) + } + + // Peek first 8 bytes to determine management op or URB stream. + var hdrBuf [headerPeekSize]byte + if err := usbip.ReadExactly(conn, hdrBuf[:]); err != nil { + return fmt.Errorf("read header: %w", err) + } + + ver := binary.BigEndian.Uint16(hdrBuf[0:2]) + code := binary.BigEndian.Uint16(hdrBuf[2:4]) + + if ver == usbip.Version && (code == usbip.OpReqDevlist || code == usbip.OpReqImport) { + switch code { + case usbip.OpReqDevlist: + s.logger.Info("OP_REQ_DEVLIST") + return s.handleDevList(conn) + case usbip.OpReqImport: + s.logger.Info("OP_REQ_IMPORT") + dev, err := s.handleImport(conn) + if err != nil { + return fmt.Errorf("handle import: %w", err) + } + return s.handleUrbStream(conn, dev) + } + } + + return fmt.Errorf("protocol violation: client sent URB data without OP_REQ_IMPORT") +} + +func (s *Server) handleDevList(conn net.Conn) error { + _ = conn.SetDeadline(time.Time{}) + var buf bytes.Buffer + rep := usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpRepDevlist, Status: 0} + _ = rep.Write(&buf) + metas := s.getAllDeviceMetas() + n := uint32(len(metas)) + dlh := usbip.DevListReplyHeader{NDevices: n} + _ = dlh.Write(&buf) + for _, m := range metas { + desc := m.Dev.GetDescriptor() + meta := m.Meta + + exp := usbip.ExportedDevice{ + ExportMeta: meta, + Speed: desc.Device.Speed, + IDVendor: desc.Device.IDVendor, + IDProduct: desc.Device.IDProduct, + BcdDevice: desc.Device.BcdDevice, + BDeviceClass: desc.Device.BDeviceClass, + BDeviceSubClass: desc.Device.BDeviceSubClass, + BDeviceProtocol: desc.Device.BDeviceProtocol, + BConfigurationValue: usbConfigValueDefault, + BNumConfigurations: desc.Device.BNumConfigurations, + BNumInterfaces: desc.NumInterfaces(), + } + + for _, iface := range descriptorListInterfaces(desc) { + exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ + Class: iface.Descriptor.BInterfaceClass, + SubClass: iface.Descriptor.BInterfaceSubClass, + Protocol: iface.Descriptor.BInterfaceProtocol, + }) + } + _ = exp.WriteDevlist(&buf) + } + if _, err := conn.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write devlist: %w", err) + } + return nil +} + +func (s *Server) handleImport(conn net.Conn) (usb.Device, error) { + var rest [busIDSize]byte + if err := usbip.ReadExactly(conn, rest[:]); err != nil { + return nil, fmt.Errorf("read import busid: %w", err) + } + reqBus := string(rest[:bytes.IndexByte(rest[:], 0)]) + s.logger.Info("Import request", "busid", reqBus) + var chosen usb.Device + var chosenMeta *usbip.ExportMeta + var chosenDesc *usb.Descriptor + for _, m := range s.getAllDeviceMetas() { + meta := m.Meta + end := bytes.IndexByte(meta.USBBusID[:], 0) + bid := string(meta.USBBusID[:end]) + if bid == reqBus { + chosen = m.Dev + chosenMeta = &meta + chosenDesc = m.Dev.GetDescriptor() + break + } + } + if chosen == nil || chosenMeta == nil || chosenDesc == nil { + return nil, fmt.Errorf("no device matches busid %s", reqBus) + } + var buf bytes.Buffer + rep := usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpRepImport, Status: 0} + _ = rep.Write(&buf) + exp := usbip.ExportedDevice{ + ExportMeta: *chosenMeta, + Speed: chosenDesc.Device.Speed, + IDVendor: chosenDesc.Device.IDVendor, + IDProduct: chosenDesc.Device.IDProduct, + BcdDevice: chosenDesc.Device.BcdDevice, + BDeviceClass: chosenDesc.Device.BDeviceClass, + BDeviceSubClass: chosenDesc.Device.BDeviceSubClass, + BDeviceProtocol: chosenDesc.Device.BDeviceProtocol, + BConfigurationValue: usbConfigValueDefault, + BNumConfigurations: chosenDesc.Device.BNumConfigurations, + BNumInterfaces: chosenDesc.NumInterfaces(), + } + for _, iface := range descriptorListInterfaces(chosenDesc) { + exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ + Class: iface.Descriptor.BInterfaceClass, + SubClass: iface.Descriptor.BInterfaceSubClass, + Protocol: iface.Descriptor.BInterfaceProtocol, + }) + } + _ = exp.WriteImport(&buf) + if _, err := conn.Write(buf.Bytes()); err != nil { + return nil, fmt.Errorf("write import reply failed: %w", err) + } + return chosen, nil +} + +// getAllDeviceMetas aggregates device metas from all registered busses. +func (s *Server) getAllDeviceMetas() []virtualbus.DeviceMeta { + s.busesMu.Lock() + defer s.busesMu.Unlock() + out := []virtualbus.DeviceMeta{} + for _, b := range s.busses { + out = append(out, b.GetAllDeviceMetas()...) + } + return out +} + +type logConn struct { + net.Conn + s *Server +} + +func (lc *logConn) Read(p []byte) (int, error) { + n, err := lc.Conn.Read(p) + if n > 0 && lc.s.rawLogger != nil { + lc.s.rawLogger.Log(true, p[:n]) + } + return n, err +} + +func (lc *logConn) Write(p []byte) (int, error) { + n, err := lc.Conn.Write(p) + if n > 0 && lc.s.rawLogger != nil { + lc.s.rawLogger.Log(false, p[:n]) + } + return n, err +} + +func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { + _ = conn.SetDeadline(time.Time{}) + // Alternate settings belong to this USB/IP attachment, not to the lifetime + // of the virtual device. Windows does not necessarily send SET_INTERFACE 0 + // before an abrupt detach, so always return every interface to alt 0 when + // this URB stream ends. + defer s.resetInterfaceAlts(dev) + + var writer io.Writer + var bw *batchingWriter + if s.config.WriteBatchFlushInterval > 0 { + bw = newBatchingWriter(conn, writeBatcherBufferSize, s.config.WriteBatchFlushInterval, writeBatcherFlushAtBytes) + writer = bw + defer func() { _ = bw.Close() }() + } else { + writer = conn + } + + var owningBus *virtualbus.VirtualBus + for _, b := range s.busses { + devices := b.Devices() + if slices.Contains(devices, dev) { + owningBus = b + } + if owningBus != nil { + break + } + } + if owningBus == nil { + return fmt.Errorf("device does not belong to any bus") + } + + ctx := owningBus.GetDeviceContext(dev) + if ctx == nil { + return fmt.Errorf("no device context available from bus") + } + + var writeMu sync.Mutex + var retOut bytes.Buffer + retOut.Grow(retSubmitHeaderSize) + writeRet := func(seq, actualLen uint32, respData []byte, isoPackets []usbip.IsoPacketDescriptor, isIso bool, flush bool) error { + writeMu.Lock() + defer writeMu.Unlock() + packetCount := int32(-1) + if isIso { + packetCount = int32(len(isoPackets)) + } + ret := usbip.RetSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.RetSubmitCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, + Status: 0, + ActualLength: actualLen, + StartFrame: 0, + NumberOfPackets: packetCount, + ErrorCount: 0, + } + retOut.Reset() + if err := ret.Write(&retOut); err != nil { + return fmt.Errorf("build RET_SUBMIT header: %w", err) + } + if _, err := writer.Write(retOut.Bytes()); err != nil { + return fmt.Errorf("write RET_SUBMIT: %w", err) + } + if len(respData) > 0 { + if _, err := writer.Write(respData); err != nil { + return fmt.Errorf("write RET_SUBMIT payload: %w", err) + } + } + for _, packet := range isoPackets { + if err := packet.Write(writer); err != nil { + return fmt.Errorf("write RET_SUBMIT ISO packet descriptor: %w", err) + } + } + if flush && bw != nil { + if err := bw.Flush(); err != nil { + return fmt.Errorf("flush response: %w", err) + } + } + return nil + } + + var pendingMu sync.Mutex + pending := map[uint32]context.CancelFunc{} + defer func() { + pendingMu.Lock() + for _, cancel := range pending { + cancel() + } + pendingMu.Unlock() + }() + + var respMu sync.Mutex + lastInResp := map[uint32][]byte{} + + var outPayloadScratch []byte + // Windows keeps several ISO-IN URBs outstanding. Preserve submission order + // independently for each endpoint while still accepting unrelated URBs. + // Each job owns a planned USB service window so TCP write time cannot make + // the virtual microphone clock drift slower on every completion. + type isoInSchedule struct { + tail <-chan time.Time + nextStart time.Time + } + completedIsoIn := make(chan time.Time, 1) + completedIsoIn <- time.Time{} + close(completedIsoIn) + isoInSchedules := make(map[uint32]isoInSchedule) + var isoAudioWindowStarted time.Time + var isoAudioLastCompletion time.Time + var isoAudioURBs int + var isoAudioPackets int + var isoAudioBytes int + var isoAudioTransferDuration time.Duration + var isoAudioScheduledWait time.Duration + var isoAudioActualWait time.Duration + var isoAudioProcessing time.Duration + var isoAudioWaitOverruns int + var isoAudioMaximumWaitOverrun time.Duration + var isoAudioMaximumCompletionGap time.Duration + var isoAudioMaximumDeadlineLateness time.Duration + var nextIsoOutCompletion time.Time + logIsoAudioWindow := func(ep uint32, payloadBytes, packetCount int, + transferDuration, scheduledWait, actualWait, processing, deadlineLateness time.Duration) { + now := time.Now() + if isoAudioWindowStarted.IsZero() { + isoAudioWindowStarted = now + } + if !isoAudioLastCompletion.IsZero() { + gap := now.Sub(isoAudioLastCompletion) + if gap > isoAudioMaximumCompletionGap { + isoAudioMaximumCompletionGap = gap + } + } + isoAudioLastCompletion = now + isoAudioURBs++ + isoAudioPackets += packetCount + isoAudioBytes += payloadBytes + isoAudioTransferDuration += transferDuration + isoAudioScheduledWait += scheduledWait + isoAudioActualWait += actualWait + isoAudioProcessing += processing + if actualWait > scheduledWait { + overrun := actualWait - scheduledWait + isoAudioWaitOverruns++ + if overrun > isoAudioMaximumWaitOverrun { + isoAudioMaximumWaitOverrun = overrun + } + } + if deadlineLateness > isoAudioMaximumDeadlineLateness { + isoAudioMaximumDeadlineLateness = deadlineLateness + } + + elapsed := now.Sub(isoAudioWindowStarted) + if elapsed < 5*time.Second { + return + } + + framesPerSecond := float64(isoAudioBytes/8) / elapsed.Seconds() + s.logger.Debug("DualSense ISO audio pacing", + "ep", ep, + "urbs", isoAudioURBs, + "isoPackets", isoAudioPackets, + "bytes", isoAudioBytes, + "framesPerSecond", fmt.Sprintf("%.1f", framesPerSecond), + "transferDurationMs", fmt.Sprintf("%.3f", float64(isoAudioTransferDuration.Microseconds())/1000.0), + "scheduledWaitMs", fmt.Sprintf("%.3f", float64(isoAudioScheduledWait.Microseconds())/1000.0), + "actualWaitMs", fmt.Sprintf("%.3f", float64(isoAudioActualWait.Microseconds())/1000.0), + "processingMs", fmt.Sprintf("%.3f", float64(isoAudioProcessing.Microseconds())/1000.0), + "waitOverruns", isoAudioWaitOverruns, + "maxWaitOverrunMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumWaitOverrun.Microseconds())/1000.0), + "maxCompletionGapMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumCompletionGap.Microseconds())/1000.0), + "maxDeadlineLatenessMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumDeadlineLateness.Microseconds())/1000.0)) + + isoAudioWindowStarted = now + isoAudioLastCompletion = now + isoAudioURBs = 0 + isoAudioPackets = 0 + isoAudioBytes = 0 + isoAudioTransferDuration = 0 + isoAudioScheduledWait = 0 + isoAudioActualWait = 0 + isoAudioProcessing = 0 + isoAudioWaitOverruns = 0 + isoAudioMaximumWaitOverrun = 0 + isoAudioMaximumCompletionGap = 0 + isoAudioMaximumDeadlineLateness = 0 + } + + for { + select { + case <-ctx.Done(): + s.logger.Info("device removed, closing URB stream") + busID := owningBus.BusID() + if emptyCtx := owningBus.GetBusEmptyContext(); emptyCtx != nil { + go func() { + slog.Debug("Started bus cleanup goroutine (HandleUrbStream ctx.Done)") + select { + case <-emptyCtx.Done(): + // Cancelled - a new device was added + return + case <-time.After(s.config.BusCleanupTimeout): + if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { + if err := s.RemoveBus(busID); err != nil { + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } else { + s.logger.Info("timeout: removed empty bus", "busID", busID) + } + } + } + }() + } else { + s.logger.Debug("No bus empty context; Cleaning bus immediately") + if b := s.GetBus(busID); b != nil && len(b.Devices()) == 0 { + if err := s.RemoveBus(busID); err != nil { + s.logger.Error("timeout: failed to remove empty bus", "busID", busID, "error", err) + } else { + s.logger.Info("timeout: removed empty bus", "busID", busID) + } + } + } + return nil + default: + } + + var hdr [urbHdrSize]byte + if err := usbip.ReadExactly(conn, hdr[:]); err != nil { + return fmt.Errorf("read URB header: %w", err) + } + cmd := binary.BigEndian.Uint32(hdr[urbHdrOffsetCommand : urbHdrOffsetCommand+4]) + seq := binary.BigEndian.Uint32(hdr[urbHdrOffsetSeqnum : urbHdrOffsetSeqnum+4]) + dir := binary.BigEndian.Uint32(hdr[urbHdrOffsetDir : urbHdrOffsetDir+4]) + ep := binary.BigEndian.Uint32(hdr[urbHdrOffsetEp : urbHdrOffsetEp+4]) + if cmd == usbip.CmdUnlinkCode { + unlinkSeq := binary.BigEndian.Uint32(hdr[urbHdrOffsetUnlink : urbHdrOffsetUnlink+4]) + s.logger.Debug("USBIP_CMD_UNLINK", "seq", seq, "unlink", unlinkSeq) + pendingMu.Lock() + cancel, found := pending[unlinkSeq] + if found { + delete(pending, unlinkSeq) + } + pendingMu.Unlock() + // -ECONNRESET signals the URB was unlinked before completion; + // status 0 means it already completed normally. + status := int32(0) + if found { + cancel() + status = errConnReset + } + ret := usbip.RetUnlink{Basic: usbip.HeaderBasic{Command: usbip.RetUnlinkCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, Status: status} + writeMu.Lock() + _ = ret.Write(writer) + if bw != nil { + _ = bw.Flush() + } + writeMu.Unlock() + continue + } + if cmd != usbip.CmdSubmitCode { + devid := binary.BigEndian.Uint32(hdr[urbHdrOffsetDevid : urbHdrOffsetDevid+4]) + return fmt.Errorf("unsupported cmd %d (seq=%d, devid=%d)", cmd, seq, devid) + } + xferLen := binary.BigEndian.Uint32(hdr[urbHdrOffsetLength : urbHdrOffsetLength+4]) + packetCountWire := int32(binary.BigEndian.Uint32(hdr[urbHdrOffsetPackets : urbHdrOffsetPackets+4])) + if packetCountWire < -1 || packetCountWire > maxIsoPackets { + return fmt.Errorf("invalid ISO packet count %d", packetCountWire) + } + // NumberOfPackets == -1 is normally the USB/IP marker for a + // non-isochronous URB. Do not trust that marker for endpoints whose USB + // descriptor says they are isochronous: a malformed microphone URB must + // never fall through to the generic IN path and consume queued PCM. + descriptorIsoIn := dir == usbip.DirIn && endpointIsIsochronous( + dev.GetDescriptor(), ep, dir) + isIso := packetCountWire >= 0 || descriptorIsoIn + setup := hdr[urbHdrOffsetSetup:urbHdrSize] + + var outPayload []byte + if dir == usbip.DirOut && xferLen > 0 { + if cap(outPayloadScratch) < int(xferLen) { + outPayloadScratch = make([]byte, xferLen) + } + outPayload = outPayloadScratch[:xferLen] + if err := usbip.ReadExactly(conn, outPayload); err != nil { + return fmt.Errorf("read OUT payload: %w", err) + } + } + + var isoPackets []usbip.IsoPacketDescriptor + if isIso && packetCountWire > 0 { + isoPackets = make([]usbip.IsoPacketDescriptor, packetCountWire) + for i := range isoPackets { + if err := isoPackets[i].Read(conn); err != nil { + return fmt.Errorf("read ISO packet descriptor %d: %w", i, err) + } + } + } + + if dir == usbip.DirIn && ep != 0 { + // An ISO IN request without packet descriptors has nowhere to place a + // service packet. Complete it empty (and explicitly as ISO) without + // calling HandleTransfer, which would otherwise drain microphone data + // ahead of the host's audio clock. + if isIso && len(isoPackets) == 0 { + if err := writeRet(seq, 0, nil, nil, true, true); err != nil { + return err + } + continue + } + + urbCtx, urbCancel := context.WithCancel(ctx) + pendingMu.Lock() + pending[seq] = urbCancel + pendingMu.Unlock() + interval := endpointInterval(dev.GetDescriptor(), ep) + + var previousIsoIn <-chan time.Time + var isoInDone chan time.Time + var isoServiceStart time.Time + var isoServiceEnd time.Time + var isoTransferDuration time.Duration + var isoPacketDuration time.Duration + if isIso && len(isoPackets) > 0 { + isoTransferDuration = isoCompletionDelay( + dev.GetDescriptor(), ep, len(isoPackets)) + isoPacketDuration = isoPacketInterval(dev.GetDescriptor(), ep) + schedule, found := isoInSchedules[ep] + if !found { + schedule.tail = completedIsoIn + } + previousIsoIn = schedule.tail + isoInDone = make(chan time.Time, 1) + now := time.Now() + isoServiceStart = schedule.nextStart + if isoServiceStart.IsZero() || + now.Sub(isoServiceStart) > isoTransferDuration { + isoServiceStart = now + } + isoServiceEnd = isoServiceStart.Add(isoTransferDuration) + isoInSchedules[ep] = isoInSchedule{ + tail: isoInDone, nextStart: isoServiceEnd, + } + } + go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool, + previousIsoIn <-chan time.Time, isoInDone chan time.Time, + isoServiceStart time.Time, + isoTransferDuration, isoPacketDuration time.Duration) { + defer urbCancel() + var respData []byte + var completedPackets []usbip.IsoPacketDescriptor + if iso && len(submitted) > 0 { + var signalNextOnce sync.Once + signalNext := func(serviceEnd time.Time) { + signalNextOnce.Do(func() { + isoInDone <- serviceEnd + close(isoInDone) + }) + } + // Cancellation must never strand a later URB behind this one. + defer func() { signalNext(time.Now()) }() + var previousServiceEnd time.Time + select { + case <-urbCtx.Done(): + pendingMu.Lock() + delete(pending, seq) + pendingMu.Unlock() + return + case previousServiceEnd = <-previousIsoIn: + } + + // RET_SUBMIT delivery is not part of the USB service clock. If a + // prior socket write or scheduler slice was late, do not replay + // expired service slots in a burst and drain future microphone PCM. + isoServiceStart, _ = reanchorIsoServiceWindow( + isoServiceStart, previousServiceEnd, isoTransferDuration, + isoPacketDuration, time.Now()) + respData, completedPackets, isoServiceEnd := s.buildIsoInResponse( + urbCtx, dev, ep, dir, submitted, isoServiceStart) + if urbCtx.Err() != nil { + pendingMu.Lock() + delete(pending, seq) + pendingMu.Unlock() + return + } + if isoTransferDuration > 0 && !waitUntilContext( + urbCtx, isoServiceEnd) { + pendingMu.Lock() + delete(pending, seq) + pendingMu.Unlock() + return + } + // Release the next endpoint service window before serializing this + // completion to the USB/IP socket. A congested response writer must + // not stall or bunch the microphone sampling clock. + signalNext(isoServiceEnd) + + pendingMu.Lock() + delete(pending, seq) + pendingMu.Unlock() + + if err := writeRet(seq, uint32(len(respData)), respData, completedPackets, iso, true); err != nil { + if isClientDisconnect(err) { + s.logger.Debug("URB ISO-IN completion after disconnect", "seq", seq, "error", err) + } else { + s.logger.Error("write async ISO-IN RET_SUBMIT", "seq", seq, "error", err) + } + } + return + } + + for { + attemptCtx, attemptCancel := urbCtx, context.CancelFunc(func() {}) + if interval > 0 { + attemptCtx, attemptCancel = context.WithTimeout(urbCtx, interval) + } + respData = s.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + expired := respData == nil && errors.Is(attemptCtx.Err(), context.DeadlineExceeded) + attemptCancel() + + if urbCtx.Err() != nil { + return + } + if respData != nil { + respMu.Lock() + lastInResp[ep] = append([]byte(nil), respData...) + respMu.Unlock() + break + } + if expired { + respMu.Lock() + cached, ok := lastInResp[ep] + respMu.Unlock() + if ok { + respData = cached + break + } + continue + } + // Device answered "no data" without blocking. + break + } + + pendingMu.Lock() + delete(pending, seq) + pendingMu.Unlock() + + completedPackets = completeIsoPackets(submitted, uint32(len(respData))) + if err := writeRet(seq, uint32(len(respData)), respData, completedPackets, iso, true); err != nil { + if isClientDisconnect(err) { + s.logger.Debug("URB completion after disconnect", "seq", seq, "error", err) + } else { + s.logger.Error("write async RET_SUBMIT", "seq", seq, "error", err) + } + } + }(seq, ep, dir, isoPackets, isIso, previousIsoIn, isoInDone, + isoServiceStart, isoTransferDuration, isoPacketDuration) + continue + } + + // EP0 and OUT transfers never block and are handled in order. ISO OUT + // completions are paced so an audio client cannot burst seconds of PCM + // into the Bluetooth haptics path in one scheduler slice. + isIsoOut := dir == usbip.DirOut && isIso + var isoTransferDuration time.Duration + var isoCompletionDeadline time.Time + var processingStarted time.Time + if isIsoOut { + isoTransferDuration = isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets)) + now := time.Now() + // A hardware USB controller schedules ISO completions against its + // service clock. Reserve this window before processing the payload, + // otherwise processing time becomes an unwanted addition to every + // 1 ms audio service interval. This mirrors vDS's next_iso_out_ready + // model while resetting after a genuine missed transfer window. + if nextIsoOutCompletion.IsZero() || now.Sub(nextIsoOutCompletion) > isoTransferDuration { + nextIsoOutCompletion = now.Add(isoTransferDuration) + } else { + nextIsoOutCompletion = nextIsoOutCompletion.Add(isoTransferDuration) + } + isoCompletionDeadline = nextIsoOutCompletion + processingStarted = time.Now() + } + respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) + var processingDuration time.Duration + if isIsoOut { + processingDuration = time.Since(processingStarted) + } + actualLen := uint32(len(respData)) + if dir == usbip.DirOut { + actualLen = uint32(len(outPayload)) + } + completedPackets := completeIsoPackets(isoPackets, actualLen) + if isIsoOut { + waitStarted := time.Now() + scheduledWait := time.Until(isoCompletionDeadline) + if scheduledWait > 0 { + time.Sleep(scheduledWait) + } else { + scheduledWait = 0 + } + completionTime := time.Now() + deadlineLateness := completionTime.Sub(isoCompletionDeadline) + if deadlineLateness < 0 { + deadlineLateness = 0 + } + logIsoAudioWindow(ep, len(outPayload), len(isoPackets), isoTransferDuration, + scheduledWait, completionTime.Sub(waitStarted), processingDuration, deadlineLateness) + } + if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { + return err + } + } +} + +func endpointInterval(desc *usb.Descriptor, ep uint32) time.Duration { + epAddr := uint8(ep) | 0x80 + for i := range desc.Interfaces { + for _, epDesc := range desc.Interfaces[i].Endpoints { + if epDesc.BEndpointAddress != epAddr || epDesc.BMAttributes&0x03 != 0x03 { + continue + } + return usbServiceInterval(desc.Device.Speed, epDesc.BInterval) + } + } + return 0 +} + +func endpointIsIsochronous(desc *usb.Descriptor, ep, dir uint32) bool { + if desc == nil || ep == 0 { + return false + } + + epAddr := uint8(ep) & 0x0f + if dir == usbip.DirIn { + epAddr |= 0x80 + } + for i := range desc.Interfaces { + for _, endpoint := range desc.Interfaces[i].Endpoints { + if endpoint.BEndpointAddress == epAddr && endpoint.BMAttributes&0x03 == 0x01 { + return true + } + } + } + return false +} + +func usbServiceInterval(speed uint32, bInterval uint8) time.Duration { + if bInterval == 0 { + return 0 + } + if speed >= 3 { + return time.Duration(1<<(bInterval-1)) * 125 * time.Microsecond + } + return time.Duration(bInterval) * time.Millisecond +} + +func completeIsoPackets(submitted []usbip.IsoPacketDescriptor, actualLen uint32) []usbip.IsoPacketDescriptor { + if len(submitted) == 0 { + return nil + } + + completed := make([]usbip.IsoPacketDescriptor, len(submitted)) + for i, packet := range submitted { + completed[i] = packet + completed[i].Status = 0 + completed[i].ActualLength = 0 + if packet.Offset >= actualLen { + continue + } + + available := actualLen - packet.Offset + completed[i].ActualLength = min(packet.Length, available) + } + + return completed +} + +func completeIsoPacketsWithActuals(submitted []usbip.IsoPacketDescriptor, actualLengths []uint32) []usbip.IsoPacketDescriptor { + if len(submitted) == 0 { + return nil + } + + completed := make([]usbip.IsoPacketDescriptor, len(submitted)) + for i, packet := range submitted { + completed[i] = packet + completed[i].Status = 0 + if i < len(actualLengths) { + completed[i].ActualLength = min(packet.Length, actualLengths[i]) + } else { + completed[i].ActualLength = 0 + } + } + + return completed +} + +func (s *Server) buildIsoInResponse( + ctx context.Context, + dev usb.Device, + ep uint32, + dir uint32, + submitted []usbip.IsoPacketDescriptor, + serviceStart time.Time, +) ([]byte, []usbip.IsoPacketDescriptor, time.Time) { + if len(submitted) == 0 { + return nil, nil, serviceStart + } + + interval := isoPacketInterval(dev.GetDescriptor(), ep) + if interval <= 0 { + interval = time.Millisecond + } + if serviceStart.IsZero() { + serviceStart = time.Now() + } + + actualLengths := make([]uint32, len(submitted)) + maximumLen := uint32(0) + for _, packet := range submitted { + maximumLen += packet.Length + } + + // USB/IP removes the padding between ISO packets on the wire. The client + // restores each packet at its descriptor offset after receiving the compact + // payload. Sending the original offset gaps here makes actual_length differ + // from the sum of packet actual lengths, so usbip-win2 discards capture data. + respData := make([]byte, 0, maximumLen) + nextServiceSlot := serviceStart + for i, packet := range submitted { + // Consume each packet at its virtual USB service time. Windows submits + // several capture URBs in advance, so dequeuing the whole URB here without + // pacing would consume future microphone audio in one scheduler slice. + nextServiceSlot = reanchorMissedIsoPacketSlot( + nextServiceSlot, interval, time.Now()) + if !waitUntilContext(ctx, nextServiceSlot) { + return nil, nil, time.Time{} + } + nextServiceSlot = nextServiceSlot.Add(interval) + if packet.Length == 0 { + continue + } + + attemptCtx, cancel := context.WithTimeout(ctx, interval) + packetData := s.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + cancel() + if ctx.Err() != nil { + return nil, nil, time.Time{} + } + if len(packetData) == 0 { + packetData = make([]byte, int(packet.Length)) + } + + actual := min(packet.Length, uint32(len(packetData))) + respData = append(respData, packetData[:actual]...) + actualLengths[i] = actual + } + + completed := completeIsoPacketsWithActuals(submitted, actualLengths) + return respData, completed, nextServiceSlot +} + +func waitUntilContext(ctx context.Context, deadline time.Time) bool { + if ctx.Err() != nil { + return false + } + wait := time.Until(deadline) + if wait <= 0 { + return true + } + + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func reanchorIsoServiceWindow(plannedStart, previousServiceEnd time.Time, + duration, packetInterval time.Duration, now time.Time) (time.Time, time.Time) { + start := plannedStart + if previousServiceEnd.After(start) { + start = previousServiceEnd + } + if start.IsZero() { + start = now + } else if packetInterval > 0 && now.Sub(start) >= packetInterval { + // Preserve the absolute clock across ordinary timer jitter, but do not + // replay a service slot that is at least one complete packet late. + start = now + } + return start, start.Add(duration) +} + +func reanchorMissedIsoPacketSlot(planned time.Time, interval time.Duration, + now time.Time) time.Time { + if planned.IsZero() { + return now + } + if interval > 0 && now.Sub(planned) >= interval { + return now + } + return planned +} + +// isoCompletionDelay returns the USB service interval represented by an ISO +// URB. ISO completions must follow this cadence; completing immediately causes +// Windows to feed the virtual audio device in bursts instead of realtime. +func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int) time.Duration { + if packetCount == 0 { + return 0 + } + + var bInterval uint8 + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress&0x0F == uint8(ep)&0x0F && endpoint.BMAttributes&0x03 == 0x01 { + bInterval = endpoint.BInterval + break + } + } + if bInterval != 0 { + break + } + } + + if bInterval == 0 { + return 0 + } + + return min(time.Duration(packetCount)*usbServiceInterval(desc.Device.Speed, bInterval), 100*time.Millisecond) +} + +func isoPacketInterval(desc *usb.Descriptor, ep uint32) time.Duration { + var bInterval uint8 + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress&0x0F == uint8(ep)&0x0F && endpoint.BMAttributes&0x03 == 0x01 { + bInterval = endpoint.BInterval + break + } + } + if bInterval != 0 { + break + } + } + + if bInterval == 0 { + return 0 + } + + return usbServiceInterval(desc.Device.Speed, bInterval) +} + +// isClientDisconnect tests whether an error represents a normal client +// disconnect (EOF, ECONNRESET, broken pipe, or the Windows WSAECONNRESET +// translated error). We treat those as normal client disconnects and log +// them at Info level instead of Error. +func isClientDisconnect(err error) bool { + if err == nil { + return false + } + if errors.Is(err, io.EOF) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) { + switch t := opErr.Err.(type) { + case syscall.Errno: + if t == syscall.ECONNRESET || t == syscall.EPIPE { + return true + } + } + } + e := strings.ToLower(err.Error()) + if strings.Contains(e, "connection reset by peer") || strings.Contains(e, "forcibly closed") || strings.Contains(e, "an existing connection was forcibly closed") || strings.Contains(e, "aborted") { + return true + } + return false +} + +func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, dir uint32, setup []byte, out []byte) []byte { + if ep != 0 { + return dev.HandleTransfer(ctx, ep, dir, out) + } + if len(setup) != 8 { + s.logger.Debug("EP0 submit with invalid setup size", "setupLen", len(setup), "setup", setup) + return nil + } + bm := setup[0] + breq := setup[1] + wValue := binary.LittleEndian.Uint16(setup[2:4]) + wIndex := binary.LittleEndian.Uint16(setup[4:6]) + wLength := binary.LittleEndian.Uint16(setup[6:8]) + desc := dev.GetDescriptor() + + if breq == usbReqGetStatus { + return []byte{0x00, 0x00} + } + if breq == usbReqSetAddress && bm == usbReqTypeStandardToDevice { + return nil + } + if breq == usbReqSetConfiguration && bm == usbReqTypeStandardToDevice { + s.resetInterfaceAlts(dev) + return nil + } + if breq == usbReqGetConfiguration && bm == usbReqTypeStandardFromDevice { + return []byte{0x01} + } + if breq == usbReqClearFeature && bm == usbReqTypeStandardToEndpoint && + wValue == 0 && wLength == 0 && uint8(wIndex>>8) == 0 && + descriptorHasEndpoint(desc, uint8(wIndex)) { + if resetter, ok := dev.(usb.EndpointResetDevice); ok { + resetter.ResetEndpoint(uint8(wIndex)) + } + return nil + } + if breq == usbReqGetInterface && bm == usbReqTypeStandardToInterface { + return []byte{s.getInterfaceAlt(dev, uint8(wIndex&usbIfaceIndexMask))} + } + if breq == usbReqSetInterface && bm == usbReqTypeStandardFromInterface { + iface := uint8(wIndex & usbIfaceIndexMask) + alt := uint8(wValue & 0xff) + if descriptorHasInterfaceAlt(desc, iface, alt) { + s.setInterfaceAlt(dev, iface, alt) + s.notifyInterfaceAlt(dev, iface, alt) + } + return nil + } + + if breq == usbReqGetDescriptor && bm == usbReqTypeStandardFromDevice { + dtype := uint8(wValue >> 8) + dindex := uint8(wValue & 0xff) + var data []byte + switch dtype { + case usbDescTypeDevice: + data = desc.Bytes() + case usbDescTypeConfiguration: + data = s.buildConfigDescriptor(desc) + case usbDescTypeString: + if dindex == 0xEE && desc.MicrosoftOS10 != nil { + data = desc.MicrosoftOS10.StringDescriptor() + } else if s, ok := desc.Strings[dindex]; ok { + data = usb.EncodeStringDescriptor(s) + } + } + if len(data) == 0 { + return nil + } + if int(wLength) < len(data) { + return data[:wLength] + } + return data + } + + if desc.MicrosoftOS10 != nil && + (bm == 0xC0 || bm == 0xC1) && + (breq == desc.MicrosoftOS10.EffectiveVendorCode() || + wIndex == 0x0004 || wIndex == 0x0005) { + if data, ok := desc.MicrosoftOS10.ControlResponse(wValue, wIndex); ok { + if int(wLength) < len(data) { + return data[:wLength] + } + return data + } + } + + if breq == usbReqGetDescriptor && bm == usbReqTypeStandardToInterface { + dtype := uint8(wValue >> 8) + iface := uint8(wIndex & 0xff) + var data []byte + if ifaceConf, ok := desc.Interface(iface); ok { + if ifaceConf.HID != nil { + switch dtype { + case usbDescTypeHID: + d, err := ifaceConf.HID.DescriptorBytes() + if err != nil { + s.logger.Error("failed to build HID descriptor", "iface", iface, "error", err) + return nil + } + data = []byte(d) + case usbDescTypeHIDReport: + d, err := ifaceConf.HID.ReportBytes() + if err != nil { + s.logger.Error("failed to build HID report descriptor", "iface", iface, "error", err) + return nil + } + data = []byte(d) + } + } + if len(data) == 0 { + for _, cd := range ifaceConf.ClassDescriptors { + if cd.DescriptorType == dtype { + data = []byte(cd.Bytes()) + break + } + } + } + } + if len(data) == 0 { + return nil + } + if int(wLength) < len(data) { + return data[:wLength] + } + return data + } + + // Handle common HID state requests before controller-specific dispatch. The + // descriptor slice contains alternate-setting entries, so interface numbers + // must be resolved logically rather than used as slice indexes. + if ifaceConfig, ok := desc.Interface(uint8(wIndex & usbIfaceIndexMask)); ok && + ifaceConfig.Descriptor.BInterfaceClass == usbInterfaceClassHID { + switch { + case bm == hidReqTypeIn && breq == hidReqGetIdle: + return []byte{0x00} + case bm == hidReqTypeOut && breq == hidReqSetIdle: + return nil + case bm == hidReqTypeIn && breq == hidReqGetProtocol: + return []byte{0x01} + case bm == hidReqTypeOut && breq == hidReqSetProtocol: + return nil + } + } + + if cd, ok := dev.(usb.ControlDevice); ok { + if resp, handled := cd.HandleControl(bm, breq, wValue, wIndex, wLength, out); handled { + if resp == nil { + return nil + } + if int(wLength) < len(resp) { + return resp[:wLength] + } + return resp + } + } + + if ifaceConfig, ok := desc.Interface(uint8(wIndex & usbIfaceIndexMask)); ok { + if ifaceConfig.Descriptor.BInterfaceClass == usbInterfaceClassHID { + switch { + case (bm == hidReqTypeIn || bm == hidReqTypeOut) && (breq == hidReqGetReport || breq == hidReqSetReport): + return nil + } + } + } + + if (bm & usbReqTypeMask) != usbReqTypeClass { + s.logger.Debug("EP0 control unhandled", "bmRequestType", bm, "bRequest", breq, "wValue", wValue, "wIndex", wIndex, "wLength", wLength) + } + + return nil +} + +func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { + var b bytes.Buffer + configValue := desc.Configuration.BConfigurationValue + if configValue == 0 { + configValue = usbConfigValueDefault + } + attrs := desc.Configuration.BMAttributes + if attrs == 0 { + attrs = usbConfigAttrBusPowered + } + maxPower := desc.Configuration.BMaxPower + if maxPower == 0 { + maxPower = usbConfigMaxPower100mA + } + h := usb.ConfigHeader{ + WTotalLength: 0, // to be patched + BNumInterfaces: desc.NumInterfaces(), + BConfigurationValue: configValue, + IConfiguration: desc.Configuration.IConfiguration, + BMAttributes: attrs, + BMaxPower: maxPower, + } + h.Write(&b) + for _, iface := range desc.Interfaces { + for _, iad := range desc.Associations { + if iad.BFirstInterface == iface.Descriptor.BInterfaceNumber && iface.Descriptor.BAlternateSetting == 0 { + iad.Write(&b) + } + } + iface.Descriptor.Write(&b) + if iface.HID != nil { + hd, err := iface.HID.DescriptorBytes() + if err != nil { + s.logger.Error("failed to build HID descriptor", "iface", iface.Descriptor.BInterfaceNumber, "error", err) + // Stall/return minimal config descriptor. + return nil + } + b.Write([]byte(hd)) + } + for _, cd := range iface.ClassDescriptors { + b.Write([]byte(cd.Bytes())) + } + for _, ep := range iface.Endpoints { + ep.Write(&b) + for _, cd := range ep.ClassDescriptors { + b.Write([]byte(cd.Bytes())) + } + } + } + + data := b.Bytes() + binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) + return data +} + +func descriptorListInterfaces(desc *usb.Descriptor) []usb.InterfaceConfig { + out := make([]usb.InterfaceConfig, 0, desc.NumInterfaces()) + seen := map[uint8]struct{}{} + for _, iface := range desc.Interfaces { + n := iface.Descriptor.BInterfaceNumber + if _, ok := seen[n]; ok || iface.Descriptor.BAlternateSetting != 0 { + continue + } + seen[n] = struct{}{} + out = append(out, iface) + } + for _, iface := range desc.Interfaces { + n := iface.Descriptor.BInterfaceNumber + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, iface) + } + return out +} + +func descriptorHasInterfaceAlt(desc *usb.Descriptor, ifaceNumber, altSetting uint8) bool { + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber == ifaceNumber && + iface.Descriptor.BAlternateSetting == altSetting { + return true + } + } + return false +} + +func descriptorHasEndpoint(desc *usb.Descriptor, endpointAddress uint8) bool { + if desc == nil || endpointAddress&0x0f == 0 { + return false + } + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress == endpointAddress { + return true + } + } + } + return false +} + +func descriptorInterfaceNumbers(desc *usb.Descriptor) []uint8 { + out := make([]uint8, 0, desc.NumInterfaces()) + seen := map[uint8]struct{}{} + for _, iface := range desc.Interfaces { + n := iface.Descriptor.BInterfaceNumber + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + +func (s *Server) notifyInterfaceAlt(dev usb.Device, iface, alt uint8) { + if notifier, ok := dev.(usb.InterfaceAltSettingDevice); ok { + notifier.SetInterfaceAltSetting(iface, alt) + } +} + +func (s *Server) notifyInterfaceAltsCleared(dev usb.Device) { + notifier, ok := dev.(usb.InterfaceAltSettingDevice) + if !ok { + return + } + + for _, iface := range descriptorInterfaceNumbers(dev.GetDescriptor()) { + notifier.SetInterfaceAltSetting(iface, 0) + } +} + +func (s *Server) getInterfaceAlt(dev usb.Device, iface uint8) uint8 { + s.altsMu.Lock() + defer s.altsMu.Unlock() + if s.alts == nil { + return 0 + } + if devAlts, ok := s.alts[dev]; ok { + return devAlts[iface] + } + return 0 +} + +func (s *Server) setInterfaceAlt(dev usb.Device, iface, alt uint8) { + s.altsMu.Lock() + defer s.altsMu.Unlock() + if s.alts == nil { + s.alts = make(map[usb.Device]map[uint8]uint8) + } + devAlts := s.alts[dev] + if devAlts == nil { + devAlts = make(map[uint8]uint8) + s.alts[dev] = devAlts + } + devAlts[iface] = alt +} + +func (s *Server) clearInterfaceAlt(dev usb.Device) { + s.altsMu.Lock() + defer s.altsMu.Unlock() + delete(s.alts, dev) +} + +func (s *Server) resetInterfaceAlts(dev usb.Device) { + s.clearInterfaceAlt(dev) + s.notifyInterfaceAltsCleared(dev) +} diff --git a/internal/tray/icon.ico b/internal/tray/icon.ico new file mode 100644 index 00000000..9d7a4187 Binary files /dev/null and b/internal/tray/icon.ico differ diff --git a/internal/tray/tray.go b/internal/tray/tray.go new file mode 100644 index 00000000..178bdcae --- /dev/null +++ b/internal/tray/tray.go @@ -0,0 +1,7 @@ +//go:build !windows + +package tray + +import "context" + +func Run(ctx context.Context, shutdown func()) {} diff --git a/internal/tray/tray_windows.go b/internal/tray/tray_windows.go new file mode 100644 index 00000000..82bb530f --- /dev/null +++ b/internal/tray/tray_windows.go @@ -0,0 +1,124 @@ +//go:build windows + +package tray + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "runtime/debug" + + "fyne.io/systray" + "golang.org/x/sys/windows/registry" +) + +//go:embed icon.ico +var trayIcon []byte + +const ( + runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + runValueKey = "VIIPER" +) + +func Run(ctx context.Context, shutdown func()) { + go systray.Run(func() { + runtime.LockOSThread() + + systray.SetIcon(trayIcon) + systray.SetTooltip("VIIPER") + + version := readVersion() + infoStr := fmt.Sprintf("VIIPER - %s", version) + versionItem := systray.AddMenuItem(infoStr, infoStr) + versionItem.Disable() + + systray.AddSeparator() + + autoStartItem := systray.AddMenuItemCheckbox("Run at startup", "", autoStartEnabled()) + + systray.AddSeparator() + + exitItem := systray.AddMenuItem("Quit", "Exit VIIPER") + + go func() { + for { + select { + case <-ctx.Done(): + return + case <-autoStartItem.ClickedCh: + if toggleAutoStart() { + autoStartItem.Check() + } else { + autoStartItem.Uncheck() + } + case <-exitItem.ClickedCh: + systray.Quit() + shutdown() + return + } + } + }() + + }, func() {}) +} + +func readVersion() string { + if info, ok := debug.ReadBuildInfo(); ok { + v := info.Main.Version + if v != "" && v != "(devel)" { + return v + } + } + return "dev" +} + +func autoStartEnabled() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() //nolint:errcheck + _, _, err = key.GetStringValue(runValueKey) + return err == nil +} + +func toggleAutoStart() bool { + if autoStartEnabled() { + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if err != nil { + slog.Error("Failed to open registry key", "error", err) + return true + } + defer key.Close() //nolint:errcheck + _ = key.DeleteValue(runValueKey) + slog.Info("Auto-start disabled") + return false + } + exe, err := os.Executable() + if err != nil { + slog.Error("Failed to get executable path", "error", err) + return false + } + selfPath, err := filepath.EvalSymlinks(exe) + if err != nil { + slog.Error("Failed to evaluate symlinks", "error", err) + return false + } + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) + if err != nil { + slog.Error("Failed to create registry key", "error", err) + return false + } + defer key.Close() //nolint:errcheck + value := fmt.Sprintf("\"%s\" server", selfPath) + if err := key.SetStringValue(runValueKey, value); err != nil { + slog.Error("Failed to set registry value", "error", err) + return false + } + slog.Info("Auto-start enabled") + return true +} diff --git a/internal/updater/msgbox.go b/internal/updater/msgbox.go new file mode 100644 index 00000000..2c3884c8 --- /dev/null +++ b/internal/updater/msgbox.go @@ -0,0 +1,32 @@ +package updater + +import ( + "runtime" + + "github.com/ncruces/zenity" +) + +func showMessageBox(title, message string) int { + options := []string{"Update Now", "View on GitHub", "Remind Me Later", "Skip This Version"} + if runtime.GOOS == "linux" { + options = []string{"View on GitHub", "Remind Me Later", "Skip This Version"} + } + choice, err := zenity.List( + message, + options, + zenity.Title(title), + ) + if err != nil { + return ActionRemindLater + } + switch choice { + case "Update Now": + return ActionUpdateNow + case "View on GitHub": + return ActionViewGitHub + case "Skip This Version": + return ActionDismiss + default: + return ActionRemindLater + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 00000000..3b8ac665 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,215 @@ +package updater + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/Alia5/VIIPER/internal/config" + "github.com/Alia5/VIIPER/internal/configpaths" +) + +const ( + ActionRemindLater = 0 + ActionViewGitHub = 1 + ActionUpdateNow = 2 + ActionDismiss = 3 +) + +var ( + client = &http.Client{Timeout: 10 * time.Second} + remindLaterVersion string + versionRe = regexp.MustCompile(`v(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?`) +) + +type version struct { + Major, Minor, Patch, Commits int +} + +func parseVersion(s string) (version, bool) { + m := versionRe.FindStringSubmatch(s) + if m == nil { + return version{}, false + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + commits := 0 + if m[4] != "" { + commits, _ = strconv.Atoi(m[4]) + } + return version{major, minor, patch, commits}, true +} + +func dismissedFilePath() string { + dir, err := configpaths.DefaultConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "update-dismissed") +} + +func isDismissed(ver string) bool { + p := dismissedFilePath() + if p == "" { + return false + } + data, err := os.ReadFile(p) + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == ver +} + +func writeDismissed(ver string) { + p := dismissedFilePath() + if p == "" { + return + } + _ = configpaths.EnsureDir(p) + if err := os.WriteFile(p, []byte(ver), 0o644); err != nil { + slog.Error("failed to write update-dismissed", "error", err) + } +} + +type release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + HTMLURL string `json:"html_url"` +} + +func CheckUpdate(currentVersion string, notify config.UpdateNotify) { + cur, ok := parseVersion(currentVersion) + if !ok && currentVersion != "dev" { + slog.Error("failed to parse current version", "version", currentVersion) + return + } + + var r release + if notify == config.UpdateNotifyPrerelease { + resp, err := client.Get("https://api.github.com/repos/Alia5/VIIPER/releases?per_page=1") + if err != nil { + slog.Error("failed to fetch releases", "error", err) + return + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + slog.Error("unexpected status from GitHub API", "status", resp.StatusCode) + return + } + var releases []release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + slog.Error("failed to decode releases", "error", err) + return + } + if len(releases) == 0 { + return + } + r = releases[0] + } else { + resp, err := client.Get("https://api.github.com/repos/Alia5/VIIPER/releases/latest") + if err != nil { + slog.Error("failed to fetch latest release", "error", err) + return + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + slog.Error("unexpected status from GitHub API", "status", resp.StatusCode) + return + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + slog.Error("failed to decode latest release", "error", err) + return + } + } + + versionSource := r.TagName + if r.Prerelease { + versionSource = r.Name + } + + remote, ok := parseVersion(versionSource) + if !ok { + slog.Error("failed to parse remote version", "version", versionSource) + return + } + + newer := remote.Major > cur.Major || + (remote.Major == cur.Major && remote.Minor > cur.Minor) || + (remote.Major == cur.Major && remote.Minor == cur.Minor && remote.Patch > cur.Patch) || + (remote.Major == cur.Major && remote.Minor == cur.Minor && remote.Patch == cur.Patch && remote.Commits > cur.Commits) + + if !newer { + return + } + + matched := versionRe.FindString(versionSource) + + if isDismissed(matched) || remindLaterVersion == matched { + return + } + + slog.Info("update available", "current", currentVersion, "available", matched) + installChannel := "stable" + if notify == config.UpdateNotifyPrerelease { + installChannel = "main" + } + + action := showMessageBox( + "VIIPER Update Available", + fmt.Sprintf("A new version of VIIPER is available: %s", matched), + ) + + switch action { + case ActionRemindLater: + remindLaterVersion = matched + case ActionViewGitHub: + openBrowser(r.HTMLURL) + case ActionUpdateNow: + writeDismissed(matched) + runInstallScript(installChannel) + case ActionDismiss: + writeDismissed(matched) + } +} + +func openBrowser(url string) { + var err error + switch runtime.GOOS { + case "windows": + err = exec.Command("cmd", "/c", "start", url).Start() + case "linux": + err = exec.Command("xdg-open", url).Start() + } + if err != nil { + slog.Error("failed to open browser", "error", err) + } +} + +func runInstallScript(channel string) { + baseURL := "https://alia5.github.io/VIIPER/" + channel + "/install" + switch runtime.GOOS { + case "windows": + url := baseURL + ".ps1" + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "Start-Process powershell -ArgumentList '-NoExit -NoProfile -ExecutionPolicy Bypass -Command \"iwr -useb "+url+" | iex\"' -Verb RunAs") + if err := cmd.Run(); err != nil { + slog.Error("failed to run install script", "error", err) + } + case "linux": + cmd := exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL '%s.sh' | sh", baseURL)) + if err := cmd.Start(); err != nil { + slog.Error("failed to run install script", "error", err) + } + } +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 00000000..7b569213 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,16 @@ +//go:build !windows + +package util + +func IsRunFromGUI() bool { + // On non-Windows, always return false. + // We only use this to spawn the viiper server without going through hoops... + // On Linux you can use nohup, systemd, and bazillion other ways... + // I'd like to also assume Linux users are familiar with a CLI + // if not... they should learn! + return false +} + +func HideConsoleWindow() { + // No-op on non-Windows platforms +} diff --git a/internal/util/util_windows.go b/internal/util/util_windows.go new file mode 100644 index 00000000..cd775aa0 --- /dev/null +++ b/internal/util/util_windows.go @@ -0,0 +1,106 @@ +//go:build windows + +package util + +import ( + "log/slog" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + user32 = windows.NewLazySystemDLL("user32.dll") + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procGetConsoleProcs = kernel32.NewProc("GetConsoleProcessList") + procShowWindow = user32.NewProc("ShowWindow") + procFreeConsole = kernel32.NewProc("FreeConsole") +) + +func IsRunFromGUI() bool { + hwnd, _, _ := procGetConsoleWindow.Call() + hasConsole := hwnd != 0 + + if !hasConsole { + return true + } + + parentPID := getParentProcessID() + parentAttached := isPIDAttachedToCurrentConsole(parentPID) + + slog.Debug("Console launch detection", "hasConsole", hasConsole, "parentPID", parentPID, "parentAttached", parentAttached) + + return !parentAttached +} + +func HideConsoleWindow() { + hwnd, _, _ := procGetConsoleWindow.Call() + if hwnd == 0 { + slog.Debug("HideConsoleWindow: no console window found") + return + } + + _, _, _ = procShowWindow.Call(hwnd, windows.SW_HIDE) + _, _, _ = procFreeConsole.Call() +} + +func getParentProcessID() uint32 { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 0 + } + defer windows.CloseHandle(snapshot) // nolint:errcheck + + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + + currentPID := uint32(os.Getpid()) + + if err := windows.Process32First(snapshot, &pe); err != nil { + return 0 + } + + for { + if pe.ProcessID == currentPID { + return pe.ParentProcessID + } + if err := windows.Process32Next(snapshot, &pe); err != nil { + return 0 + } + } +} + +func isPIDAttachedToCurrentConsole(pid uint32) bool { + if pid == 0 { + return false + } + + buf := make([]uint32, 8) + + for { + count, _, _ := procGetConsoleProcs.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) + + if count == 0 { + return false + } + + needed := int(count) + if needed > len(buf) { + buf = make([]uint32, needed) + continue + } + + for _, consolePID := range buf[:needed] { + if consolePID == pid { + return true + } + } + + return false + } +} diff --git a/justfile b/justfile new file mode 100644 index 00000000..de2c4fa3 --- /dev/null +++ b/justfile @@ -0,0 +1,150 @@ +set windows-shell := ["powershell.exe", "-NoProfile", "-Command"] + +binary_name := "viiper" +main_pkg := "./cmd/viiper" +src_dir := "." +dist_dir := "dist" +target_goos := env_var_or_default("GOOS", if os_family() == "windows" { "windows" } else { "linux" }) +target_goarch := env_var_or_default("GOARCH", if os_family() == "windows" { "amd64" } else { "amd64" }) +exe_ext := if target_goos == "windows" { ".exe" } else { "" } +mkdir_p := if os_family() == "windows" { "New-Item -ItemType Directory -Force" } else { "mkdir -p" } +rm_rf := if os_family() == "windows" { "Remove-Item -Recurse -Force -ErrorAction 0" } else { "rm -rf" } +rm_f := if os_family() == "windows" { "Remove-Item -Force -ErrorAction 0" } else { "rm -f" } + +version := env_var_or_default("VERSION", `git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always`) +commit := `git rev-parse --short HEAD` +build_time := if os_family() == "windows" { + `Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'` +} else { + `date -u '+%Y-%m-%dT%H:%M:%SZ'` +} +build_type := env_var_or_default("BUILD_TYPE", "Debug") +output_name := env_var_or_default("OUTPUT_NAME", binary_name + exe_ext) +build_path := join(dist_dir, output_name) +go_licenses_cmd := "go run github.com/google/go-licenses/v2@v2.0.1" +licenses_template := "scripts/licenses.tpl" +licenses_template_work := if os_family() == "windows" { join(env_var_or_default("TEMP", "."), "viiper-licenses.rendered.tpl") } else { "/tmp/viiper-licenses.rendered.tpl" } +licenses_ignore := "github.com/Alia5/VIIPER,github.com/alecthomas/kong-yaml" +licenses_dir := join(dist_dir, "libVIIPER") +licenses_out := join(dist_dir, "licenses.txt") +lib_licenses_out := join(licenses_dir, "licenses.txt") + +ldflags_common := "-X main.Version=" + version + " -X main.Commit=" + commit + " -X main.Date=" + build_time + " -X github.com/Alia5/VIIPER/internal/codegen/common.Version=" + version +ldflags_release := "-s -w " + ldflags_common + +default: + just --list + +help: + just --list + +tidy: + go mod tidy + +test: + go test -count=1 -v ./... + +test-coverage: + go test -count=1 -v -coverpkg="./..." -coverprofile="coverage.txt" ./... + +[windows] +generate-versioninfo: + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "versioninfo.json" "versioninfo.tmp.json" + {{ + if target_goarch == "amd64" { + "goversioninfo -64 -o cmd/viiper/resource.syso versioninfo.tmp.json" + } else if target_goarch == "arm64" { + "goversioninfo -arm -64 -o cmd/viiper/resource.syso versioninfo.tmp.json" + } else { + "goversioninfo -o cmd/viiper/resource.syso versioninfo.tmp.json" + } + }} + +[unix] +generate-versioninfo: + @: + +clean-versioninfo: + -{{ rm_f }} cmd/viiper/resource.syso + -{{ rm_f }} lib/viiper/resource.syso + -{{ rm_f }} versioninfo.tmp.json + -{{ rm_f }} libviiper.versioninfo.tmp.json + +[arg("type", pattern="Debug|Release")] +[windows] +build type=build_type: generate-versioninfo + {{ mkdir_p }} {{ dist_dir }} + $env:CGO_ENABLED='0'; go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + just licenses + +[arg("type", pattern="Debug|Release")] +[unix] +build type=build_type: + {{ mkdir_p }} {{ dist_dir }} + CGO_ENABLED=0 go build {{ if type == "Release" { "-tags release" } else { "" } }} -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + just licenses + +[arg("type", pattern="Debug|Release")] +[windows] +build-libVIIPER type=build_type: + {{ mkdir_p }} dist/libVIIPER + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + pwsh -NoProfile -NonInteractive -File scripts/inject-version.ps1 "{{ version }}" "lib/viiper/versioninfo.json" "libviiper.versioninfo.tmp.json" + goversioninfo -64 -o lib/viiper/resource.syso libviiper.versioninfo.tmp.json + $env:CGO_ENABLED='1'; go build -buildmode=c-shared -trimpath {{ if type == "Release" { "-ldflags \"-s -w\"" } else { "" } }} -o dist/libVIIPER/libVIIPER.dll ./lib/viiper + gendef - dist/libVIIPER/libVIIPER.dll | Set-Content -Encoding ascii dist/libVIIPER/libVIIPER.def + go run ./lib/viiper/postbuild + {{ rm_f }} libviiper.versioninfo.tmp.json + just licenses-libVIIPER + +[arg("type", pattern="Debug|Release")] +[unix] +build-libVIIPER type=build_type: + {{ mkdir_p }} dist/libVIIPER + CGO_ENABLED=1 go build -buildmode=c-shared -trimpath {{ if type == "Release" { "-ldflags \"-s -w\"" } else { "" } }} -o dist/libVIIPER/libVIIPER.so ./lib/viiper + go run ./lib/viiper/postbuild + just licenses-libVIIPER + +clean: clean-versioninfo + -{{ rm_rf }} {{ dist_dir }} + -{{ rm_f }} coverage.out + -{{ rm_f }} coverage.html + go clean + +fmt: + go fmt ./... + +lint: + golangci-lint run ./... + +[windows] +licenses: + go install github.com/google/go-licenses/v2@latest + {{ mkdir_p }} {{ dist_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue + +[windows] +licenses-libVIIPER: + go install github.com/google/go-licenses/v2@latest + {{ mkdir_p }} {{ licenses_dir }}; $template = (Get-Content {{ licenses_template }} -Raw).Replace('VERSION_PLACEHOLDER', '{{ version }}'); [System.IO.File]::WriteAllText("{{ licenses_template_work }}", $template, [System.Text.UTF8Encoding]::new($false)); $env:GOOS = ''; $env:GOARCH = ''; {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} | Set-Content -Encoding utf8 {{ lib_licenses_out }}; Remove-Item -Force {{ licenses_template_work }} -ErrorAction SilentlyContinue + +[unix] +licenses: + go install github.com/google/go-licenses/v2@latest + {{ mkdir_p }} {{ dist_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ licenses_out }} && rm -f {{ licenses_template_work }} + +[unix] +licenses-libVIIPER: + go install github.com/google/go-licenses/v2@latest + {{ mkdir_p }} {{ licenses_dir }} && sed "s/VERSION_PLACEHOLDER/{{ version }}/g" {{ licenses_template }} > {{ licenses_template_work }} && GOOS= GOARCH= {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ lib_licenses_out }} && rm -f {{ licenses_template_work }} + +run *args: build + {{ if os_family() == "windows" { "$env:DEV='1'; & './" + build_path + "'" } else { "DEV=1 './" + build_path + "'" } }} {{ args }} + +run-server *args: build + {{ if os_family() == "windows" { "$env:DEV='1'; & './" + build_path + "' server" } else { "DEV=1 './" + build_path + "' server" } }} {{ args }} + +version: + @echo Version: {{ version }} + @echo Commit: {{ commit }} + @echo Built: {{ build_time }} diff --git a/lib/viiper/bus.go b/lib/viiper/bus.go new file mode 100644 index 00000000..98cb0c82 --- /dev/null +++ b/lib/viiper/bus.go @@ -0,0 +1,73 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; +*/ +import "C" +import ( + "runtime/cgo" + + "github.com/Alia5/VIIPER/virtualbus" +) + +// CreateUSBBus creates a new USB bus on the server associated with the given handle. +// @param handle Handle to the USB server. +// @param busID ID of the bus to create. If 0 or NULL, the server will assign the next free bus ID. +// +//export CreateUSBBus +func CreateUSBBus(handle C.USBServerHandle, busID *uint32) bool { + h := cgo.Handle(handle) + hw, ok := h.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + + if busID == nil { + id := hw.s.NextFreeBusID() + busID = &id + } else if *busID == 0 { + *busID = hw.s.NextFreeBusID() + } + + b, err := virtualbus.NewWithBusID(*busID) + if err != nil { + return false + } + if err := hw.s.AddBus(b); err != nil { + return false + } + hw.mtx.Lock() + defer hw.mtx.Unlock() + hw.deviceHandles[*busID] = make([]deviceHandle, 0) + + return true +} + +// RemoveUSBBus removes the USB bus with the given ID from the server associated with the given handle. +// Automatically removes devices associated with the bus. +// @param handle Handle to the USB server. +// @param busID ID of the bus to remove. +// +//export RemoveUSBBus +func RemoveUSBBus(handle C.USBServerHandle, busID uint32) bool { + h := cgo.Handle(handle) + hw, ok := h.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + + if err := hw.s.RemoveBus(busID); err != nil { + return false + } + hw.mtx.Lock() + defer hw.mtx.Unlock() + for _, dh := range hw.deviceHandles[busID] { + cgo.Handle(dh).Delete() + } + delete(hw.deviceHandles, busID) + + return true +} diff --git a/lib/viiper/dualsense.go b/lib/viiper/dualsense.go new file mode 100644 index 00000000..05a78497 --- /dev/null +++ b/lib/viiper/dualsense.go @@ -0,0 +1,345 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t DSDeviceHandle; + +#define DS_BUTTON_SQUARE 0x00000010u +#define DS_BUTTON_CROSS 0x00000020u +#define DS_BUTTON_CIRCLE 0x00000040u +#define DS_BUTTON_TRIANGLE 0x00000080u +#define DS_BUTTON_L1 0x00000100u +#define DS_BUTTON_R1 0x00000200u +#define DS_BUTTON_L2 0x00000400u +#define DS_BUTTON_R2 0x00000800u +#define DS_BUTTON_CREATE 0x00001000u +#define DS_BUTTON_OPTIONS 0x00002000u +#define DS_BUTTON_L3 0x00004000u +#define DS_BUTTON_R3 0x00008000u +#define DS_BUTTON_PS 0x00010000u +#define DS_BUTTON_TOUCHPAD 0x00020000u +#define DS_BUTTON_MIC_MUTE 0x00040000u +#define DS_BUTTON_RFN 0x00200000u +#define DS_BUTTON_LFN 0x00100000u +#define DS_BUTTON_R4 0x00800000u +#define DS_BUTTON_L4 0x00400000u + +#define DS_DPAD_UP 0x01u +#define DS_DPAD_DOWN 0x02u +#define DS_DPAD_LEFT 0x04u +#define DS_DPAD_RIGHT 0x08u + +#define DS_SHELL_COLOR_WHITE "00" +#define DS_SHELL_COLOR_BLACK "01" +#define DS_SHELL_COLOR_COSMIC_RED "02" +#define DS_SHELL_COLOR_NOVA_PINK "03" +#define DS_SHELL_COLOR_GALACTIC_PURPLE "04" +#define DS_SHELL_COLOR_STARLIGHT_BLUE "05" +#define DS_SHELL_COLOR_GREY_CAMOUFLAGE "06" +#define DS_SHELL_COLOR_VOLCANIC_RED "07" +#define DS_SHELL_COLOR_STERLING_SILVER "08" +#define DS_SHELL_COLOR_COBALT_BLUE "09" +#define DS_SHELL_COLOR_CHROMA_TEAL "10" +#define DS_SHELL_COLOR_CHROMA_INDIGO "11" +#define DS_SHELL_COLOR_CHROMA_PEARL "12" +#define DS_SHELL_COLOR_ANNIVERSARY_30TH "30" +#define DS_SHELL_COLOR_GOD_OF_WAR_RAGNAROK "Z1" +#define DS_SHELL_COLOR_SPIDER_MAN_2 "Z2" +#define DS_SHELL_COLOR_ASTRO_BOT "Z3" +#define DS_SHELL_COLOR_FORTNITE "Z4" +#define DS_SHELL_COLOR_MONSTER_HUNTER_WILDS "Z5" +#define DS_SHELL_COLOR_THE_LAST_OF_US "Z6" +#define DS_SHELL_COLOR_GHOST_OF_YOTEI "Z7" +#define DS_SHELL_COLOR_ICON_BLUE_LIMITED_EDITION "ZB" +#define DS_SHELL_COLOR_ASTRO_BOT_JOYFUL_EDITION "ZC" +#define DS_SHELL_COLOR_GENSHIN_IMPACT "ZE" + +typedef struct { + int8_t LX; + int8_t LY; + int8_t RX; + int8_t RY; + uint32_t Buttons; + uint8_t DPad; + uint8_t L2; + uint8_t R2; + uint16_t Touch1X; + uint16_t Touch1Y; + uint8_t Touch1Active; + uint16_t Touch2X; + uint16_t Touch2Y; + uint8_t Touch2Active; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; +} DSDeviceState; + +typedef struct { + const char* SerialNumber; // NULL = use default + const char* MACAddress; // NULL = use default + const char* Board; // NULL = use default + uint8_t BatteryStatus; // 0 = use default + double TemperatureCelsius; // 0 = use default + double BatteryVoltage; // 0 = use default + const char* ShellColor; // NULL = use default (2-char code, e.g. "00", "Z1") +} DSMetaState; + +typedef void (*DSOutputCallback)(DSDeviceHandle handle, uint8_t rumbleSmall, uint8_t rumbleLarge, uint8_t ledRed, uint8_t ledGreen, uint8_t ledBlue, uint8_t playerLeds); + +static void viiper_call_ds_output(DSOutputCallback fn, DSDeviceHandle handle, uint8_t rumbleSmall, uint8_t rumbleLarge, uint8_t ledRed, uint8_t ledGreen, uint8_t ledBlue, uint8_t playerLeds) { + fn(handle, rumbleSmall, rumbleLarge, ledRed, ledGreen, ledBlue, playerLeds); +} + +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/dualsense" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateDualSenseDevice creates a new DualSense (non-edge) device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// @param meta Optional pointer to initial device metadata. Pass NULL to use defaults. +// +//export CreateDualSenseDevice +func CreateDualSenseDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DSDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + meta *C.DSMetaState, +) bool { + return createDualSenseDevice(serverHandle, outDeviceHandle, busID, autoAttachLocalhost, idVendor, idProduct, meta, false) +} + +// CreateDualSenseEdgeDevice creates a new DualSense Edge device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// @param meta Optional pointer to initial device metadata. Pass NULL to use defaults. +// +//export CreateDualSenseEdgeDevice +func CreateDualSenseEdgeDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DSDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + meta *C.DSMetaState, +) bool { + return createDualSenseDevice(serverHandle, outDeviceHandle, busID, autoAttachLocalhost, idVendor, idProduct, meta, true) +} + +func createDualSenseDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DSDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + meta *C.DSMetaState, + edge bool, +) bool { + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + if meta != nil { + goMeta := dualsense.MetaState{ + SerialNumber: goStringOrEmpty(meta.SerialNumber), + MACAddress: goStringOrEmpty(meta.MACAddress), + Board: goStringOrEmpty(meta.Board), + BatteryStatus: uint8(meta.BatteryStatus), + TemperatureCelsius: float64(meta.TemperatureCelsius), + BatteryVoltage: float64(meta.BatteryVoltage), + ShellColor: goStringOrEmpty(meta.ShellColor), + } + b, err := json.Marshal(goMeta) + if err != nil { + return false + } + opts.DeviceSpecific = string(b) + } + + ctor := dualsense.New + if edge { + ctor = dualsense.NewEdge + } + d, err := ctor(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.DSDeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetDualSenseDeviceState updates the input state of the DualSense device associated with the given handle. +// @param handle Handle to the DualSense device. +// @param state New input state to set on the device. +// +//export SetDualSenseDeviceState +func SetDualSenseDeviceState(handle C.DSDeviceHandle, state C.DSDeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + dsDevice, ok := dhw.device.(*dualsense.DualSense) + if !ok { + return false + } + s := &dualsense.InputState{ + LX: int8(state.LX), + LY: int8(state.LY), + RX: int8(state.RX), + RY: int8(state.RY), + Buttons: uint32(state.Buttons), + DPad: uint8(state.DPad), + L2: uint8(state.L2), + R2: uint8(state.R2), + Touch1X: uint16(state.Touch1X), + Touch1Y: uint16(state.Touch1Y), + Touch1Active: state.Touch1Active != 0, + Touch2X: uint16(state.Touch2X), + Touch2Y: uint16(state.Touch2Y), + Touch2Active: state.Touch2Active != 0, + GyroX: int16(state.GyroX), + GyroY: int16(state.GyroY), + GyroZ: int16(state.GyroZ), + AccelX: int16(state.AccelX), + AccelY: int16(state.AccelY), + AccelZ: int16(state.AccelZ), + } + dsDevice.UpdateInputState(s) + return true +} + +// SetDualSenseOutputCallback sets a callback to be invoked when the host sends output (rumble/LED) commands to the device. +// @param handle Handle to the DualSense device. +// @param callback Callback receiving rumbleSmall, rumbleLarge, ledRed, ledGreen, ledBlue, playerLeds. Pass NULL to clear. +// +//export SetDualSenseOutputCallback +func SetDualSenseOutputCallback(handle C.DSDeviceHandle, cb C.DSOutputCallback) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + dsDevice, ok := dhw.device.(*dualsense.DualSense) + if !ok { + return false + } + if cb == nil { + dsDevice.SetOutputCallback(nil) + return true + } + dsDevice.SetOutputCallback(func(out dualsense.OutputState) { + C.viiper_call_ds_output(cb, handle, + C.uint8_t(out.RumbleSmall), + C.uint8_t(out.RumbleLarge), + C.uint8_t(out.LedRed), + C.uint8_t(out.LedGreen), + C.uint8_t(out.LedBlue), + C.uint8_t(out.PlayerLeds), + ) + }) + return true +} + +// RemoveDualSenseDevice removes the DualSense device associated with the given handle from the server. +// @param handle Handle to the DualSense device to remove. +// +//export RemoveDualSenseDevice +func RemoveDualSenseDevice(handle C.DSDeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} diff --git a/lib/viiper/dualshock4.go b/lib/viiper/dualshock4.go new file mode 100644 index 00000000..c5122a30 --- /dev/null +++ b/lib/viiper/dualshock4.go @@ -0,0 +1,278 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t DS4DeviceHandle; + +#define DS4_BUTTON_SQUARE 0x0010u +#define DS4_BUTTON_CROSS 0x0020u +#define DS4_BUTTON_CIRCLE 0x0040u +#define DS4_BUTTON_TRIANGLE 0x0080u +#define DS4_BUTTON_L1 0x0100u +#define DS4_BUTTON_R1 0x0200u +#define DS4_BUTTON_L2 0x0400u +#define DS4_BUTTON_R2 0x0800u +#define DS4_BUTTON_SHARE 0x1000u +#define DS4_BUTTON_OPTIONS 0x2000u +#define DS4_BUTTON_L3 0x4000u +#define DS4_BUTTON_R3 0x8000u +#define DS4_BUTTON_PS 0x0001u +#define DS4_BUTTON_TOUCHPAD 0x0002u + +#define DS4_DPAD_UP 0x00u +#define DS4_DPAD_UP_RIGHT 0x01u +#define DS4_DPAD_RIGHT 0x02u +#define DS4_DPAD_DOWN_RIGHT 0x03u +#define DS4_DPAD_DOWN 0x04u +#define DS4_DPAD_DOWN_LEFT 0x05u +#define DS4_DPAD_LEFT 0x06u +#define DS4_DPAD_UP_LEFT 0x07u +#define DS4_DPAD_NEUTRAL 0x08u + +typedef struct { + int8_t LX; + int8_t LY; + int8_t RX; + int8_t RY; + uint16_t Buttons; + uint8_t DPad; + uint8_t L2; + uint8_t R2; + uint16_t Touch1X; + uint16_t Touch1Y; + uint8_t Touch1Active; + uint16_t Touch2X; + uint16_t Touch2Y; + uint8_t Touch2Active; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; +} DS4DeviceState; + +typedef struct { + const char* SerialNumber; // NULL = use default + const char* Board; // NULL = use default + uint8_t BatteryStatus; // 0 = use default + double TemperatureCelsius; // 0 = use default + double BatteryVoltage; // 0 = use default +} DS4MetaState; + +typedef void (*DS4OutputCallback)(DS4DeviceHandle handle, uint8_t rumbleSmall, uint8_t rumbleLarge, uint8_t ledRed, uint8_t ledGreen, uint8_t ledBlue, uint8_t flashOn, uint8_t flashOff); + +static void viiper_call_ds4_output(DS4OutputCallback fn, DS4DeviceHandle handle, uint8_t rumbleSmall, uint8_t rumbleLarge, uint8_t ledRed, uint8_t ledGreen, uint8_t ledBlue, uint8_t flashOn, uint8_t flashOff) { + fn(handle, rumbleSmall, rumbleLarge, ledRed, ledGreen, ledBlue, flashOn, flashOff); +} + +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateDS4Device creates a new DualShock 4 device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// @param meta Optional pointer to initial device metadata. Pass NULL to use defaults. +// +//export CreateDS4Device +func CreateDS4Device( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DS4DeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + meta *C.DS4MetaState, +) bool { + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + if meta != nil { + goMeta := dualshock4.MetaState{ + SerialNumber: goStringOrEmpty(meta.SerialNumber), + Board: goStringOrEmpty(meta.Board), + BatteryStatus: uint8(meta.BatteryStatus), + TemperatureCelsius: float64(meta.TemperatureCelsius), + BatteryVoltage: float64(meta.BatteryVoltage), + } + b, err := json.Marshal(goMeta) + if err != nil { + return false + } + opts.DeviceSpecific = string(b) + } + + d, err := dualshock4.New(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.DS4DeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetDS4DeviceState updates the input state of the DualShock 4 device associated with the given handle. +// @param handle Handle to the DS4 device. +// @param state New input state to set on the device. +// +//export SetDS4DeviceState +func SetDS4DeviceState(handle C.DS4DeviceHandle, state C.DS4DeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + ds4device, ok := dhw.device.(*dualshock4.DualShock4) + if !ok { + return false + } + s := &dualshock4.InputState{ + LX: int8(state.LX), + LY: int8(state.LY), + RX: int8(state.RX), + RY: int8(state.RY), + Buttons: uint16(state.Buttons), + DPad: uint8(state.DPad), + L2: uint8(state.L2), + R2: uint8(state.R2), + Touch1X: uint16(state.Touch1X), + Touch1Y: uint16(state.Touch1Y), + Touch1Active: state.Touch1Active != 0, + Touch2X: uint16(state.Touch2X), + Touch2Y: uint16(state.Touch2Y), + Touch2Active: state.Touch2Active != 0, + GyroX: int16(state.GyroX), + GyroY: int16(state.GyroY), + GyroZ: int16(state.GyroZ), + AccelX: int16(state.AccelX), + AccelY: int16(state.AccelY), + AccelZ: int16(state.AccelZ), + } + ds4device.UpdateInputState(s) + return true +} + +// SetDS4OutputCallback sets a callback to be invoked when the host sends output (rumble/LED) commands to the device. +// @param handle Handle to the DS4 device. +// @param callback Callback receiving rumbleSmall, rumbleLarge, ledRed, ledGreen, ledBlue, flashOn, flashOff. Pass NULL to clear. +// +//export SetDS4OutputCallback +func SetDS4OutputCallback(handle C.DS4DeviceHandle, cb C.DS4OutputCallback) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + ds4device, ok := dhw.device.(*dualshock4.DualShock4) + if !ok { + return false + } + if cb == nil { + ds4device.SetOutputCallback(nil) + return true + } + ds4device.SetOutputCallback(func(out dualshock4.OutputState) { + C.viiper_call_ds4_output(cb, handle, + C.uint8_t(out.RumbleSmall), + C.uint8_t(out.RumbleLarge), + C.uint8_t(out.LedRed), + C.uint8_t(out.LedGreen), + C.uint8_t(out.LedBlue), + C.uint8_t(out.FlashOn), + C.uint8_t(out.FlashOff), + ) + }) + return true +} + +// RemoveDS4Device removes the DualShock 4 device associated with the given handle from the server. +// @param handle Handle to the DS4 device to remove. +// +//export RemoveDS4Device +func RemoveDS4Device(handle C.DS4DeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} diff --git a/lib/viiper/keyboard.go b/lib/viiper/keyboard.go new file mode 100644 index 00000000..6d61a121 --- /dev/null +++ b/lib/viiper/keyboard.go @@ -0,0 +1,316 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t KeyboardDeviceHandle; + +#define KB_MOD_LEFT_CTRL 0x01u +#define KB_MOD_LEFT_SHIFT 0x02u +#define KB_MOD_LEFT_ALT 0x04u +#define KB_MOD_LEFT_GUI 0x08u +#define KB_MOD_RIGHT_CTRL 0x10u +#define KB_MOD_RIGHT_SHIFT 0x20u +#define KB_MOD_RIGHT_ALT 0x40u +#define KB_MOD_RIGHT_GUI 0x80u + +#define KB_LED_NUM_LOCK 0x01u +#define KB_LED_CAPS_LOCK 0x02u +#define KB_LED_SCROLL_LOCK 0x04u +#define KB_LED_COMPOSE 0x08u +#define KB_LED_KANA 0x10u + +#define KB_KEY_A 0x04u +#define KB_KEY_B 0x05u +#define KB_KEY_C 0x06u +#define KB_KEY_D 0x07u +#define KB_KEY_E 0x08u +#define KB_KEY_F 0x09u +#define KB_KEY_G 0x0Au +#define KB_KEY_H 0x0Bu +#define KB_KEY_I 0x0Cu +#define KB_KEY_J 0x0Du +#define KB_KEY_K 0x0Eu +#define KB_KEY_L 0x0Fu +#define KB_KEY_M 0x10u +#define KB_KEY_N 0x11u +#define KB_KEY_O 0x12u +#define KB_KEY_P 0x13u +#define KB_KEY_Q 0x14u +#define KB_KEY_R 0x15u +#define KB_KEY_S 0x16u +#define KB_KEY_T 0x17u +#define KB_KEY_U 0x18u +#define KB_KEY_V 0x19u +#define KB_KEY_W 0x1Au +#define KB_KEY_X 0x1Bu +#define KB_KEY_Y 0x1Cu +#define KB_KEY_Z 0x1Du +#define KB_KEY_1 0x1Eu +#define KB_KEY_2 0x1Fu +#define KB_KEY_3 0x20u +#define KB_KEY_4 0x21u +#define KB_KEY_5 0x22u +#define KB_KEY_6 0x23u +#define KB_KEY_7 0x24u +#define KB_KEY_8 0x25u +#define KB_KEY_9 0x26u +#define KB_KEY_0 0x27u +#define KB_KEY_ENTER 0x28u +#define KB_KEY_ESCAPE 0x29u +#define KB_KEY_BACKSPACE 0x2Au +#define KB_KEY_TAB 0x2Bu +#define KB_KEY_SPACE 0x2Cu +#define KB_KEY_MINUS 0x2Du +#define KB_KEY_EQUAL 0x2Eu +#define KB_KEY_LEFT_BRACE 0x2Fu +#define KB_KEY_RIGHT_BRACE 0x30u +#define KB_KEY_BACKSLASH 0x31u +#define KB_KEY_SEMICOLON 0x33u +#define KB_KEY_APOSTROPHE 0x34u +#define KB_KEY_GRAVE 0x35u +#define KB_KEY_COMMA 0x36u +#define KB_KEY_PERIOD 0x37u +#define KB_KEY_SLASH 0x38u +#define KB_KEY_CAPS_LOCK 0x39u +#define KB_KEY_F1 0x3Au +#define KB_KEY_F2 0x3Bu +#define KB_KEY_F3 0x3Cu +#define KB_KEY_F4 0x3Du +#define KB_KEY_F5 0x3Eu +#define KB_KEY_F6 0x3Fu +#define KB_KEY_F7 0x40u +#define KB_KEY_F8 0x41u +#define KB_KEY_F9 0x42u +#define KB_KEY_F10 0x43u +#define KB_KEY_F11 0x44u +#define KB_KEY_F12 0x45u +#define KB_KEY_PRINT_SCREEN 0x46u +#define KB_KEY_SCROLL_LOCK 0x47u +#define KB_KEY_PAUSE 0x48u +#define KB_KEY_INSERT 0x49u +#define KB_KEY_HOME 0x4Au +#define KB_KEY_PAGE_UP 0x4Bu +#define KB_KEY_DELETE 0x4Cu +#define KB_KEY_END 0x4Du +#define KB_KEY_PAGE_DOWN 0x4Eu +#define KB_KEY_RIGHT 0x4Fu +#define KB_KEY_LEFT 0x50u +#define KB_KEY_DOWN 0x51u +#define KB_KEY_UP 0x52u +#define KB_KEY_NUM_LOCK 0x53u +#define KB_KEY_KP_SLASH 0x54u +#define KB_KEY_KP_ASTERISK 0x55u +#define KB_KEY_KP_MINUS 0x56u +#define KB_KEY_KP_PLUS 0x57u +#define KB_KEY_KP_ENTER 0x58u +#define KB_KEY_KP_1 0x59u +#define KB_KEY_KP_2 0x5Au +#define KB_KEY_KP_3 0x5Bu +#define KB_KEY_KP_4 0x5Cu +#define KB_KEY_KP_5 0x5Du +#define KB_KEY_KP_6 0x5Eu +#define KB_KEY_KP_7 0x5Fu +#define KB_KEY_KP_8 0x60u +#define KB_KEY_KP_9 0x61u +#define KB_KEY_KP_0 0x62u +#define KB_KEY_KP_DOT 0x63u +#define KB_KEY_MUTE 0x7Fu +#define KB_KEY_VOLUME_UP 0x80u +#define KB_KEY_VOLUME_DOWN 0x81u + +typedef struct { + uint8_t Modifiers; + uint8_t KeyBitmap[32]; +} KeyboardDeviceState; + +typedef void (*KeyboardLEDCallback)(KeyboardDeviceHandle handle, uint8_t leds); + +static void viiper_call_kb_led(KeyboardLEDCallback fn, KeyboardDeviceHandle handle, uint8_t leds) { + fn(handle, leds); +} + +*/ +import "C" +import ( + "context" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateKeyboardDevice creates a new HID keyboard device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// +//export CreateKeyboardDevice +func CreateKeyboardDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.KeyboardDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, +) bool { + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + + d, err := keyboard.New(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.KeyboardDeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetKeyboardDeviceState updates the input state of the keyboard device associated with the given handle. +// @param handle Handle to the keyboard device. +// @param state New input state (Modifiers bitmask + 256-bit key bitmap). +// +//export SetKeyboardDeviceState +func SetKeyboardDeviceState(handle C.KeyboardDeviceHandle, state C.KeyboardDeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + kbDevice, ok := dhw.device.(*keyboard.Keyboard) + if !ok { + return false + } + s := keyboard.InputState{ + Modifiers: uint8(state.Modifiers), + } + for i, v := range state.KeyBitmap { + s.KeyBitmap[i] = byte(v) + } + kbDevice.UpdateInputState(s) + return true +} + +// SetKeyboardLEDCallback sets a callback to be invoked when the host changes keyboard LED state. +// @param handle Handle to the keyboard device. +// @param callback Callback receiving the raw LED bitmask byte (KB_LED_* flags). Pass NULL to clear. +// +//export SetKeyboardLEDCallback +func SetKeyboardLEDCallback(handle C.KeyboardDeviceHandle, cb C.KeyboardLEDCallback) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + kbDevice, ok := dhw.device.(*keyboard.Keyboard) + if !ok { + return false + } + if cb == nil { + kbDevice.SetLEDCallback(nil) + return true + } + kbDevice.SetLEDCallback(func(led keyboard.LEDState) { + var raw C.uint8_t + if led.NumLock { + raw |= 0x01 + } + if led.CapsLock { + raw |= 0x02 + } + if led.ScrollLock { + raw |= 0x04 + } + if led.Compose { + raw |= 0x08 + } + if led.Kana { + raw |= 0x10 + } + C.viiper_call_kb_led(cb, handle, raw) + }) + return true +} + +// RemoveKeyboardDevice removes the keyboard device associated with the given handle from the server. +// @param handle Handle to the keyboard device to remove. +// +//export RemoveKeyboardDevice +func RemoveKeyboardDevice(handle C.KeyboardDeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} diff --git a/lib/viiper/mouse.go b/lib/viiper/mouse.go new file mode 100644 index 00000000..37d1d889 --- /dev/null +++ b/lib/viiper/mouse.go @@ -0,0 +1,164 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t MouseDeviceHandle; + +#define MOUSE_BTN_LEFT 0x01u +#define MOUSE_BTN_RIGHT 0x02u +#define MOUSE_BTN_MIDDLE 0x04u +#define MOUSE_BTN_BACK 0x08u +#define MOUSE_BTN_FORWARD 0x10u + +typedef struct { + uint8_t Buttons; + int16_t DX; + int16_t DY; + int16_t Wheel; + int16_t Pan; +} MouseDeviceState; + +*/ +import "C" +import ( + "context" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateMouseDevice creates a new HID mouse device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// +//export CreateMouseDevice +func CreateMouseDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.MouseDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, +) bool { + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + + d, err := mouse.New(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.MouseDeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetMouseDeviceState updates the input state of the mouse device associated with the given handle. +// @param handle Handle to the mouse device. +// @param state New input state. DX/DY/Wheel/Pan are relative and consumed each poll cycle. +// +//export SetMouseDeviceState +func SetMouseDeviceState(handle C.MouseDeviceHandle, state C.MouseDeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + mouseDevice, ok := dhw.device.(*mouse.Mouse) + if !ok { + return false + } + mouseDevice.UpdateInputState(mouse.InputState{ + Buttons: uint8(state.Buttons), + DX: int16(state.DX), + DY: int16(state.DY), + Wheel: int16(state.Wheel), + Pan: int16(state.Pan), + }) + return true +} + +// RemoveMouseDevice removes the mouse device associated with the given handle from the server. +// @param handle Handle to the mouse device to remove. +// +//export RemoveMouseDevice +func RemoveMouseDevice(handle C.MouseDeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} diff --git a/lib/viiper/ns2pro.go b/lib/viiper/ns2pro.go new file mode 100644 index 00000000..201e617c --- /dev/null +++ b/lib/viiper/ns2pro.go @@ -0,0 +1,274 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t NS2ProDeviceHandle; + +#define NS2PRO_BUTTON_B 0x00000001u +#define NS2PRO_BUTTON_A 0x00000002u +#define NS2PRO_BUTTON_Y 0x00000004u +#define NS2PRO_BUTTON_X 0x00000008u +#define NS2PRO_BUTTON_R 0x00000010u +#define NS2PRO_BUTTON_ZR 0x00000020u +#define NS2PRO_BUTTON_PLUS 0x00000040u +#define NS2PRO_BUTTON_RIGHT_STICK 0x00000080u +#define NS2PRO_BUTTON_DOWN 0x00000100u +#define NS2PRO_BUTTON_RIGHT 0x00000200u +#define NS2PRO_BUTTON_LEFT 0x00000400u +#define NS2PRO_BUTTON_UP 0x00000800u +#define NS2PRO_BUTTON_L 0x00001000u +#define NS2PRO_BUTTON_ZL 0x00002000u +#define NS2PRO_BUTTON_MINUS 0x00004000u +#define NS2PRO_BUTTON_LEFT_STICK 0x00008000u +#define NS2PRO_BUTTON_HOME 0x00010000u +#define NS2PRO_BUTTON_CAPTURE 0x00020000u +#define NS2PRO_BUTTON_GR 0x00040000u +#define NS2PRO_BUTTON_GL 0x00080000u +#define NS2PRO_BUTTON_C 0x00100000u +#define NS2PRO_BUTTON_HEADSET 0x00200000u + +#define NS2PRO_STICK_MIN 0x0000u +#define NS2PRO_STICK_CENTER 0x0800u +#define NS2PRO_STICK_MAX 0x0FFFu + +#define NS2PRO_FEATURE_BUTTONS 0x01u +#define NS2PRO_FEATURE_STICKS 0x02u +#define NS2PRO_FEATURE_IMU 0x04u +#define NS2PRO_FEATURE_MOUSE 0x10u +#define NS2PRO_FEATURE_RUMBLE 0x20u + +typedef struct { + uint32_t Buttons; + uint16_t LX; + uint16_t LY; + uint16_t RX; + uint16_t RY; + int16_t AccelX; + int16_t AccelY; + int16_t AccelZ; + int16_t GyroX; + int16_t GyroY; + int16_t GyroZ; +} NS2ProDeviceState; + +typedef struct { + const char* SerialNumber; // NULL = use default + uint8_t BatteryLevel; // 0-9; 0 = use default (9 = full) + uint8_t Charging; // 0 = not charging + uint8_t ExternalPower; // 0 = battery only + uint16_t BatteryVolts; // mV; 0 = use default (3800) +} NS2ProMetaState; + +typedef struct { + uint8_t LeftRumble[16]; + uint8_t RightRumble[16]; + uint8_t Flags; + uint8_t PlayerLedMask; +} NS2ProOutputState; + +typedef void (*NS2ProOutputCallback)(NS2ProDeviceHandle handle, NS2ProOutputState output); + +static void viiper_call_ns2pro_output(NS2ProOutputCallback fn, NS2ProDeviceHandle handle, NS2ProOutputState output) { + fn(handle, output); +} + +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/ns2pro" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateNS2ProDevice creates a new Nintendo Switch 2 Pro Controller device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. +// @param idVendor Optional USB vendor ID (0 = default). +// @param idProduct Optional USB product ID (0 = default). +// @param meta Optional pointer to initial device metadata. Pass NULL to use defaults. +// +//export CreateNS2ProDevice +func CreateNS2ProDevice( + serverHandle C.USBServerHandle, + outDeviceHandle *C.NS2ProDeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + meta *C.NS2ProMetaState, +) bool { + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + if meta != nil { + goMeta := ns2pro.MetaState{ + SerialNumber: goStringOrEmpty(meta.SerialNumber), + BatteryLevel: uint8(meta.BatteryLevel), + Charging: meta.Charging != 0, + ExternalPower: meta.ExternalPower != 0, + BatteryVolts: uint16(meta.BatteryVolts), + } + b, err := json.Marshal(goMeta) + if err != nil { + return false + } + opts.DeviceSpecific = string(b) + } + + d, err := ns2pro.New(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.NS2ProDeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetNS2ProDeviceState updates the input state of the NS2Pro device associated with the given handle. +// @param handle Handle to the NS2Pro device. +// @param state New input state to set on the device. +// +//export SetNS2ProDeviceState +func SetNS2ProDeviceState(handle C.NS2ProDeviceHandle, state C.NS2ProDeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + ns2device, ok := dhw.device.(*ns2pro.NS2Pro) + if !ok { + return false + } + s := ns2pro.InputState{ + Buttons: uint32(state.Buttons), + LX: uint16(state.LX), + LY: uint16(state.LY), + RX: uint16(state.RX), + RY: uint16(state.RY), + AccelX: int16(state.AccelX), + AccelY: int16(state.AccelY), + AccelZ: int16(state.AccelZ), + GyroX: int16(state.GyroX), + GyroY: int16(state.GyroY), + GyroZ: int16(state.GyroZ), + } + ns2device.UpdateInputState(s) + return true +} + +// SetNS2ProOutputCallback sets a callback to be invoked when the host sends output (rumble/LED) commands to the device. +// @param handle Handle to the NS2Pro device. +// @param callback Callback receiving the full output state (HD rumble data, flags, player LED mask). Pass NULL to clear. +// +//export SetNS2ProOutputCallback +func SetNS2ProOutputCallback(handle C.NS2ProDeviceHandle, cb C.NS2ProOutputCallback) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + ns2device, ok := dhw.device.(*ns2pro.NS2Pro) + if !ok { + return false + } + if cb == nil { + ns2device.SetOutputCallback(nil) + return true + } + ns2device.SetOutputCallback(func(out ns2pro.OutputState) { + var cOut C.NS2ProOutputState + for i := 0; i < 16; i++ { + cOut.LeftRumble[i] = C.uint8_t(out.LeftRumble[i]) + cOut.RightRumble[i] = C.uint8_t(out.RightRumble[i]) + } + cOut.Flags = C.uint8_t(out.Flags) + cOut.PlayerLedMask = C.uint8_t(out.PlayerLedMask) + C.viiper_call_ns2pro_output(cb, handle, cOut) + }) + return true +} + +// RemoveNS2ProDevice removes the NS2Pro device associated with the given handle from the server. +// @param handle Handle to the NS2Pro device to remove. +// +//export RemoveNS2ProDevice +func RemoveNS2ProDevice(handle C.NS2ProDeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} diff --git a/lib/viiper/postbuild/main.go b/lib/viiper/postbuild/main.go new file mode 100644 index 00000000..3b4ee376 --- /dev/null +++ b/lib/viiper/postbuild/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" +) + +func main() { + entries, _ := os.ReadDir("lib/viiper") + fset := token.NewFileSet() + comments := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + file, _ := parser.ParseFile(fset, "lib/viiper/"+e.Name(), nil, parser.ParseComments) + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Doc == nil { + continue + } + var name string + var lines []string + for _, c := range fn.Doc.List { + if n, ok := strings.CutPrefix(c.Text, "//export "); ok { + name = n + } else { + line, ok := strings.CutPrefix(c.Text, "// ") + if !ok { + line, _ = strings.CutPrefix(c.Text, "//") + } + lines = append(lines, line) + } + } + if name != "" && len(lines) > 0 { + comments[name] = strings.Join(lines, "\n") + } + } + } + + data, _ := os.ReadFile("dist/libVIIPER/libVIIPER.h") + var out []string + for line := range strings.SplitSeq(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "extern ") { + for _, p := range strings.Fields(line)[1:] { + if before, _, ok := strings.Cut(p, "("); ok { + if doc, ok := comments[before]; ok { + out = append(out, "/*") + for _, dl := range strings.Split(doc, "\n") { + out = append(out, " * "+dl) + } + out = append(out, " */") + } + break + } + } + } + out = append(out, line) + } + os.WriteFile("dist/libVIIPER/libVIIPER.h", []byte(strings.Join(out, "\n")), 0644) // nolint +} diff --git a/lib/viiper/server.go b/lib/viiper/server.go new file mode 100644 index 00000000..a7f24203 --- /dev/null +++ b/lib/viiper/server.go @@ -0,0 +1,134 @@ +package main + +/* +#include +#include + +typedef struct { + char* addr; // default "0.0.0.0:3241" + uint64_t connection_timeout_ms; // default 30000 (30s) + uint64_t device_handler_connect_timeout_ms; // default 5000 (5s) + uint32_t write_batch_flush_interval_ms; // default 1 (1ms) +} USBServerConfig; + +typedef uintptr_t USBServerHandle; + +typedef enum { + VIIPER_LOG_DEBUG = -4, + VIIPER_LOG_INFO = 0, + VIIPER_LOG_WARN = 4, + VIIPER_LOG_ERROR = 8, +} VIIPERLogLevel; + +typedef void (*VIIPERLogCallback)(VIIPERLogLevel level, const char* message); + +static void viiper_call_log(VIIPERLogCallback fn, VIIPERLogLevel level, const char* msg) { + fn(level, msg); +} +*/ +import "C" + +import ( + "log/slog" + "runtime/cgo" + "time" + "unsafe" + + "github.com/Alia5/VIIPER/internal/server/usb" +) + +// NewUSBServer creates a new USB server with the given configuration and returns a handle to it. +// The server will run in the background and can be stopped by calling CloseUSBServer with the returned handle. +// @param config Server configuration +// @param outHandle Output parameter for the created server handle +// @param logCallback Optional callback function for log messages from the USB server +// +//export NewUSBServer +func NewUSBServer(config *C.USBServerConfig, outHandle *C.USBServerHandle, logCallback C.VIIPERLogCallback) bool { + addr := C.GoString(config.addr) + connectionTimeout := time.Duration(config.connection_timeout_ms) * time.Millisecond + busCleanupTimeout := time.Duration(config.device_handler_connect_timeout_ms) * time.Millisecond + writeBatchFlushInterval := time.Duration(config.write_batch_flush_interval_ms) * time.Millisecond + + if addr == "" { + addr = ":3241" + } + if connectionTimeout == 0 { + connectionTimeout = 30 * time.Second + } + if busCleanupTimeout == 0 { + busCleanupTimeout = 5 * time.Second + } + + var logger *slog.Logger + if logCallback != nil { + logger = slog.New(&funcLogHandler{ + func(level slog.Level, msg string) { + if logCallback == nil { + return + } + cMsg := C.CString(msg) + defer C.free(unsafe.Pointer(cMsg)) + C.viiper_call_log(logCallback, C.VIIPERLogLevel(level), cMsg) + }, + }) + } else { + logger = slog.New(slog.DiscardHandler) + } + slog.SetDefault(logger) + + s := usb.New(usb.ServerConfig{ + Addr: addr, + ConnectionTimeout: connectionTimeout, + BusCleanupTimeout: busCleanupTimeout, + WriteBatchFlushInterval: writeBatchFlushInterval, + }, logger, nil) + + readyChan := s.Ready() + errChan := make(chan error, 1) + + go func() { + errChan <- s.ListenAndServe() + }() + + select { + case <-readyChan: + *outHandle = C.USBServerHandle(cgo.NewHandle(&usbServerHandleWrapper{ + s: s, + deviceHandles: make(map[uint32][]deviceHandle), + })) + return true + case err := <-errChan: + logger.Error("NewUSBServer: ListenAndServe failed", "error", err) + return false + } +} + +// CloseUSBServer closes the USB server associated with the given handle. +// Automatically removes busses and devices associated with the server. +// @param handle Handle to the USB server to close. +// +//export CloseUSBServer +func CloseUSBServer(handle C.USBServerHandle) bool { + h := cgo.Handle(handle) + hw, ok := h.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + hw.mtx.Lock() + defer hw.mtx.Unlock() + + for busID, dhs := range hw.deviceHandles { + for _, dh := range dhs { + cgo.Handle(dh).Delete() + } + delete(hw.deviceHandles, busID) + } + hw.deviceHandles = nil + + if err := hw.s.Close(); err != nil { + return false + } + h.Delete() + return true +} diff --git a/lib/viiper/versioninfo.json b/lib/viiper/versioninfo.json new file mode 100644 index 00000000..60340f23 --- /dev/null +++ b/lib/viiper/versioninfo.json @@ -0,0 +1,41 @@ +{ + "FixedFileInfo": { + "FileVersion": { + "Major": 0, + "Minor": 0, + "Patch": 0, + "Build": 0 + }, + "ProductVersion": { + "Major": 0, + "Minor": 0, + "Patch": 0, + "Build": 0 + }, + "FileFlagsMask": "3f", + "FileFlags ": "00", + "FileOS": "040004", + "FileType": "02", + "FileSubType": "00" + }, + "StringFileInfo": { + "Comments": "Virtual Input over IP EmulatoR", + "CompanyName": "Peter Repukat", + "FileDescription": "libVIIPER - Virtual Input over IP EmulatoR", + "FileVersion": "0.0.0.0", + "InternalName": "libVIIPER", + "LegalCopyright": "Copyright (C) 2025-2026 Peter Repukat - GPL-3.0", + "LegalTrademarks": "", + "OriginalFilename": "libVIIPER.dll", + "PrivateBuild": "", + "ProductName": "VIIPER", + "ProductVersion": "0.0.0.0", + "SpecialBuild": "" + }, + "VarFileInfo": { + "Translation": { + "LangID": "0409", + "CharsetID": "04B0" + } + } +} diff --git a/lib/viiper/viiper.go b/lib/viiper/viiper.go new file mode 100644 index 00000000..f21b57ae --- /dev/null +++ b/lib/viiper/viiper.go @@ -0,0 +1,54 @@ +package main + +import "C" +import ( + "context" + "fmt" + "log/slog" + "runtime/cgo" + "strings" + "sync" + + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/usbip" +) + +func main() {} + +func goStringOrEmpty(p *C.char) string { + if p == nil { + return "" + } + return C.GoString(p) +} + +type deviceHandle cgo.Handle + +type usbServerHandleWrapper struct { + s *usb.Server + mtx sync.Mutex + deviceHandles map[uint32][]deviceHandle +} + +type deviceHandleWrapper struct { + device any + exportMeta *usbip.ExportMeta + usbServer *usbServerHandleWrapper +} + +// --- + +type funcLogHandler struct{ fn func(slog.Level, string) } + +func (h *funcLogHandler) Enabled(context.Context, slog.Level) bool { return true } +func (h *funcLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *funcLogHandler) WithGroup(string) slog.Handler { return h } +func (h *funcLogHandler) Handle(_ context.Context, r slog.Record) error { + msg := r.Message + r.Attrs(func(a slog.Attr) bool { + msg += fmt.Sprintf(" %s=%v", a.Key, a.Value) + return true + }) + h.fn(r.Level, strings.TrimSpace(msg)) + return nil +} diff --git a/lib/viiper/xbox360.go b/lib/viiper/xbox360.go new file mode 100644 index 00000000..80c3ee57 --- /dev/null +++ b/lib/viiper/xbox360.go @@ -0,0 +1,232 @@ +package main + +/* +#include +#include + +typedef uintptr_t USBServerHandle; + +typedef uintptr_t Xbox360DeviceHandle; + +#define XBOX360_BUTTON_DPAD_UP 0x0001u +#define XBOX360_BUTTON_DPAD_DOWN 0x0002u +#define XBOX360_BUTTON_DPAD_LEFT 0x0004u +#define XBOX360_BUTTON_DPAD_RIGHT 0x0008u +#define XBOX360_BUTTON_START 0x0010u +#define XBOX360_BUTTON_BACK 0x0020u +#define XBOX360_BUTTON_LTHUMB 0x0040u +#define XBOX360_BUTTON_RTHUMB 0x0080u +#define XBOX360_BUTTON_LSHOULDER 0x0100u +#define XBOX360_BUTTON_RSHOULDER 0x0200u +#define XBOX360_BUTTON_GUIDE 0x0400u +#define XBOX360_BUTTON_A 0x1000u +#define XBOX360_BUTTON_B 0x2000u +#define XBOX360_BUTTON_X 0x4000u +#define XBOX360_BUTTON_Y 0x8000u + +typedef struct { + // Button bitfield (lower 16 bits used typically), higher bits reserved + uint32_t Buttons; + // Triggers: 0-255 + uint8_t LT; + uint8_t RT; + // Sticks: signed 16-bit little endian values + int16_t LX; + int16_t LY; + int16_t RX; + int16_t RY; + uint8_t Reserved[6]; +} Xbox360DeviceState; + +typedef void (*Xbox360RumbleCallback)(Xbox360DeviceHandle handle, uint8_t leftMotor, uint8_t rightMotor); + +static void viiper_call_rumble(Xbox360RumbleCallback fn, Xbox360DeviceHandle handle, uint8_t left, uint8_t right) { + fn(handle, left, right); +} + +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "runtime/cgo" + "slices" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// CreateXbox360Device creates a new Xbox360 device on the bus with the given ID on the server associated with the given handle. +// @param serverHandle Handle to the USB server. +// @param outDeviceHandle Output parameter for the created device handle. +// @param busID ID of the bus to add the device to. +// @param idVendor Optional USB vendor ID to set on the device. +// @param idProduct Optional USB product ID to set on the device. +// @param xinputSubType Optional XInput subtype to set on the device (e.g. 0x01 for gamepad, 0x02 for wheel, etc.). (Default gamepad) +// @param autoAttachLocalhost If true, the device will be automatically attached to a USBIP-Client/Driver running on THIS machine. (uses IOCTL on windows, USBIP binary on linux) +// +//export CreateXbox360Device +func CreateXbox360Device( + serverHandle C.USBServerHandle, + outDeviceHandle *C.Xbox360DeviceHandle, + busID uint32, + autoAttachLocalhost bool, + idVendor uint16, + idProduct uint16, + xinputSubType uint8, +) bool { + + sh := cgo.Handle(serverHandle) + shw, ok := sh.Value().(*usbServerHandleWrapper) + if !ok { + return false + } + bus := shw.s.GetBus(busID) + if bus == nil { + return false + } + + opts := &device.CreateOptions{} + if idVendor != 0 { + opts.IDVendor = &idVendor + } + if idProduct != 0 { + opts.IDProduct = &idProduct + } + if xinputSubType != 0 { + subOpts := &xbox360.Xbox360CreateOptions{ + SubType: &xinputSubType, + } + str, err := json.Marshal(subOpts) + if err != nil { + return false + } + opts.DeviceSpecific = string(str) + } + d, err := xbox360.New(opts) + if err != nil { + return false + } + devCtx, err := bus.Add(d) + if err != nil { + return false + } + exportMeta := device.GetDeviceMeta(devCtx) + if exportMeta == nil { + return false + } + + if autoAttachLocalhost { + err := api.AttachLocalhostClient( + context.Background(), + exportMeta, + shw.s.GetListenPort(), + true, + slog.Default(), + ) + if err != nil { + slog.Error("failed to auto-attach localhost client", "error", err) + return false + } + } + + handleWrapper := &deviceHandleWrapper{ + device: d, + exportMeta: exportMeta, + usbServer: shw, + } + *outDeviceHandle = C.Xbox360DeviceHandle(cgo.NewHandle(handleWrapper)) + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = append(shw.deviceHandles[busID], deviceHandle(*outDeviceHandle)) + return true +} + +// SetXbox360DeviceState updates the input state of the Xbox360 device associated with the given handle. +// @param deviceHandle Handle to the Xbox360 device to update. +// @param state New input state to set on the device.^ +// +//export SetXbox360DeviceState +func SetXbox360DeviceState(handle C.Xbox360DeviceHandle, state C.Xbox360DeviceState) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + xbox360device, ok := dhw.device.(*xbox360.Xbox360) + if !ok { + return false + } + deviceState := xbox360.InputState{ + Buttons: uint32(state.Buttons), + LT: uint8(state.LT), + RT: uint8(state.RT), + LX: int16(state.LX), + LY: int16(state.LY), + RX: int16(state.RX), + RY: int16(state.RY), + } + for i, v := range state.Reserved { + deviceState.Reserved[i] = byte(v) + } + + xbox360device.UpdateInputState(deviceState) + + return true +} + +// RemoveXbox360Device removes the Xbox360 device associated with the given handle from the server. +// @param deviceHandle Handle to the Xbox360 device to remove. +// +//export RemoveXbox360Device +func RemoveXbox360Device(handle C.Xbox360DeviceHandle) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusID, fmt.Sprintf("%d", dhw.exportMeta.DevID)); err != nil { + return false + } + + shw := dhw.usbServer + busID := dhw.exportMeta.BusID + + shw.mtx.Lock() + defer shw.mtx.Unlock() + shw.deviceHandles[busID] = slices.DeleteFunc(shw.deviceHandles[busID], func(h deviceHandle) bool { + return h == deviceHandle(handle) + }) + dh.Delete() + + return true +} + +// SetXbox360RumbleCallback sets a callback to be invoked when the host sends rumble/motor commands to the device. +// @param handle Handle to the Xbox360 device. +// @param callback Callback function receiving the device handle and left/right motor intensities (0-255). Pass NULL to clear. +// +//export SetXbox360RumbleCallback +func SetXbox360RumbleCallback(handle C.Xbox360DeviceHandle, cb C.Xbox360RumbleCallback) bool { + dh := cgo.Handle(handle) + dhw, ok := dh.Value().(*deviceHandleWrapper) + if !ok { + return false + } + xbox360device, ok := dhw.device.(*xbox360.Xbox360) + if !ok { + return false + } + if cb == nil { + xbox360device.SetRumbleCallback(nil) + return true + } + xbox360device.SetRumbleCallback(func(rumble xbox360.XRumbleState) { + C.viiper_call_rumble(cb, handle, C.uint8_t(rumble.LeftMotor), C.uint8_t(rumble.RightMotor)) + }) + return true +} diff --git a/mkdocs.yml b/mkdocs.yml index 91928461..a5a108bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,33 +6,34 @@ repo_url: https://github.com/Alia5/VIIPER theme: name: material + logo: viiper.svg palette: - # Light mode - - media: "(prefers-color-scheme: light)" - scheme: default - primary: custom - accent: custom - toggle: - icon: material/brightness-7 - name: Switch to dark mode - # Dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: custom - accent: custom - toggle: - icon: material/brightness-4 - name: Switch to light mode + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode features: - - navigation.instant - - navigation.tracking - - navigation.tabs - - navigation.sections - - navigation.expand - - navigation.top - - search.suggest - - search.highlight - - content.code.copy + - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.suggest + - search.highlight + - content.code.copy extra: version: @@ -40,38 +41,53 @@ extra: default: stable extra_css: - - stylesheets/extra.css +- stylesheets/extra.css markdown_extensions: - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.superfences - - pymdownx.tabbed: - alternate_style: true - - admonition - - pymdownx.details - - attr_list - - md_in_html - - tables +- pymdownx.highlight: + anchor_linenums: true +- pymdownx.superfences +- pymdownx.tabbed: + alternate_style: true +- admonition +- pymdownx.details +- attr_list +- md_in_html +- tables nav: - - Home: index.md - - Getting Started: - - Installation: getting-started/installation.md - - Quick Start: getting-started/quickstart.md - - CLI Reference: - - Overview: cli/overview.md - - Server Command: cli/server.md - - Proxy Command: cli/proxy.md - - Code Generation: cli/codegen.md - - Configuration: cli/configuration.md - - API & Clients: - - API Overview: api/overview.md - - Go Client: clients/go.md - - Generator Documentation: clients/generator.md - - C SDK: clients/c.md - - Devices: - - Xbox 360 Controller: devices/xbox360.md - - Keyboard: devices/keyboard.md - - Mouse: devices/mouse.md - - Changelog: changelog/ +- Home: index.md +- Getting Started: + - Installation: getting-started/installation.md + - Quick Start: getting-started/quickstart.md +- CLI Reference: + - Overview: cli/overview.md + - Server Command: cli/server.md + - Proxy Command: cli/proxy.md + - Code Generation: cli/codegen.md + - Configuration: cli/configuration.md +- API & Clients: + - API Overview: api/overview.md + - Go Client: clients/go.md + - C++ Client Library: clients/cpp.md + - C# Client Library: clients/csharp.md + - Rust Client Library: clients/rust.md + - TypeScript Client Library: clients/typescript.md + - Generator Documentation: clients/generator.md +- libVIIPER: + - Overview: libviiper/overview.md + - Xbox 360 Controller: devices/xbox360.md + - DualShock 4: devices/dualshock4.md + - DualSense: devices/dualsense.md + - Switch 2 Pro Controller: devices/ns2pro.md + - Keyboard: devices/keyboard.md + - Mouse: devices/mouse.md +- Devices: + - Xbox 360 Controller: devices/xbox360.md + - DualShock 4 Controller: devices/dualshock4.md + - DualSense Controller: devices/dualsense.md + - Switch 2 Pro Controller: devices/ns2pro.md + - Keyboard: devices/keyboard.md + - Mouse: devices/mouse.md +- Community & Support: misc/support.md +- Changelog: changelog/ diff --git a/scripts/inject-version.ps1 b/scripts/inject-version.ps1 new file mode 100644 index 00000000..5c84463a --- /dev/null +++ b/scripts/inject-version.ps1 @@ -0,0 +1,38 @@ +param( + [string]$Version, + [string]$InputJson, + [string]$OutputJson +) + +$ver = $Version.TrimStart('v') +$major = 0 +$minor = 0 +$patch = 0 +$build = 0 + +if ($ver -match '^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[.-](\d+))?') { + $major = [int]$Matches[1] + if ($Matches[2]) { + $minor = [int]$Matches[2] + } + if ($Matches[3]) { + $patch = [int]$Matches[3] + } + if ($Matches[4]) { + $build = [int]$Matches[4] + } +} + +$json = Get-Content $InputJson -Raw | ConvertFrom-Json +$json.FixedFileInfo.FileVersion.Major = $major +$json.FixedFileInfo.FileVersion.Minor = $minor +$json.FixedFileInfo.FileVersion.Patch = $patch +$json.FixedFileInfo.FileVersion.Build = $build +$json.FixedFileInfo.ProductVersion.Major = $major +$json.FixedFileInfo.ProductVersion.Minor = $minor +$json.FixedFileInfo.ProductVersion.Patch = $patch +$json.FixedFileInfo.ProductVersion.Build = $build +$json.StringFileInfo.FileVersion = "$major.$minor.$patch.$build" +$json.StringFileInfo.ProductVersion = $ver + +$json | ConvertTo-Json -Depth 10 | Set-Content $OutputJson diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..7fdb0f19 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,232 @@ +$ErrorActionPreference = "Stop" + +$viiperVersion = "dev-snapshot" + +$repo = "hbashton/VIIPER" +$apiUrl = if ($viiperVersion -eq "dev-snapshot" -or $viiperVersion -eq "latest") { + "https://api.github.com/repos/$repo/releases/latest" +} +else { + "https://api.github.com/repos/$repo/releases/tags/$viiperVersion" +} + +Write-Host "Fetching VIIPER release: $viiperVersion..." +$releaseData = Invoke-RestMethod -Uri $apiUrl -ErrorAction Stop +$version = $releaseData.tag_name + +if (-not $version) { + Write-Host "Error: Could not fetch VIIPER release" -ForegroundColor Red + exit 1 +} + +Write-Host "Version: $version" + +$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { + Write-Host "Error: Only 64-bit Windows is supported" -ForegroundColor Red + exit 1 +} + +if ((Get-CimInstance Win32_ComputerSystem).SystemType -match "ARM") { + Write-Host "Error: The current hbashton VIIPER package supports Windows x64 only" -ForegroundColor Red + exit 1 +} + +$preferredAssetNames = @("viiper-windows-$arch.zip", "viiper.exe") +$asset = $null +foreach ($assetName in $preferredAssetNames) { + $asset = $releaseData.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if ($asset) { break } +} + +if (-not $asset) { + $availableAssets = ($releaseData.assets | ForEach-Object { $_.name }) -join ", " + throw "Release '$version' in $repo does not contain a supported Windows x64 asset. Assets found: $availableAssets" +} + +$downloadUrl = $asset.browser_download_url + +Write-Host "Downloading from: $downloadUrl" +$tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } + +try { + function Get-ViiperVersion($path) { + try { + $help = & $path --help -p + $match = ($help | Select-String -Pattern "Version:\s*([^\s]+)" -AllMatches | Select-Object -First 1) + if ($match) { + return $match.Matches[0].Groups[1].Value + } + } + catch { } + return $null + } + + function Parse-VersionOrNull($ver) { + if (-not $ver) { return $null } + $clean = $ver.Trim().TrimStart('v', 'V') + $clean = $clean.Split('-')[0] + try { return [Version]$clean } + catch { return $null } + } + + $tempDownload = Join-Path $tempDir $asset.name + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempDownload -ErrorAction Stop + + if ([IO.Path]::GetExtension($asset.name) -eq ".zip") { + $extractDir = Join-Path $tempDir "release" + Expand-Archive -LiteralPath $tempDownload -DestinationPath $extractDir -Force + $tempViiper = Get-ChildItem -Path $extractDir -Recurse -Filter "viiper.exe" | + Select-Object -First 1 -ExpandProperty FullName + if (-not $tempViiper) { + throw "Downloaded VIIPER archive did not contain viiper.exe" + } + } + else { + $tempViiper = $tempDownload + } + + $newVersion = Get-ViiperVersion $tempViiper + if (-not $newVersion) { $newVersion = "unknown" } + Write-Host "Downloaded VIIPER version: $newVersion" + + $installDir = Join-Path $env:LOCALAPPDATA "VIIPER" + $installPath = Join-Path $installDir "viiper.exe" + $isUpdate = Test-Path $installPath + $skipInstall = $false + + $oldVersion = "unknown" + if ($isUpdate) { + Write-Host "Existing VIIPER installation detected. Preserving startup/autostart configuration..." + $oldVersionRaw = Get-ViiperVersion $installPath + if ($oldVersionRaw) { $oldVersion = $oldVersionRaw } + Write-Host "Installed VIIPER version: $oldVersion" + + $newV = Parse-VersionOrNull $newVersion + $oldV = Parse-VersionOrNull $oldVersion + + if ($newVersion -eq $oldVersion -and $newVersion -ne "unknown") { + Write-Host "Versions are identical. Skipping VIIPER install step." + $skipInstall = $true + } + elseif ($newV -and $oldV -and $newV -lt $oldV) { + Write-Host "Detected potential downgrade (installed: $oldVersion, new: $newVersion). Skipping install." -ForegroundColor Yellow + $skipInstall = $true + } + } + + if (-not $skipInstall) { + Write-Host "Installing binary to $installPath..." + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + + if ($isUpdate) { + $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.ExecutablePath -eq $installPath } + if ($procs) { + Write-Host "Stopping running VIIPER instance(s) so the binary can be updated..." + foreach ($p in $procs) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue + } + catch { } + } + Start-Sleep -Milliseconds 500 + } + } + + Copy-Item $tempViiper $installPath -Force + } + + Write-Host "" + Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan + + $usbipTargetVersion = [Version]"0.9.7.7" + $usbipInstalledVersion = $null + + $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like 'USBip version*' } | + Select-Object -First 1 + if (-not $usbipEntry) { + $usbipEntry = Get-ItemProperty "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like 'USBip version*' } | + Select-Object -First 1 + } + if ($usbipEntry) { + try { $usbipInstalledVersion = [Version]$usbipEntry.DisplayVersion } catch { } + } + + if (-not $usbipInstalledVersion) { + $driverPath = Join-Path $env:SystemRoot "System32\drivers\usbip2_ude.sys" + if (Test-Path $driverPath) { + try { $usbipInstalledVersion = [Version](Get-Item $driverPath).VersionInfo.FileVersion } catch { } + } + } + + $needsReboot = $false + $needsUsbipInstall = $true + + if ($usbipInstalledVersion) { + if ($usbipInstalledVersion -ge $usbipTargetVersion) { + Write-Host "USBIP drivers already up to date (installed: $usbipInstalledVersion)" -ForegroundColor Green + $needsUsbipInstall = $false + } + else { + Write-Host "USBIP drivers outdated (installed: $usbipInstalledVersion, required: $usbipTargetVersion). Updating..." -ForegroundColor Yellow + } + } + else { + Write-Host "USBIP drivers not found. Installing..." -ForegroundColor Yellow + } + + if ($needsUsbipInstall) { + Write-Host "This requires administrator privileges." -ForegroundColor Yellow + + $usbipInstallerUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.7/USBip-0.9.7.7-x64.exe" + $usbipInstaller = Join-Path $tempDir "USBip-setup.exe" + + try { + Write-Host " Downloading usbip-win2 installer..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $usbipInstallerUrl -OutFile $usbipInstaller -ErrorAction Stop + Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow + Start-Process -FilePath $usbipInstaller -ArgumentList "/S" -Verb RunAs -Wait + Write-Host "USBIP drivers installed/updated successfully" -ForegroundColor Green + $needsReboot = $true + } + catch { + Write-Host "Warning: Failed to install USBIP drivers - $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host "You may need to install usbip-win2 manually from:" -ForegroundColor Yellow + Write-Host " https://github.com/vadimgrn/usbip-win2/releases" -ForegroundColor Yellow + } + } + + if (-not $isUpdate) { + Write-Host "Configuring system startup..." + } + Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "install" + + Write-Host "VIIPER installed successfully!" -ForegroundColor Green + Write-Host "Binary installed to: $installPath" + if ($isUpdate) { + if ($skipInstall) { + Write-Host "Binary already at correct version or newer. Skipping binary copy." + } + else { + Write-Host "Update complete. Startup/autostart configuration was left unchanged." + } + Write-Host "VIIPER service has been restarted." + } + else { + Write-Host "VIIPER server is now running and will start automatically on boot." + } + + taskkill.exe /IM "viiper.exe" /F > $null 2>&1 + Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "server" + + if ($needsReboot) { + Write-Host "" + Write-Host "IMPORTANT: A system reboot is required for USBIP drivers to function properly." -ForegroundColor Yellow + Write-Host "Please restart your computer before using VIIPER." -ForegroundColor Yellow + } +} +finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 00000000..e96b418c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env sh + +set -e + +VIIPER_VERSION="dev-snapshot" + +REPO="Alia5/VIIPER" +API_URL="https://api.github.com/repos/${REPO}/releases/tags/${VIIPER_VERSION}" + +echo "Fetching VIIPER release: $VIIPER_VERSION..." +RELEASE_DATA=$(curl -fsSL "$API_URL") +VERSION=$(printf '%s' "$RELEASE_DATA" \ + | grep -Eo '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | head -n 1 \ + | cut -d'"' -f4) + +if [ -z "$VERSION" ]; then + echo "Error: Could not fetch VIIPER release" + exit 1 +fi + +echo "Version: $VERSION" + +ARCH=$(uname -m) + +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + echo "Error: Unsupported architecture: $ARCH" + echo "Supported: x86_64 (amd64), aarch64/arm64" + exit 1 + ;; +esac + +ARCHIVE_NAME="viiper-linux-${ARCH}.tar.gz" +DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE_NAME}" + +echo "Downloading from: $DOWNLOAD_URL" +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +cd "$TEMP_DIR" +if ! curl -fsSL -o release.tar.gz "$DOWNLOAD_URL"; then + echo "Error: Could not download VIIPER archive" + exit 1 +fi + +tar -xzf release.tar.gz +chmod +x viiper + +NEW_VERSION=$(./viiper --help -p | grep -Eo 'Version: [^ ]+' | head -1 | cut -d' ' -f2) +if [ -z "$NEW_VERSION" ]; then + echo "Warning: Could not extract version from downloaded binary" + NEW_VERSION="unknown" +fi +echo "Downloaded VIIPER version: $NEW_VERSION" + +INSTALL_DIR="/usr/local/bin" +INSTALL_PATH="$INSTALL_DIR/viiper" + +IS_UPDATE=0 +SKIP_INSTALL=0 +if [ -f "$INSTALL_PATH" ]; then + IS_UPDATE=1 + + OLD_VERSION=$("$INSTALL_PATH" --help -p | grep -Eo 'Version: [^ ]+' | head -1 | cut -d' ' -f2) + if [ -z "$OLD_VERSION" ]; then + echo "Warning: Could not extract version from installed binary" + OLD_VERSION="unknown" + fi + echo "Installed VIIPER version: $OLD_VERSION" + + if [ "$NEW_VERSION" = "$OLD_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then + echo "Versions are identical. Skipping VIIPER install step." + SKIP_INSTALL=1 + else + if [ "$NEW_VERSION" != "unknown" ] && [ "$OLD_VERSION" != "unknown" ]; then + LOWEST=$(printf '%s\n' "$OLD_VERSION" "$NEW_VERSION" | sort -V | head -n1) + if [ "$LOWEST" = "$NEW_VERSION" ] && [ "$OLD_VERSION" != "$NEW_VERSION" ]; then + echo "Detected potential downgrade (installed: $OLD_VERSION, new: $NEW_VERSION). Skipping install." + SKIP_INSTALL=1 + fi + fi + fi +fi + +if [ "$IS_UPDATE" -eq 1 ] && [ "$SKIP_INSTALL" -eq 0 ]; then + echo "Stopping VIIPER service if running..." + sudo systemctl stop viiper.service || true +fi + +if [ "$SKIP_INSTALL" -eq 0 ]; then + echo "Installing binary to $INSTALL_PATH..." + sudo mkdir -p "$INSTALL_DIR" + sudo cp viiper "$INSTALL_PATH" + sudo chmod +x "$INSTALL_PATH" +else + echo "Binary already at correct version, skipping installation." +fi + + +detect_package_manager() { + if command -v pacman >/dev/null ; then + echo "pacman" + return 0 + fi + if command -v apt >/dev/null ; then + echo "apt" + return 0 + fi + if command -v apt-get >/dev/null ; then + echo "apt" + return 0 + fi + if command -v dnf >/dev/null ; then + echo "dnf" + return 0 + fi + return 1 +} + +is_steamos() { + if command -v steamos-readonly >/dev/null; then + return 0 + fi + if [ -r /etc/os-release ] && grep -qi '^ID=steamos' /etc/os-release; then + return 0 + fi + return 1 +} + +STEAMOS_RW_TOGGLED=0 + +echo "" +echo "Checking USBIP installation..." + +if command -v usbip >/dev/null ; then + echo "USBIP already installed" +else + echo "USBIP not found. Installing..." + + if is_steamos; then + echo "SteamOS detected" + if command -v steamos-readonly >/dev/null ; then + if steamos-readonly status | grep -q "enabled"; then + echo "Read-only root is enabled. Temporarily disabling..." + if steamos-readonly disable; then + echo "Read-only root disabled" + STEAMOS_RW_TOGGLED=1 + else + echo "Warning: Could not disable read-only root. USBIP installation may fail." + fi + else + echo "Read-only root is already disabled" + fi + fi + fi + + PM=$(detect_package_manager) || PM="" + case "$PM" in + pacman) + echo "Installing USBIP via pacman..." + sudo pacman -S --noconfirm usbip || echo "Warning: USBIP installation failed" + ;; + apt) + echo "Installing USBIP via apt..." + sudo apt update + sudo apt install -y linux-tools-generic || echo "Warning: USBIP installation failed" + ;; + dnf) + echo "Installing USBIP via dnf..." + sudo dnf install -y usbip || echo "Warning: USBIP installation failed" + ;; + *) + echo "Warning: Could not detect package manager. Please install USBIP manually." + echo "See: https://alia5.github.io/VIIPER/stable/getting-started/installation/" + ;; + esac +fi + +if [ "$IS_UPDATE" -eq 1 ]; then + echo "Existing VIIPER installation detected. Preserving startup/autostart configuration..." +else + echo "Configuring system startup..." +fi + +echo "Checking vhci_hcd kernel module..." +MODULE_ALREADY_LOADED=0 +if lsmod | grep -q vhci_hcd; then + MODULE_ALREADY_LOADED=1 + echo "vhci_hcd module is already loaded" +else + echo "Loading vhci_hcd kernel module..." + if sudo modprobe vhci_hcd; then + echo "vhci_hcd module loaded" + else + echo "Warning: Could not load vhci_hcd module" + fi +fi + +MODULES_CONF="/etc/modules-load.d/viiper.conf" +if [ $MODULE_ALREADY_LOADED -eq 0 ]; then + echo "Configuring vhci_hcd to load at boot..." + if echo "vhci_hcd" | sudo tee "$MODULES_CONF" >/dev/null; then + echo "Module persistence configured: $MODULES_CONF" + else + echo "Warning: Could not configure module persistence" + fi +else + echo "vhci_hcd module is already loaded, skipping autoload configuration" +fi + +if [ "$SKIP_INSTALL" -eq 0 ]; then + echo "Creating systemd service..." + sudo "$INSTALL_PATH" install +fi + +if [ "$STEAMOS_RW_TOGGLED" -eq 1 ]; then + echo "Re-enabling SteamOS read-only root..." + steamos-readonly enable || echo "Warning: failed to re-enable read-only. You may re-enable it manually later." +fi + +echo "" +echo "VIIPER installed successfully!" +echo "Binary installed to: $INSTALL_PATH" + +if [ "$IS_UPDATE" -eq 1 ] && [ "$SKIP_INSTALL" -eq 0 ]; then + echo "Update complete. VIIPER service has been restarted." +elif [ "$IS_UPDATE" -eq 0 ]; then + echo "VIIPER server is now running and will start automatically on boot." +fi diff --git a/scripts/licenses.tpl b/scripts/licenses.tpl new file mode 100644 index 00000000..47a53010 --- /dev/null +++ b/scripts/licenses.tpl @@ -0,0 +1,37 @@ +{{ $viiperVersion := "VERSION_PLACEHOLDER" }} + +# github.com/Alia5/VIIPER + +* Name: github.com/Alia5/VIIPER +* Version: {{ $viiperVersion }} +* License: [GPL-3.0](https://github.com/Alia5/VIIPER/blob/HEAD/LICENSE.txt) + +VIIPER - Virtual Input over IP EmulatoR + +Copyright (C) 2025-2026 Peter Repukat + +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 . + +{{ range . }} +## {{ .Name }} + +* Name: {{ .Name }} +* Version: {{ .Version }} +* License: [{{ .LicenseName }}]({{ .LicenseURL }}) + +{{ if and .LicensePath (ne .LicensePath "Unknown") }} +{{ .LicenseText }} +{{ end }} + +{{ end }} \ No newline at end of file diff --git a/scripts/viiper-api.ps1 b/scripts/viiper-api.ps1 index 9427d1b1..efebce01 100644 --- a/scripts/viiper-api.ps1 +++ b/scripts/viiper-api.ps1 @@ -8,7 +8,7 @@ function Invoke-ViiperApi { Sends a command to the VIIPER API server and returns the response. .PARAMETER Command - The API command to send (e.g., "bus.list", "bus.create MyBus") + The API command to send (e.g., "bus/list", "bus/create MyBus") .PARAMETER Port The TCP port where the API server is listening. Default: 3242 @@ -17,13 +17,13 @@ function Invoke-ViiperApi { The hostname or IP address of the API server. Default: localhost .EXAMPLE - Invoke-ViiperApi "bus.list" + Invoke-ViiperApi "bus/list" .EXAMPLE - Invoke-ViiperApi "bus.create MyBus" -Port 3242 + Invoke-ViiperApi "bus/create MyBus" -Port 3242 .EXAMPLE - Invoke-ViiperApi "device.add MyBus xbox360" -Hostname "192.168.1.100" + Invoke-ViiperApi "bus/1/add {\"type\":\"xbox360\"}" -Hostname "192.168.1.100" #> param( [Parameter(Mandatory=$true, Position=0)] @@ -42,27 +42,13 @@ function Invoke-ViiperApi { $writer = New-Object System.IO.StreamWriter($stream) $reader = New-Object System.IO.StreamReader($stream) - $writer.WriteLine($Command) + # Send command with null terminator + $writer.Write($Command) + $writer.Write("`0") $writer.Flush() - # Read response line by line until we get everything available - $response = "" - $timeout = 1000 # 1 second timeout - $start = Get-Date - while ($true) { - if ($stream.DataAvailable) { - $response += $reader.ReadLine() + "`n" - } - elseif ($response.Length -gt 0) { - # Got some data and no more available, we're done - break - } - elseif (((Get-Date) - $start).TotalMilliseconds -gt $timeout) { - # Timeout waiting for response - break - } - Start-Sleep -Milliseconds 10 - } + # Read single line response + $response = $reader.ReadLine() $client.Close() diff --git a/scripts/viiper-api.sh b/scripts/viiper-api.sh index 9d946a4a..8736b546 100644 --- a/scripts/viiper-api.sh +++ b/scripts/viiper-api.sh @@ -60,7 +60,8 @@ if nc -h 2>&1 | grep -q -- "-q "; then fi -if ! OUTPUT=$(printf '%s\n' "$CMD" | nc $NC_QUIT -w "$TIMEOUT" "$HOST" "$PORT"); then +# Send command with null terminator (\0) — matches VIIPER API transport framing +if ! OUTPUT=$(printf '%s\0' "$CMD" | nc $NC_QUIT -w "$TIMEOUT" "$HOST" "$PORT"); then echo "Error: failed to connect to ${HOST}:${PORT} (is the VIIPER API running?)" >&2 exit 1 fi diff --git a/usb/device.go b/usb/device.go new file mode 100644 index 00000000..7863acf3 --- /dev/null +++ b/usb/device.go @@ -0,0 +1,46 @@ +package usb + +import "context" + +// Device is the minimal interface a device must implement. +// It only handles non-EP0 (interrupt/bulk) transfers. +type Device interface { + // HandleTransfer processes a non-EP0 transfer (interrupt/bulk). + // ep is the endpoint number (without direction). dir is usbip.DirIn or usbip.DirOut. + // For IN transfers the implementation should block until data is available or ctx is + // cancelled, then return the payload. For OUT transfers, consume 'out' and return nil. + HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte + GetDescriptor() *Descriptor + GetDeviceSpecificArgs() map[string]any +} + +// ControlDevice is an optional interface for devices that need to handle +// control transfers on endpoint 0 (EP0). +// +// This is primarily used for class-specific requests that are not covered by +// the server's built-in standard request handling (e.g. HID GET_REPORT/ +// SET_REPORT). +type ControlDevice interface { + // HandleControl handles a control request. + // + // - bmRequestType, bRequest, wValue, wIndex, wLength are the raw setup packet fields. + // - data is the OUT data stage payload (for host-to-device requests), and is nil for + // device-to-host requests. + // + // If handled is false, the server will fall back to its default behavior. + // If handled is true, the returned bytes (if any) will be used as the IN data stage. + HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) (resp []byte, handled bool) +} + +// InterfaceAltSettingDevice is an optional interface for devices that need to +// react when the host opens or closes alternate USB interfaces. +type InterfaceAltSettingDevice interface { + SetInterfaceAltSetting(iface, alt uint8) +} + +// EndpointResetDevice is notified after the host clears the halt feature on a +// known endpoint. Windows uses this standard request as part of pipe reset and +// audio stream teardown even for virtual isochronous endpoints. +type EndpointResetDevice interface { + ResetEndpoint(endpointAddress uint8) +} diff --git a/usb/hid/constants.go b/usb/hid/constants.go new file mode 100644 index 00000000..587fc2f4 --- /dev/null +++ b/usb/hid/constants.go @@ -0,0 +1,73 @@ +package hid + +// Common Usage Pages. +// Values per HID Usage Tables. +const ( + UsagePageGenericDesktop uint16 = 0x01 + UsagePageSimulation uint16 = 0x02 + UsagePageVR uint16 = 0x03 + UsagePageSport uint16 = 0x04 + UsagePageGame uint16 = 0x05 + UsagePageKeyboard uint16 = 0x07 + UsagePageLEDs uint16 = 0x08 + UsagePageButton uint16 = 0x09 + UsagePageConsumer uint16 = 0x0C +) + +// Generic Desktop usages. +const ( + UsagePointer uint16 = 0x01 + UsageMouse uint16 = 0x02 + UsageJoystick uint16 = 0x04 + UsageGamePad uint16 = 0x05 + UsageKeyboard uint16 = 0x06 + UsageX uint16 = 0x30 + UsageY uint16 = 0x31 + UsageZ uint16 = 0x32 + UsageRx uint16 = 0x33 + UsageRy uint16 = 0x34 + UsageRz uint16 = 0x35 + UsageWheel uint16 = 0x38 +) + +// Consumer usages. +const ( + UsageACPan uint16 = 0x0238 +) + +// CollectionKind values. +type CollectionKind uint8 + +const ( + CollectionPhysical CollectionKind = 0x00 + CollectionApplication CollectionKind = 0x01 + CollectionLogical CollectionKind = 0x02 +) + +type MainFlags uint8 + +const ( + MainData MainFlags = 0x00 + MainConst MainFlags = 0x01 + + MainArray MainFlags = 0x00 + MainVar MainFlags = 0x02 + + MainAbs MainFlags = 0x00 + MainRel MainFlags = 0x04 + + MainNoWrap MainFlags = 0x00 + MainWrap MainFlags = 0x08 + + MainLinear MainFlags = 0x00 + MainNonLinear MainFlags = 0x10 + + MainPreferredState MainFlags = 0x00 + MainNoPreferredState MainFlags = 0x20 + + MainNoNullPosition MainFlags = 0x00 + MainNullState MainFlags = 0x40 + + MainNonVolatile MainFlags = 0x00 + MainVolatile MainFlags = 0x80 +) diff --git a/usb/hid/hid.go b/usb/hid/hid.go new file mode 100644 index 00000000..d2d1aacc --- /dev/null +++ b/usb/hid/hid.go @@ -0,0 +1,145 @@ +// Package hid provides a structured representation of HID report descriptors. +// +// A HID report descriptor is a byte-coded DSL. This package models it as a tree +// of Go structs (including nested collections) and encodes it to the exact +// descriptor byte stream. +package hid + +import ( + "fmt" +) + +// Data is a strongly-typed byte slice used for HID report descriptor payloads. +// +// It exists to avoid exposing raw []byte fields on report descriptor models. +// The underlying representation is still bytes because that is what the USB/HID +// specification ultimately requires. +type Data []uint8 + +// ItemType is the HID short item "type" field. +// See HID 1.11 spec: Main=0, Global=1, Local=2, Reserved=3. +type ItemType uint8 + +const ( + ItemTypeMain ItemType = 0 + ItemTypeGlobal ItemType = 1 + ItemTypeLocal ItemType = 2 + ItemTypeReserved ItemType = 3 +) + +// Item is one node in a HID report descriptor. +type Item interface { + encode(e *encoder) error +} + +// ReportDescriptor is a complete HID report descriptor (type 0x22). +type ReportDescriptor struct { + Items []Item +} + +// Bytes encodes the report descriptor. +func (r ReportDescriptor) Bytes() (Data, error) { + e := &encoder{} + for _, it := range r.Items { + if it == nil { + return nil, fmt.Errorf("hid: nil item") + } + if err := it.encode(e); err != nil { + return nil, err + } + } + return Data(e.buf), nil +} + +// AnyItem is an escape hatch for rarely used or vendor-defined items. +// +// For short items, Data must have length 0, 1, 2, or 4. +// For other sizes, use LongItem. +type AnyItem struct { + Type ItemType + Tag uint8 + Data Data +} + +func (a AnyItem) encode(e *encoder) error { + n := len(a.Data) + var sizeCode uint8 + switch n { + case 0: + sizeCode = 0 + case 1: + sizeCode = 1 + case 2: + sizeCode = 2 + case 4: + sizeCode = 3 + default: + return fmt.Errorf("hid: AnyItem short item data must be 0/1/2/4 bytes, got %d", n) + } + header := (a.Tag << 4) | (uint8(a.Type) << 2) | sizeCode + e.buf = append(e.buf, header) + e.buf = append(e.buf, a.Data...) + return nil +} + +// LongItem encodes a HID long item (rare). Format: 0xFE, len, tag, data... +type LongItem struct { + Tag uint8 + Data Data +} + +func (l LongItem) encode(e *encoder) error { + if len(l.Data) > 255 { + return fmt.Errorf("hid: long item too large: %d", len(l.Data)) + } + e.buf = append(e.buf, 0xFE, uint8(len(l.Data)), l.Tag) + e.buf = append(e.buf, l.Data...) + return nil +} + +type encoder struct { + buf []byte +} + +func (e *encoder) short(tag uint8, typ ItemType, data Data) error { + n := len(data) + var sizeCode uint8 + switch n { + case 0: + sizeCode = 0 + case 1: + sizeCode = 1 + case 2: + sizeCode = 2 + case 4: + sizeCode = 3 + default: + return fmt.Errorf("hid: short item data must be 0/1/2/4 bytes, got %d", n) + } + header := (tag << 4) | (uint8(typ) << 2) | sizeCode + e.buf = append(e.buf, header) + e.buf = append(e.buf, data...) + return nil +} + +func dataU32(v uint32) Data { + if v <= 0xFF { + return Data{uint8(v)} + } + if v <= 0xFFFF { + return Data{uint8(v), uint8(v >> 8)} + } + return Data{uint8(v), uint8(v >> 8), uint8(v >> 16), uint8(v >> 24)} +} + +func dataI32(v int32) Data { + if v >= -128 && v <= 127 { + return Data{uint8(v)} + } + if v >= -32768 && v <= 32767 { + uv := uint16(int16(v)) + return Data{uint8(uv), uint8(uv >> 8)} + } + uv := uint32(v) + return Data{uint8(uv), uint8(uv >> 8), uint8(uv >> 16), uint8(uv >> 24)} +} diff --git a/usb/hid/items.go b/usb/hid/items.go new file mode 100644 index 00000000..3c61e8f9 --- /dev/null +++ b/usb/hid/items.go @@ -0,0 +1,127 @@ +package hid + +// Common item structs. + +// UsagePage sets the current usage page (Global item, tag 0x0). +type UsagePage struct{ Page uint16 } + +func (u UsagePage) encode(e *encoder) error { + return e.short(0x0, ItemTypeGlobal, dataU32(uint32(u.Page))) +} + +// Usage sets the current usage (Local item, tag 0x0). +type Usage struct{ Usage uint16 } + +func (u Usage) encode(e *encoder) error { + return e.short(0x0, ItemTypeLocal, dataU32(uint32(u.Usage))) +} + +// Collection begins a collection (Main item, tag 0xA) and implicitly ends it. +type Collection struct { + Kind CollectionKind + Items []Item +} + +func (c Collection) encode(e *encoder) error { + if err := e.short(0xA, ItemTypeMain, Data{uint8(c.Kind)}); err != nil { + return err + } + for _, it := range c.Items { + if err := it.encode(e); err != nil { + return err + } + } + // End Collection (Main item, tag 0xC) with 0 bytes. + return e.short(0xC, ItemTypeMain, nil) +} + +// UsageMinimum sets the usage minimum (Local item, tag 0x1). +type UsageMinimum struct{ Min uint16 } + +func (u UsageMinimum) encode(e *encoder) error { + return e.short(0x1, ItemTypeLocal, dataU32(uint32(u.Min))) +} + +// UsageMaximum sets the usage maximum (Local item, tag 0x2). +type UsageMaximum struct{ Max uint16 } + +func (u UsageMaximum) encode(e *encoder) error { + return e.short(0x2, ItemTypeLocal, dataU32(uint32(u.Max))) +} + +// LogicalMinimum sets the logical minimum (Global item, tag 0x1). +type LogicalMinimum struct{ Min int32 } + +func (l LogicalMinimum) encode(e *encoder) error { + return e.short(0x1, ItemTypeGlobal, dataI32(l.Min)) +} + +// LogicalMaximum sets the logical maximum (Global item, tag 0x2). +type LogicalMaximum struct{ Max int32 } + +func (l LogicalMaximum) encode(e *encoder) error { + return e.short(0x2, ItemTypeGlobal, dataI32(l.Max)) +} + +// ReportSize sets report size in bits (Global item, tag 0x7). +type ReportSize struct{ Bits uint8 } + +func (r ReportSize) encode(e *encoder) error { + return e.short(0x7, ItemTypeGlobal, Data{r.Bits}) +} + +// ReportCount sets report count (Global item, tag 0x9). +type ReportCount struct{ Count uint16 } + +func (r ReportCount) encode(e *encoder) error { + return e.short(0x9, ItemTypeGlobal, dataU32(uint32(r.Count))) +} + +// Input encodes an Input main item (tag 0x8). +type Input struct{ Flags MainFlags } + +func (i Input) encode(e *encoder) error { + return e.short(0x8, ItemTypeMain, Data{uint8(i.Flags)}) +} + +// Output encodes an Output main item (tag 0x9). +type Output struct{ Flags MainFlags } + +func (o Output) encode(e *encoder) error { + return e.short(0x9, ItemTypeMain, Data{uint8(o.Flags)}) +} + +// Feature encodes a Feature main item (tag 0xB). +type Feature struct{ Flags MainFlags } + +func (f Feature) encode(e *encoder) error { + return e.short(0xB, ItemTypeMain, Data{uint8(f.Flags)}) +} + +// ReportID sets the report ID (Global item, tag 0x8). +type ReportID struct{ ID uint8 } + +func (r ReportID) encode(e *encoder) error { + return e.short(0x8, ItemTypeGlobal, Data{r.ID}) +} + +// PhysicalMinimum sets the physical minimum (Global item, tag 0x3). +type PhysicalMinimum struct{ Min int32 } + +func (p PhysicalMinimum) encode(e *encoder) error { + return e.short(0x3, ItemTypeGlobal, dataI32(p.Min)) +} + +// PhysicalMaximum sets the physical maximum (Global item, tag 0x4). +type PhysicalMaximum struct{ Max int32 } + +func (p PhysicalMaximum) encode(e *encoder) error { + return e.short(0x4, ItemTypeGlobal, dataI32(p.Max)) +} + +// Unit sets the unit system and exponents (Global item, tag 0x6). Use 0 to clear units. +type Unit struct{ Value uint32 } + +func (u Unit) encode(e *encoder) error { + return e.short(0x6, ItemTypeGlobal, dataU32(u.Value)) +} diff --git a/usb/usbdesc.go b/usb/usbdesc.go new file mode 100644 index 00000000..84e34ff2 --- /dev/null +++ b/usb/usbdesc.go @@ -0,0 +1,475 @@ +// Package usb contains helpers for building USB descriptors and data. +package usb + +import ( + "bytes" + "encoding/binary" + "fmt" + "unicode/utf16" + + "github.com/Alia5/VIIPER/usb/hid" +) + +// USB descriptor type constants +const ( + DeviceDescType = 0x01 + ConfigDescType = 0x02 + InterfaceDescType = 0x04 + EndpointDescType = 0x05 + IADDescType = 0x0B + HIDDescType = 0x21 + ReportDescType = 0x22 +) + +// Descriptor lengths in bytes (fixed values from USB spec) +const ( + DeviceDescLen = 18 + ConfigDescLen = 9 + IADDescLen = 8 + InterfaceDescLen = 9 + EndpointDescLen = 7 + HIDDescLen = 9 +) + +type Data []uint8 + +// Descriptor holds all static descriptor/config data for a device. +type Descriptor struct { + Device DeviceDescriptor + Configuration ConfigurationDescriptor + MicrosoftOS10 *MicrosoftOS10Descriptor + Associations []InterfaceAssociationDescriptor + Interfaces []InterfaceConfig + Strings map[uint8]string +} + +// MicrosoftOS10Descriptor enables the Microsoft OS 1.0 descriptor probe used by +// Windows to bind vendor-specific interfaces to inbox drivers such as WinUSB. +type MicrosoftOS10Descriptor struct { + VendorCode uint8 + InterfaceNumber uint8 + CompatibleID string + SubCompatibleID string + DeviceInterfaceGUID string +} + +func (d MicrosoftOS10Descriptor) StringDescriptor() []byte { + return []byte{ + 0x12, 0x03, + 'M', 0x00, + 'S', 0x00, + 'F', 0x00, + 'T', 0x00, + '1', 0x00, + '0', 0x00, + '0', 0x00, + d.EffectiveVendorCode(), 0x00, + } +} + +func (d MicrosoftOS10Descriptor) EffectiveVendorCode() uint8 { + if d.VendorCode == 0 { + return 0x20 + } + return d.VendorCode +} + +func (d MicrosoftOS10Descriptor) ControlResponse(wValue, wIndex uint16) ([]byte, bool) { + switch { + case wIndex == 0x0004: + return d.CompatibleIDDescriptor(), true + case wIndex == 0x0005 || wValue == 0x0005: + return d.ExtendedPropertiesDescriptor(), true + default: + return nil, false + } +} + +func (d MicrosoftOS10Descriptor) CompatibleIDDescriptor() []byte { + out := make([]byte, 40) + binary.LittleEndian.PutUint32(out[0:4], uint32(len(out))) + binary.LittleEndian.PutUint16(out[4:6], 0x0100) + binary.LittleEndian.PutUint16(out[6:8], 0x0004) + out[8] = 0x01 + out[16] = d.InterfaceNumber + copyFixedASCII(out[18:26], d.CompatibleID) + copyFixedASCII(out[26:34], d.SubCompatibleID) + return out +} + +func (d MicrosoftOS10Descriptor) ExtendedPropertiesDescriptor() []byte { + if d.DeviceInterfaceGUID == "" { + out := make([]byte, 10) + binary.LittleEndian.PutUint32(out[0:4], uint32(len(out))) + binary.LittleEndian.PutUint16(out[4:6], 0x0100) + binary.LittleEndian.PutUint16(out[6:8], 0x0005) + return out + } + + name := utf16leString("DeviceInterfaceGUID") + data := utf16leString(d.DeviceInterfaceGUID) + + sectionLen := 4 + 4 + 2 + len(name) + 4 + len(data) + out := make([]byte, 10+sectionLen) + binary.LittleEndian.PutUint32(out[0:4], uint32(len(out))) + binary.LittleEndian.PutUint16(out[4:6], 0x0100) + binary.LittleEndian.PutUint16(out[6:8], 0x0005) + binary.LittleEndian.PutUint16(out[8:10], 0x0001) + + off := 10 + binary.LittleEndian.PutUint32(out[off:off+4], uint32(sectionLen)) + off += 4 + binary.LittleEndian.PutUint32(out[off:off+4], 0x00000001) // REG_SZ + off += 4 + binary.LittleEndian.PutUint16(out[off:off+2], uint16(len(name))) + off += 2 + copy(out[off:off+len(name)], name) + off += len(name) + binary.LittleEndian.PutUint32(out[off:off+4], uint32(len(data))) + off += 4 + copy(out[off:], data) + return out +} + +func copyFixedASCII(dst []byte, s string) { + for i := range dst { + dst[i] = 0 + } + copy(dst, []byte(s)) +} + +func utf16leString(s string) []byte { + units := utf16.Encode([]rune(s + "\x00")) + out := make([]byte, len(units)*2) + for i, u := range units { + binary.LittleEndian.PutUint16(out[i*2:i*2+2], u) + } + return out +} + +// NumInterfaces returns the number of distinct interface numbers in the active +// configuration. Alternate settings share the same interface number and only +// count once in the USB configuration descriptor header. +func (d Descriptor) NumInterfaces() uint8 { + seen := map[uint8]struct{}{} + for _, iface := range d.Interfaces { + seen[iface.Descriptor.BInterfaceNumber] = struct{}{} + } + return uint8(len(seen)) +} + +// Interface returns the first matching interface descriptor for an interface +// number, preferring alternate setting zero when available. +func (d Descriptor) Interface(number uint8) (InterfaceConfig, bool) { + var found InterfaceConfig + ok := false + for _, iface := range d.Interfaces { + if iface.Descriptor.BInterfaceNumber != number { + continue + } + if iface.Descriptor.BAlternateSetting == 0 { + return iface, true + } + if !ok { + found = iface + ok = true + } + } + return found, ok +} + +// InterfaceConfig holds all descriptors for a single interface for bus management. +type InterfaceConfig struct { + Descriptor InterfaceDescriptor + Endpoints []EndpointDescriptor + + // HID describes a HID-class interface (bInterfaceClass=0x03). + // If set, the server will emit the HID descriptor (0x21) in the configuration + // descriptor and serve the report descriptor (0x22) via GET_DESCRIPTOR. + HID *HIDFunction + + // ClassDescriptors are additional interface-level class-specific descriptors + // emitted as part of the configuration descriptor (after the interface descriptor + // and before the endpoints). + // + // This is also used for vendor-specific interfaces that need to expose opaque + // descriptors (e.g. type 0x21 blobs on Xbox360). + ClassDescriptors []ClassSpecificDescriptor +} + +// EncodeStringDescriptor converts a UTF-8 string to a USB string descriptor byte array. +// The resulting descriptor has the format: +// +// Byte 0: bLength (total descriptor length) +// Byte 1: bDescriptorType (0x03 for string) +// Bytes 2+: UTF-16LE encoded string +func EncodeStringDescriptor(s string) []byte { + runes := []rune(s) + buf := make([]byte, 2+len(runes)*2) + buf[0] = uint8(len(buf)) // bLength + buf[1] = 0x03 // bDescriptorType (STRING) + for i, r := range runes { + buf[2+i*2] = uint8(r) + buf[2+i*2+1] = uint8(r >> 8) + } + return buf +} + +// DeviceDescriptor represents the standard USB device descriptor. +// BLength is computed dynamically; BDescriptorType is implied DeviceDescType. +type DeviceDescriptor struct { + BcdUSB uint16 // LE + BDeviceClass uint8 + BDeviceSubClass uint8 + BDeviceProtocol uint8 + BMaxPacketSize0 uint8 + IDVendor uint16 // LE; may get overridden + IDProduct uint16 // LE; may get overridden + BcdDevice uint16 // LE + IManufacturer uint8 + IProduct uint8 + ISerialNumber uint8 + BNumConfigurations uint8 + Speed uint32 // USB speed: 1=low, 2=full, 3=high, 4=super +} + +// Bytes returns the binary representation of the DeviceDescriptor with BLength auto-filled. +func (d Descriptor) Bytes() []byte { + var b bytes.Buffer + b.WriteByte(DeviceDescLen) + b.WriteByte(DeviceDescType) + _ = binary.Write(&b, binary.LittleEndian, d.Device.BcdUSB) + b.WriteByte(d.Device.BDeviceClass) + b.WriteByte(d.Device.BDeviceSubClass) + b.WriteByte(d.Device.BDeviceProtocol) + b.WriteByte(d.Device.BMaxPacketSize0) + _ = binary.Write(&b, binary.LittleEndian, d.Device.IDVendor) + _ = binary.Write(&b, binary.LittleEndian, d.Device.IDProduct) + _ = binary.Write(&b, binary.LittleEndian, d.Device.BcdDevice) + b.WriteByte(d.Device.IManufacturer) + b.WriteByte(d.Device.IProduct) + b.WriteByte(d.Device.ISerialNumber) + b.WriteByte(d.Device.BNumConfigurations) + return b.Bytes() +} + +// ConfigHeader represents the USB configuration descriptor header (9 bytes). +type ConfigHeader struct { + WTotalLength uint16 // LE, to be patched after building + BNumInterfaces uint8 + BConfigurationValue uint8 + IConfiguration uint8 + BMAttributes uint8 + BMaxPower uint8 +} + +// ConfigurationDescriptor contains the non-derived fields from the USB +// configuration descriptor. Zero values keep the server defaults. +type ConfigurationDescriptor struct { + BConfigurationValue uint8 + IConfiguration uint8 + BMAttributes uint8 + BMaxPower uint8 +} + +// InterfaceAssociationDescriptor describes a USB Interface Association +// Descriptor (IAD), used by composite devices to group related interfaces. +type InterfaceAssociationDescriptor struct { + BFirstInterface uint8 + BInterfaceCount uint8 + BFunctionClass uint8 + BFunctionSubClass uint8 + BFunctionProtocol uint8 + IFunction uint8 +} + +func (i InterfaceAssociationDescriptor) Write(b *bytes.Buffer) { + b.WriteByte(IADDescLen) + b.WriteByte(IADDescType) + b.WriteByte(i.BFirstInterface) + b.WriteByte(i.BInterfaceCount) + b.WriteByte(i.BFunctionClass) + b.WriteByte(i.BFunctionSubClass) + b.WriteByte(i.BFunctionProtocol) + b.WriteByte(i.IFunction) +} + +func (h ConfigHeader) Write(b *bytes.Buffer) { + b.WriteByte(ConfigDescLen) + b.WriteByte(ConfigDescType) + _ = binary.Write(b, binary.LittleEndian, h.WTotalLength) + b.WriteByte(h.BNumInterfaces) + b.WriteByte(h.BConfigurationValue) + b.WriteByte(h.IConfiguration) + b.WriteByte(h.BMAttributes) + b.WriteByte(h.BMaxPower) + +} + +// InterfaceDescriptor (9 bytes) for each interface altsetting. +type InterfaceDescriptor struct { + BInterfaceNumber uint8 + BAlternateSetting uint8 + BNumEndpoints uint8 + BInterfaceClass uint8 + BInterfaceSubClass uint8 + BInterfaceProtocol uint8 + IInterface uint8 +} + +func (i InterfaceDescriptor) Write(b *bytes.Buffer) { + b.WriteByte(InterfaceDescLen) + b.WriteByte(InterfaceDescType) + b.WriteByte(i.BInterfaceNumber) + b.WriteByte(i.BAlternateSetting) + b.WriteByte(i.BNumEndpoints) + b.WriteByte(i.BInterfaceClass) + b.WriteByte(i.BInterfaceSubClass) + b.WriteByte(i.BInterfaceProtocol) + b.WriteByte(i.IInterface) + +} + +// EndpointDescriptor describes a standard endpoint descriptor. Most endpoints +// are 7 bytes; class-specific standards such as USB Audio Class 1.0 can append +// additional standard endpoint fields after bInterval. +type EndpointDescriptor struct { + BEndpointAddress uint8 + BMAttributes uint8 + WMaxPacketSize uint16 // LE + BInterval uint8 + Trailing Data + + // ClassDescriptors are optional endpoint-level class-specific descriptors + // emitted immediately after this endpoint descriptor. + ClassDescriptors []ClassSpecificDescriptor +} + +func (e EndpointDescriptor) Write(b *bytes.Buffer) { + b.WriteByte(uint8(EndpointDescLen + len(e.Trailing))) + b.WriteByte(EndpointDescType) + b.WriteByte(e.BEndpointAddress) + b.WriteByte(e.BMAttributes) + _ = binary.Write(b, binary.LittleEndian, e.WMaxPacketSize) + b.WriteByte(e.BInterval) + b.Write(e.Trailing) + +} + +// HIDSubDescriptor is one subordinate descriptor entry in the HID class descriptor. +// +// Type is typically ReportDescType (0x22). If Type==ReportDescType and Length==0, +// the server will auto-fill Length from the associated HID report descriptor at +// serialization time. +type HIDSubDescriptor struct { + Type uint8 + Length uint16 // LE +} + +// HIDDescriptor is the HID class descriptor (0x21) for HID-class interfaces. +// +// bDescriptorType is fixed to HIDDescType (0x21). +// bLength is auto-calculated as: 6 + 3*len(Descriptors). +type HIDDescriptor struct { + BcdHID uint16 // LE + BCountryCode uint8 + Descriptors []HIDSubDescriptor +} + +func (h HIDDescriptor) IsZero() bool { + return h.BcdHID == 0 && h.BCountryCode == 0 && len(h.Descriptors) == 0 +} + +func (h HIDDescriptor) Write(b *bytes.Buffer, reportLen uint16) error { + if len(h.Descriptors) == 0 { + return fmt.Errorf("usb: HIDDescriptor has no subordinate descriptors") + } + b.WriteByte(uint8(6 + 3*len(h.Descriptors))) + b.WriteByte(HIDDescType) + _ = binary.Write(b, binary.LittleEndian, h.BcdHID) + b.WriteByte(h.BCountryCode) + b.WriteByte(uint8(len(h.Descriptors))) + for _, sd := range h.Descriptors { + b.WriteByte(sd.Type) + l := sd.Length + if sd.Type == ReportDescType && l == 0 { + l = reportLen + } + _ = binary.Write(b, binary.LittleEndian, l) + } + return nil +} + +// ClassSpecificDescriptor represents an opaque class-specific interface descriptor. +// +// It auto-calculates bLength and hardcodes bDescriptorType. Payload contains all bytes +// after the (bLength,bDescriptorType) header. +type ClassSpecificDescriptor struct { + DescriptorType uint8 + Payload Data +} + +func (d ClassSpecificDescriptor) Bytes() Data { + out := make([]uint8, 0, 2+len(d.Payload)) + out = append(out, uint8(2+len(d.Payload))) + out = append(out, d.DescriptorType) + out = append(out, d.Payload...) + return Data(out) +} + +// HIDFunction bundles the HID class descriptor (0x21) and the report descriptor (0x22) +// for a HID-class interface. +type HIDFunction struct { + Descriptor HIDDescriptor + ReportDescriptor hid.ReportDescriptor + // ReportDescriptorBytes, when non-empty, is returned verbatim as the HID report + // descriptor (0x22) and used for HID report length calculation. This is useful for + // complex, vendor-specific descriptors that are easier to provide as raw bytes. + ReportDescriptorBytes Data +} + +func (f HIDFunction) reportLen() (uint16, error) { + if len(f.ReportDescriptorBytes) > 0 { + if len(f.ReportDescriptorBytes) > 0xFFFF { + return 0, fmt.Errorf("usb: HID raw report descriptor too large: %d", len(f.ReportDescriptorBytes)) + } + return uint16(len(f.ReportDescriptorBytes)), nil + } + + rb, err := f.ReportDescriptor.Bytes() + if err != nil { + return 0, err + } + if len(rb) > 0xFFFF { + return 0, fmt.Errorf("usb: HID report descriptor too large: %d", len(rb)) + } + return uint16(len(rb)), nil +} + +// DescriptorBytes returns the HID class descriptor (0x21) bytes. +func (f HIDFunction) DescriptorBytes() (Data, error) { + rl, err := f.reportLen() + if err != nil { + return nil, err + } + var b bytes.Buffer + if err := f.Descriptor.Write(&b, rl); err != nil { + return nil, err + } + return Data(b.Bytes()), nil +} + +// ReportBytes returns the HID report descriptor (0x22) bytes. +func (f HIDFunction) ReportBytes() (Data, error) { + if len(f.ReportDescriptorBytes) > 0 { + out := make([]uint8, len(f.ReportDescriptorBytes)) + copy(out, f.ReportDescriptorBytes) + return Data(out), nil + } + + rb, err := f.ReportDescriptor.Bytes() + if err != nil { + return nil, err + } + return Data(rb), nil +} diff --git a/viiper/pkg/usbip/usbip.go b/usbip/usbip.go similarity index 84% rename from viiper/pkg/usbip/usbip.go rename to usbip/usbip.go index 6f85bed7..c7b74da7 100644 --- a/viiper/pkg/usbip/usbip.go +++ b/usbip/usbip.go @@ -58,9 +58,9 @@ func (d *DevListReplyHeader) Write(w io.Writer) error { // Uses fixed-size arrays matching the wire protocol format. type ExportMeta struct { Path [256]byte - USBBusId [32]byte - BusId uint32 - DevId uint32 + USBBusID [32]byte + BusID uint32 + DevID uint32 } // ExportedDevice describes one exported device in devlist/import replies. @@ -89,27 +89,18 @@ type InterfaceDesc struct { Protocol uint8 } -func putFixedString(dst []byte, s string) { - n := copy(dst, []byte(s)) - if n < len(dst) { - for i := n; i < len(dst); i++ { - dst[i] = 0 - } - } -} - // WriteDevlist writes the device entry for OP_REP_DEVLIST (includes interface triplets). func (d *ExportedDevice) WriteDevlist(w io.Writer) error { if _, err := w.Write(d.Path[:]); err != nil { return err } - if _, err := w.Write(d.USBBusId[:]); err != nil { + if _, err := w.Write(d.USBBusID[:]); err != nil { return err } - if err := binary.Write(w, binary.BigEndian, d.BusId); err != nil { + if err := binary.Write(w, binary.BigEndian, d.BusID); err != nil { return err } - if err := binary.Write(w, binary.BigEndian, d.DevId); err != nil { + if err := binary.Write(w, binary.BigEndian, d.DevID); err != nil { return err } if err := binary.Write(w, binary.BigEndian, d.Speed); err != nil { @@ -148,13 +139,13 @@ func (d *ExportedDevice) WriteImport(w io.Writer) error { if _, err := w.Write(d.Path[:]); err != nil { return err } - if _, err := w.Write(d.USBBusId[:]); err != nil { + if _, err := w.Write(d.USBBusID[:]); err != nil { return err } - if err := binary.Write(w, binary.BigEndian, d.BusId); err != nil { + if err := binary.Write(w, binary.BigEndian, d.BusID); err != nil { return err } - if err := binary.Write(w, binary.BigEndian, d.DevId); err != nil { + if err := binary.Write(w, binary.BigEndian, d.DevID); err != nil { return err } if err := binary.Write(w, binary.BigEndian, d.Speed); err != nil { @@ -197,7 +188,7 @@ type CmdSubmit struct { TransferFlags uint32 TransferBufferLen uint32 StartFrame uint32 - NumberOfPackets uint32 + NumberOfPackets int32 Interval uint32 Setup [8]byte } @@ -243,11 +234,47 @@ type RetSubmit struct { Status int32 ActualLength uint32 StartFrame uint32 - NumberOfPackets uint32 + NumberOfPackets int32 ErrorCount uint32 Padding [8]byte } +// IsoPacketDescriptor is the 16-byte USB/IP representation of one packet in +// an isochronous URB. USB/IP appends one descriptor per packet after the data +// payload for both submit and return messages. +type IsoPacketDescriptor struct { + Offset uint32 + Length uint32 + ActualLength uint32 + Status int32 +} + +func (d *IsoPacketDescriptor) Read(r io.Reader) error { + if err := binary.Read(r, binary.BigEndian, &d.Offset); err != nil { + return err + } + if err := binary.Read(r, binary.BigEndian, &d.Length); err != nil { + return err + } + if err := binary.Read(r, binary.BigEndian, &d.ActualLength); err != nil { + return err + } + return binary.Read(r, binary.BigEndian, &d.Status) +} + +func (d IsoPacketDescriptor) Write(w io.Writer) error { + if err := binary.Write(w, binary.BigEndian, d.Offset); err != nil { + return err + } + if err := binary.Write(w, binary.BigEndian, d.Length); err != nil { + return err + } + if err := binary.Write(w, binary.BigEndian, d.ActualLength); err != nil { + return err + } + return binary.Write(w, binary.BigEndian, d.Status) +} + func (r *RetSubmit) Write(w io.Writer) error { if err := binary.Write(w, binary.BigEndian, r.Basic.Command); err != nil { return err diff --git a/usbip/usbip_test.go b/usbip/usbip_test.go new file mode 100644 index 00000000..91f3afe3 --- /dev/null +++ b/usbip/usbip_test.go @@ -0,0 +1,31 @@ +package usbip + +import ( + "bytes" + "testing" +) + +func TestIsoPacketDescriptorRoundTrip(t *testing.T) { + want := IsoPacketDescriptor{ + Offset: 384, + Length: 384, + ActualLength: 384, + Status: -104, + } + + var wire bytes.Buffer + if err := want.Write(&wire); err != nil { + t.Fatalf("Write returned error: %v", err) + } + if wire.Len() != 16 { + t.Fatalf("unexpected descriptor length: got %d want 16", wire.Len()) + } + + var got IsoPacketDescriptor + if err := got.Read(&wire); err != nil { + t.Fatalf("Read returned error: %v", err) + } + if got != want { + t.Fatalf("descriptor mismatch: got %#v want %#v", got, want) + } +} diff --git a/versioninfo.json b/versioninfo.json new file mode 100644 index 00000000..31809956 --- /dev/null +++ b/versioninfo.json @@ -0,0 +1,42 @@ +{ + "FixedFileInfo": { + "FileVersion": { + "Major": 0, + "Minor": 0, + "Patch": 0, + "Build": 0 + }, + "ProductVersion": { + "Major": 0, + "Minor": 0, + "Patch": 0, + "Build": 0 + }, + "FileFlagsMask": "3f", + "FileFlags ": "00", + "FileOS": "040004", + "FileType": "01", + "FileSubType": "00" + }, + "StringFileInfo": { + "Comments": "Virtual Input over IP EmulatoR", + "CompanyName": "Peter Repukat", + "FileDescription": "VIIPER - Virtual Input over IP EmulatoR", + "FileVersion": "0.0.0.0", + "InternalName": "viiper", + "LegalCopyright": "Copyright (C) 2025-2026 Peter Repukat - GPL-3.0", + "LegalTrademarks": "", + "OriginalFilename": "viiper.exe", + "PrivateBuild": "", + "ProductName": "VIIPER", + "ProductVersion": "0.0.0.0", + "SpecialBuild": "" + }, + "VarFileInfo": { + "Translation": { + "LangID": "0409", + "CharsetID": "04B0" + } + }, + "IconPath": "viiper.ico" +} diff --git a/viiper.ico b/viiper.ico new file mode 100644 index 00000000..9d7a4187 Binary files /dev/null and b/viiper.ico differ diff --git a/viiper/cmd/viiper/viiper.go b/viiper/cmd/viiper/viiper.go deleted file mode 100644 index 23f5a479..00000000 --- a/viiper/cmd/viiper/viiper.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "os" - "strings" - "viiper/internal/config" - "viiper/internal/configpaths" - "viiper/internal/log" - - _ "viiper/internal/registry" // Register all device handlers - - "github.com/alecthomas/kong" - kongtoml "github.com/alecthomas/kong-toml" - kongyaml "github.com/alecthomas/kong-yaml" -) - -func main() { - - findUserConfig := func(args []string) string { - for i := 0; i < len(args); i++ { - a := args[i] - if strings.HasPrefix(a, "--config=") { - return a[len("--config="):] - } - if a == "--config" && i+1 < len(args) { - return args[i+1] - } - } - if v := os.Getenv("VIIPER_CONFIG"); v != "" { - return v - } - return "" - } - - userCfg := findUserConfig(os.Args[1:]) - jsonPaths, yamlPaths, tomlPaths := configpaths.ConfigCandidatePaths(userCfg) - - var cli config.CLI - ctx := kong.Parse(&cli, - kong.Name("viiper"), - kong.Description("Virtual Input over IP EmulatoR"), - kong.UsageOnError(), - // Load configuration from JSON/YAML/TOML in priority order; flags/env override config values. - kong.Configuration(kong.JSON, jsonPaths...), - kong.Configuration(kongyaml.Loader, yamlPaths...), - kong.Configuration(kongtoml.Loader, tomlPaths...), - ) - - logger, closeFiles, err := log.SetupLogger(cli.Log.Level, cli.Log.File) - if err != nil { - _, _ = os.Stderr.WriteString("failed to setup logger: " + err.Error() + "\n") - os.Exit(2) - } - defer func() { - for _, c := range closeFiles { - _ = c.Close() - } - }() - - var rawLogger log.RawLogger - if cli.Log.RawFile != "" { - f, err := os.OpenFile(cli.Log.RawFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - logger.Error("failed to open raw log file", "file", cli.Log.RawFile, "error", err) - rawLogger = log.NewRaw(nil) - } else { - rawLogger = log.NewRaw(f) - closeFiles = append(closeFiles, f) - } - } else if cli.Log.Level == "trace" { - rawLogger = log.NewRaw(os.Stdout) - } else { - rawLogger = log.NewRaw(nil) - } - - ctx.Bind(logger) - ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) - - err = ctx.Run() - ctx.FatalIfErrorf(err) -} diff --git a/viiper/go.mod b/viiper/go.mod deleted file mode 100644 index 300e50df..00000000 --- a/viiper/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module viiper - -go 1.25 - -require ( - github.com/alecthomas/kong v1.13.0 - github.com/alecthomas/kong-toml v0.4.0 - github.com/alecthomas/kong-yaml v0.2.0 - github.com/pelletier/go-toml v1.9.5 - github.com/stretchr/testify v1.9.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect -) diff --git a/viiper/go.sum b/viiper/go.sum deleted file mode 100644 index 95f46b66..00000000 --- a/viiper/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= -github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= -github.com/alecthomas/kong-toml v0.4.0 h1:sSK/HHi2M5jqSXYTxmuxkdZcJ+ip9jhYvwcjDGcaJBQ= -github.com/alecthomas/kong-toml v0.4.0/go.mod h1:hRVV9iGmqYsFqs17jFQgqhkjYIxiklbfy95xJ3nlpKI= -github.com/alecthomas/kong-yaml v0.2.0 h1:iiVVqVttmOsHKawlaW/TljPsjaEv1O4ODx6dloSA58Y= -github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/viiper/internal/cmd/codegen.go b/viiper/internal/cmd/codegen.go deleted file mode 100644 index ef590b26..00000000 --- a/viiper/internal/cmd/codegen.go +++ /dev/null @@ -1,37 +0,0 @@ -package cmd - -import ( - "log/slog" - "viiper/internal/codegen/generator" -) - -type Codegen struct { - Output string `help:"Output directory for generated SDKs (repo-root relative). Default resolves to /clients" default:"../clients" env:"VIIPER_CODEGEN_OUTPUT"` - Lang string `help:"Target language: c, csharp, typescript, or 'all'" default:"all" enum:"c,csharp,typescript,all" env:"VIIPER_CODEGEN_LANG"` -} - -// Run is called by Kong when the codegen command is executed. -func (c *Codegen) Run(logger *slog.Logger) error { - logger.Info("Starting VIIPER code generation", "output", c.Output, "lang", c.Lang) - - gen := generator.New(c.Output, logger) - - switch c.Lang { - case "c": - return gen.GenerateC() - case "csharp": - return gen.GenerateCSharp() - case "typescript": - return gen.GenerateTypeScript() - case "all": - if err := gen.GenerateC(); err != nil { - return err - } - if err := gen.GenerateCSharp(); err != nil { - return err - } - return gen.GenerateTypeScript() - } - - return nil -} diff --git a/viiper/internal/cmd/server.go b/viiper/internal/cmd/server.go deleted file mode 100644 index 3575fee2..00000000 --- a/viiper/internal/cmd/server.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - "viiper/internal/log" - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" -) - -type Server struct { - UsbServerConfig usb.ServerConfig `embed:"" prefix:"usb."` - ApiServerConfig api.ServerConfig `embed:"" prefix:"api."` - ConnectionTimeout time.Duration `help:"ConnectionTimeout operation timeout" default:"30s" env:"VIIPER_CONNECTION_TIMEOUT"` -} - -// Run is called by Kong when the server command is executed. -func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - s.UsbServerConfig.ConnectionTimeout = s.ConnectionTimeout - s.ApiServerConfig.ConnectionTimeout = s.ConnectionTimeout - - logger.Info("Starting VIIPER USB-IP server", "addr", s.UsbServerConfig.Addr) - usbSrv := usb.New(s.UsbServerConfig, logger, rawLogger) - - usbErrCh := make(chan error, 1) - go func() { - usbErrCh <- usbSrv.ListenAndServe() - }() - - select { - case err := <-usbErrCh: - return err - case <-usbSrv.Ready(): - } - - var apiSrv *api.Server - if s.ApiServerConfig.Addr != "" { - apiSrv = api.New(usbSrv, s.ApiServerConfig.Addr, s.ApiServerConfig, logger) - - r := apiSrv.Router() - r.Register("bus/list", handler.BusList(usbSrv)) - r.Register("bus/create", handler.BusCreate(usbSrv)) - r.Register("bus/remove", handler.BusRemove(usbSrv)) - r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) - r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) - r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) - r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) - if err := apiSrv.Start(); err != nil { - logger.Error("failed to start API server", "error", err) - return err - } - } - - select { - case <-ctx.Done(): - if apiSrv != nil { - apiSrv.Close() - } - _ = usbSrv.Close() - _ = <-usbErrCh - return nil - case err := <-usbErrCh: - if apiSrv != nil { - apiSrv.Close() - } - return err - } -} diff --git a/viiper/internal/codegen/generator/c/cmake.go b/viiper/internal/codegen/generator/c/cmake.go deleted file mode 100644 index 8e1a2e47..00000000 --- a/viiper/internal/codegen/generator/c/cmake.go +++ /dev/null @@ -1,74 +0,0 @@ -package cgen - -import ( - "bytes" - "fmt" - "log/slog" - "os" - "path/filepath" - "sort" - "text/template" - "viiper/internal/codegen/meta" -) - -var cmakeTmpl = template.Must(template.New("cmake").Parse(`cmake_minimum_required(VERSION 3.10) -project(viiper C) - -set(CMAKE_C_STANDARD 99) - -# Library source files -add_library(viiper SHARED - src/viiper.c -{{range .Devices}} src/viiper_{{.}}.c -{{end}}) - -# Include directories -target_include_directories(viiper PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include -) - -# Platform-specific settings -if(WIN32) - target_compile_definitions(viiper PRIVATE VIIPER_BUILD) - target_link_libraries(viiper ws2_32) -else() - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden") -endif() - -# Installation -install(TARGETS viiper - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib - RUNTIME DESTINATION bin -) - -install(DIRECTORY include/viiper - DESTINATION include -) -`)) - -func generateCMake(logger *slog.Logger, outDir string, md *meta.Metadata) error { - devices := make([]string, 0, len(md.DevicePackages)) - for device := range md.DevicePackages { - devices = append(devices, device) - } - sort.Strings(devices) - - data := struct { - Devices []string - }{ - Devices: devices, - } - - var buf bytes.Buffer - if err := cmakeTmpl.Execute(&buf, data); err != nil { - return fmt.Errorf("execute CMake template: %w", err) - } - - out := filepath.Join(outDir, "CMakeLists.txt") - if err := os.WriteFile(out, buf.Bytes(), 0644); err != nil { - return fmt.Errorf("write CMakeLists.txt: %w", err) - } - logger.Info("Generated CMakeLists.txt", "file", out) - return nil -} diff --git a/viiper/internal/codegen/generator/c/gen.go b/viiper/internal/codegen/generator/c/gen.go deleted file mode 100644 index 1aaacdca..00000000 --- a/viiper/internal/codegen/generator/c/gen.go +++ /dev/null @@ -1,60 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" -) - -// Generate produces the C SDK layout under outputDir. -// It creates: -// - include/viiper/viiper.h (common types and management API) -// - include/viiper/viiper_.h (per-device constants and API) -// - src/viiper.c (common implementations) -// - src/viiper_.c (per-device) -// - CMakeLists.txt -func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { - includeDir := filepath.Join(outputDir, "include", "viiper") - srcDir := filepath.Join(outputDir, "src") - - if err := os.MkdirAll(includeDir, 0755); err != nil { - return fmt.Errorf("create include dir: %w", err) - } - if err := os.MkdirAll(srcDir, 0755); err != nil { - return fmt.Errorf("create src dir: %w", err) - } - - if err := generateCommonHeader(logger, includeDir, md); err != nil { - return err - } - - for device := range md.DevicePackages { - if err := generateDeviceHeader(logger, includeDir, device, md); err != nil { - return err - } - } - - if err := generateCommonSource(logger, srcDir, md); err != nil { - return err - } - - for device := range md.DevicePackages { - if err := generateDeviceSource(logger, srcDir, device, md); err != nil { - return err - } - } - - if err := generateCMake(logger, outputDir, md); err != nil { - return err - } - - if err := common.GenerateLicense(logger, outputDir); err != nil { - return err - } - - logger.Info("Generated C SDK", "dir", outputDir) - return nil -} diff --git a/viiper/internal/codegen/generator/c/header_common.go b/viiper/internal/codegen/generator/c/header_common.go deleted file mode 100644 index 02e06587..00000000 --- a/viiper/internal/codegen/generator/c/header_common.go +++ /dev/null @@ -1,169 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - "viiper/internal/codegen/meta" -) - -const commonHeaderTmpl = `#ifndef VIIPER_H -#define VIIPER_H - -/* Auto-generated VIIPER - C SDK: common header */ - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -/* Platform-specific exports */ -#if defined(_WIN32) || defined(_WIN64) - #ifdef VIIPER_BUILD - #define VIIPER_API __declspec(dllexport) - #else - #define VIIPER_API __declspec(dllimport) - #endif -#else - #define VIIPER_API __attribute__((visibility("default"))) -#endif - -/* Version information */ -#define VIIPER_VERSION_MAJOR 0 -#define VIIPER_VERSION_MINOR 1 -#define VIIPER_VERSION_PATCH 0 - -/* Forward declarations */ -typedef struct viiper_client viiper_client_t; -typedef struct viiper_device viiper_device_t; - -/* Error codes */ -typedef enum { - VIIPER_OK = 0, - VIIPER_ERROR_CONNECT = -1, - VIIPER_ERROR_INVALID_PARAM = -2, - VIIPER_ERROR_PROTOCOL = -3, - VIIPER_ERROR_TIMEOUT = -4, - VIIPER_ERROR_MEMORY = -5, - VIIPER_ERROR_NOT_FOUND = -6, - VIIPER_ERROR_IO = -7 -} viiper_error_t; - -/* ======================================================================== - * Management API - DTOs - * ======================================================================== */ - -/* Device info (special case to avoid conflict with viiper_device_t) */ -typedef struct { - uint32_t BusID; - const char* DevId; - const char* Vid; - const char* Pid; - const char* Type; -} viiper_device_info_t; - -{{range .DTOs}} -{{if ne .Name "Device"}} -/* {{.Name}} */ -typedef struct { -{{- range .Fields}} - {{fieldDecl .}} -{{- end}} -} viiper_{{snakecase .Name}}_t; -{{end}} -{{end}} - -/* Free helpers for DTOs (call to release memory allocated by the SDK) */ -{{range .DTOs}} -{{if ne .Name "Device"}} -VIIPER_API void viiper_free_{{snakecase .Name}}(viiper_{{snakecase .Name}}_t* v); -{{end}} -{{end}} - -/* ======================================================================== - * Client API - * ======================================================================== */ - -/* Create a VIIPER client handle (no persistent connection for management API) */ -VIIPER_API viiper_error_t viiper_client_create( - const char* host, - uint16_t port, - viiper_client_t** out_client -); - -/* Free the client handle and resources */ -VIIPER_API void viiper_client_free(viiper_client_t* client); - -/* Get the last error message */ -VIIPER_API const char* viiper_get_error(viiper_client_t* client); - -/* ======================================================================== - * Management API - * ======================================================================== */ -{{range .Routes}} -/* {{.Method}}: {{.Path}} */ -VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( - viiper_client_t* client{{ $params := pathParams .Path }}{{range $params}}, - const char* {{.}}{{end}}{{range .Arguments}}, - {{ctype .Type ""}} {{.Name}}{{end}}{{if .ResponseDTO}}, - viiper_{{snakecase .ResponseDTO}}_t* out{{end}} -); -{{end}} - -/* ======================================================================== - * Device Streaming API - * ======================================================================== */ - -/* Callback type for receiving device output (raw bytes) */ -typedef void (*viiper_output_cb)(const void* output, size_t output_size, void* user_data); - -/* Create a device stream connection (opens stream socket to bus/busId/devId) */ -VIIPER_API viiper_error_t viiper_device_create( - viiper_client_t* client, - uint32_t bus_id, - const char* dev_id, - viiper_device_t** out_device -); - -/* Send raw input bytes to the device (client → device) */ -VIIPER_API viiper_error_t viiper_device_send( - viiper_device_t* device, - const void* input, - size_t input_size -); - -/* Register callback for device output (device → client, async) */ -VIIPER_API void viiper_device_on_output( - viiper_device_t* device, - viiper_output_cb callback, - void* user_data -); - -/* Close device stream and free resources */ -VIIPER_API void viiper_device_close(viiper_device_t* device); - -#ifdef __cplusplus -} -#endif - -#endif /* VIIPER_H */ -` - -func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata) error { - out := filepath.Join(includeDir, "viiper.h") - t := template.Must(template.New("viiper.h").Funcs(tplFuncs(md)).Parse(commonHeaderTmpl)) - f, err := os.Create(out) - if err != nil { - return fmt.Errorf("create header: %w", err) - } - defer f.Close() - if err := t.Execute(f, md); err != nil { - return fmt.Errorf("exec header tmpl: %w", err) - } - logger.Info("Generated C header", "file", out) - return nil -} diff --git a/viiper/internal/codegen/generator/c/header_device.go b/viiper/internal/codegen/generator/c/header_device.go deleted file mode 100644 index bd182527..00000000 --- a/viiper/internal/codegen/generator/c/header_device.go +++ /dev/null @@ -1,72 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" -) - -const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H -#define VIIPER_{{upper .Device}}_H - -/* Auto-generated VIIPER - C SDK: {{.Device}} device */ - -#include "viiper.h" - -/* ======================================================================== - * {{.Device}} Constants - * ======================================================================== */ -{{- if gt (len .Pkg.Constants) 0 }} -/* {{.Device}} constants */ -{{range .Pkg.Constants -}} -#define VIIPER_{{upper $.Device}}_{{upper .Name}} {{printf "0x%X" .Value}} -{{end}} -{{- end}} - -/* ======================================================================== - * {{.Device}} Input/Output Structures - * ======================================================================== */ -{{if hasWireTag .Device "c2s"}} -#pragma pack(push, 1) -typedef struct { -{{wireFields .Device "c2s" | indent 4}} -} viiper_{{.Device}}_input_t; -#pragma pack(pop) -{{end}} - -{{if hasWireTag .Device "s2c"}} -#pragma pack(push, 1) -typedef struct { -{{wireFields .Device "s2c" | indent 4}} -} viiper_{{.Device}}_output_t; -#pragma pack(pop) -{{end}} - -#endif /* VIIPER_{{upper .Device}}_H */ -` - -type deviceHeaderData struct { - Device string - Pkg *scanner.DeviceConstants -} - -func generateDeviceHeader(logger *slog.Logger, includeDir, device string, md *meta.Metadata) error { - pkg := md.DevicePackages[device] - data := deviceHeaderData{Device: device, Pkg: pkg} - out := filepath.Join(includeDir, fmt.Sprintf("viiper_%s.h", device)) - t := template.Must(template.New("device.h").Funcs(tplFuncs(md)).Parse(deviceHeaderTmpl)) - f, err := os.Create(out) - if err != nil { - return fmt.Errorf("create device header: %w", err) - } - defer f.Close() - if err := t.Execute(f, data); err != nil { - return fmt.Errorf("exec device header tmpl: %w", err) - } - logger.Info("Generated device header", "device", device, "file", out) - return nil -} diff --git a/viiper/internal/codegen/generator/c/helpers.go b/viiper/internal/codegen/generator/c/helpers.go deleted file mode 100644 index dafe38d5..00000000 --- a/viiper/internal/codegen/generator/c/helpers.go +++ /dev/null @@ -1,193 +0,0 @@ -package cgen - -import ( - "fmt" - "strings" - "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" -) - -func tplFuncs(md *meta.Metadata) template.FuncMap { - return template.FuncMap{ - "ctype": cType, - "snakecase": toSnakeCase, - "upper": strings.ToUpper, - "hasWireTag": func(device, direction string) bool { return hasWireTag(md, device, direction) }, - "wireFields": func(device, direction string) string { return wireFields(md, device, direction) }, - "indent": indent, - "hasResponse": hasResponse, - "fieldDecl": fieldDecl, - "pathParams": orderedPathParams, - "join": strings.Join, - } -} - -func cType(goType, kind string) string { - switch { - case strings.HasPrefix(goType, "[]"): - elem := strings.TrimPrefix(goType, "[]") - return cType(elem, "") - } - - switch goType { - case "string": - return "const char*" - case "uint8": - return "uint8_t" - case "uint16": - return "uint16_t" - case "uint32": - return "uint32_t" - case "uint64": - return "uint64_t" - case "int8": - return "int8_t" - case "int16": - return "int16_t" - case "int32": - return "int32_t" - case "int64": - return "int64_t" - case "bool": - return "int" - case "float32": - return "float" - case "float64": - return "double" - default: - if goType == "Device" { - return "viiper_device_info_t" - } - if kind == "struct" { - return fmt.Sprintf("viiper_%s_t*", toSnakeCase(goType)) - } - return goType - } -} - -func toSnakeCase(s string) string { - var b strings.Builder - for i, r := range s { - if i > 0 && r >= 'A' && r <= 'Z' { - b.WriteByte('_') - } - b.WriteRune(r) - } - return strings.ToLower(b.String()) -} - -func hasWireTag(md *meta.Metadata, device, direction string) bool { - if md.WireTags == nil { - return false - } - return md.WireTags.HasDirection(device, direction) -} - -func wireFields(md *meta.Metadata, device, direction string) string { - if md.WireTags == nil { - return "/* no wire tags found */" - } - - tag := md.WireTags.GetTag(device, direction) - if tag == nil { - return "/* no wire tag for this device/direction */" - } - - return renderCWireFields(tag) -} - -func renderCWireFields(tag *scanner.WireTag) string { - if tag == nil || len(tag.Fields) == 0 { - return "/* no fields */" - } - - var lines []string - for _, field := range tag.Fields { - if strings.Contains(field.Type, "*") { - base := strings.Split(field.Type, "*")[0] - cbase := wireBaseToC(base) - lines = append(lines, fmt.Sprintf("%s* %s; size_t %s_count;", cbase, field.Name, toSnake(field.Name))) - continue - } - lines = append(lines, fmt.Sprintf("%s %s;", wireBaseToC(field.Type), field.Name)) - } - return strings.Join(lines, "\n ") -} - -func toSnake(s string) string { - var b strings.Builder - for i, r := range s { - if i > 0 && r >= 'A' && r <= 'Z' { - b.WriteByte('_') - } - b.WriteRune(r) - } - return strings.ToLower(b.String()) -} - -func wireBaseToC(wireType string) string { - switch wireType { - case "u8": - return "uint8_t" - case "u16": - return "uint16_t" - case "u32": - return "uint32_t" - case "u64": - return "uint64_t" - case "i8": - return "int8_t" - case "i16": - return "int16_t" - case "i32": - return "int32_t" - case "i64": - return "int64_t" - default: - return wireType - } -} - -func indent(spaces int, s string) string { - prefix := strings.Repeat(" ", spaces) - parts := strings.Split(s, "\n") - for i, p := range parts { - if p != "" { - parts[i] = prefix + p - } - } - return strings.Join(parts, "\n") -} - -func hasResponse(handler string) bool { return false } - -func orderedPathParams(path string) []string { - if path == "" { - return nil - } - parts := strings.Split(path, "/") - var params []string - for _, p := range parts { - if strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}") { - params = append(params, p[1:len(p)-1]) - } - } - return params -} - -func fieldDecl(f scanner.FieldInfo) string { - if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { - elem := strings.TrimPrefix(f.Type, "[]") - cElem := cType(elem, "") - return fmt.Sprintf("%s* %s; size_t %s_count;%s", cElem, f.Name, toSnakeCase(f.Name), optComment(f)) - } - return fmt.Sprintf("%s %s;%s", cType(f.Type, f.TypeKind), f.Name, optComment(f)) -} - -func optComment(f scanner.FieldInfo) string { - if f.Optional { - return " /* optional */" - } - return "" -} diff --git a/viiper/internal/codegen/generator/c/source_common.go b/viiper/internal/codegen/generator/c/source_common.go deleted file mode 100644 index 644db39c..00000000 --- a/viiper/internal/codegen/generator/c/source_common.go +++ /dev/null @@ -1,567 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - "viiper/internal/codegen/meta" -) - -const commonSourceTmpl = `/* Auto-generated VIIPER - C SDK: common source */ - -#include "viiper/viiper.h" -#include -#include -#include -#if defined(_WIN32) || defined(_WIN64) -# include -# include -# pragma comment(lib, "Ws2_32.lib") -static int viiper_wsa_init_once = 0; -#else -# include -# include -# include -# include -# include -#endif - -/* ======================================================================== - * Internal structures - * ======================================================================== */ - -struct viiper_client { - int socket_fd; - char* host; - uint16_t port; - char error_msg[256]; -}; - -struct viiper_device { - int socket_fd; - viiper_client_t* client; - uint32_t bus_id; - char* dev_id; - /* Async output handling */ - viiper_output_cb callback; - void* callback_user; - int running; -#if defined(_WIN32) || defined(_WIN64) - HANDLE recv_thread; -#else - pthread_t recv_thread; -#endif -}; - -/* ======================================================================== - * Minimal JSON helpers (sufficient for VIIPER API responses) - * ======================================================================== */ - -static const char* json_skip_ws(const char* s){ while(*s==' '||*s=='\t'||*s=='\r'||*s=='\n') ++s; return s; } -static int json_streq(const char* a,const char* b){ return strcmp(a,b)==0; } - -static const char* json_find_key(const char* json, const char* key){ - const size_t klen = strlen(key); - const char* p = json; - while ((p = strchr(p, '"')) != NULL) { - const char* q = strchr(p+1, '"'); if (!q) return NULL; - size_t len = (size_t)(q - (p+1)); - if (len == klen && strncmp(p+1, key, klen) == 0) { - const char* c = json_skip_ws(q+1); - if (*c == ':') return json_skip_ws(c+1); - } - p = q+1; - } - return NULL; -} - -static int json_parse_uint32(const char* json, const char* key, uint32_t* out){ - const char* v = json_find_key(json, key); if (!v) return -1; - unsigned long val = 0; int neg=0; - if (*v=='-'){ neg=1; ++v; } - while (*v>='0' && *v<='9'){ val = val*10 + (unsigned long)(*v - '0'); ++v; } - if (neg) return -1; *out = (uint32_t)val; return 0; -} - -static int json_parse_string_alloc(const char* json, const char* key, char** out){ - const char* v = json_find_key(json, key); if (!v) return -1; - if (*v!='"') return -1; ++v; const char* q = v; while (*q && *q!='"') ++q; if (*q!='"') return -1; - size_t n = (size_t)(q - v); - char* s = (char*)malloc(n+1); if (!s) return -1; memcpy(s, v, n); s[n] = '\0'; *out = s; return 0; -} - -static int json_parse_array_uint32(const char* json, const char* key, uint32_t** arr, size_t* count){ - const char* v = json_find_key(json, key); if (!v) return -1; - if (*v!='[') return -1; ++v; - size_t cap=8, n=0; uint32_t* out = (uint32_t*)malloc(cap*sizeof(uint32_t)); if(!out) return -1; - v = json_skip_ws(v); - while (*v && *v!=']'){ - unsigned long val=0; int got=0; - while (*v>='0' && *v<='9'){ val = val*10 + (unsigned long)(*v - '0'); ++v; got=1; } - if (!got){ free(out); return -1; } - if (n>=cap){ cap*=2; uint32_t* tmp=(uint32_t*)realloc(out,cap*sizeof(uint32_t)); if(!tmp){ free(out); return -1; } out=tmp; } - out[n++] = (uint32_t)val; - v = json_skip_ws(v); - if (*v==','){ ++v; v = json_skip_ws(v); } - } - if (*v!=']'){ free(out); return -1; } - *arr = out; *count = n; return 0; -} - -/* Device info object parser for arrays */ -static int json_parse_device_info_obj(const char* obj, viiper_device_info_t* out){ - if (json_parse_uint32(obj, "busId", &out->BusID) != 0) return -1; - if (json_parse_string_alloc(obj, "devId", (char**)&out->DevId) != 0) return -1; - if (json_parse_string_alloc(obj, "vid", (char**)&out->Vid) != 0) return -1; - if (json_parse_string_alloc(obj, "pid", (char**)&out->Pid) != 0) return -1; - if (json_parse_string_alloc(obj, "type", (char**)&out->Type) != 0) return -1; - return 0; -} - -static int json_parse_array_device_info(const char* json, const char* key, viiper_device_info_t** arr, size_t* count){ - const char* v = json_find_key(json, key); if (!v) return -1; - if (*v!='[') return -1; ++v; - size_t cap=4, n=0; viiper_device_info_t* out = (viiper_device_info_t*)calloc(cap, sizeof(viiper_device_info_t)); if(!out) return -1; - v = json_skip_ws(v); - while (*v && *v!=']'){ - if (*v!='{'){ free(out); return -1; } - int depth=1; const char* start=v; ++v; while (*v && depth>0){ if (*v=='{') depth++; else if (*v=='}') depth--; ++v; } - if (depth!=0){ free(out); return -1; } - const char* end = v; // points after closing '}' - size_t len = (size_t)(end - start); - char* slice = (char*)malloc(len+1); if(!slice){ free(out); return -1; } - memcpy(slice, start, len); slice[len]='\0'; - if (n>=cap){ cap*=2; viiper_device_info_t* tmp=(viiper_device_info_t*)realloc(out,cap*sizeof(viiper_device_info_t)); if(!tmp){ free(slice); free(out); return -1; } out=tmp; } - if (json_parse_device_info_obj(slice, &out[n]) != 0){ free(slice); free(out); return -1; } - free(slice); - n++; - v = json_skip_ws(v); - if (*v==','){ ++v; v = json_skip_ws(v); } - } - if (*v!=']'){ free(out); return -1; } - *arr = out; *count = n; return 0; -} - -/* ======================================================================== - * Client API - * ======================================================================== */ - -VIIPER_API viiper_error_t viiper_client_create( - const char* host, - uint16_t port, - viiper_client_t** out_client -) { - viiper_client_t* client = (viiper_client_t*)calloc(1, sizeof(viiper_client_t)); - if (!client) { - return VIIPER_ERROR_MEMORY; - } - if (host) { - size_t len = strlen(host); - client->host = (char*)malloc(len + 1); - if (!client->host) { - free(client); - return VIIPER_ERROR_MEMORY; - } - memcpy(client->host, host, len + 1); - } else { - client->host = NULL; - } - client->port = port; - client->socket_fd = -1; - *out_client = client; - return VIIPER_OK; -} - -VIIPER_API void viiper_client_free(viiper_client_t* client) { - if (!client) return; - if (client->host) free(client->host); - free(client); -} - -VIIPER_API const char* viiper_get_error(viiper_client_t* client) { - if (!client) return "Invalid client"; - return client->error_msg; -} - -/* ======================================================================== - * Internal networking helpers (line protocol) - * ======================================================================== */ - -static int viiper_connect(const char* host, uint16_t port) { -#if defined(_WIN32) || defined(_WIN64) - if (!viiper_wsa_init_once) { - WSADATA wsaData; - if (WSAStartup(MAKEWORD(2,2), &wsaData) != 0) { - return -1; - } - viiper_wsa_init_once = 1; - } -#endif - char portbuf[16]; - snprintf(portbuf, sizeof portbuf, "%u", (unsigned)port); - struct addrinfo hints; memset(&hints, 0, sizeof hints); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - hints.ai_protocol = IPPROTO_TCP; - struct addrinfo* res = NULL; - if (getaddrinfo(host, portbuf, &hints, &res) != 0 || !res) { - return -1; - } - int fd = -1; - for (struct addrinfo* p = res; p; p = p->ai_next) { - int s = (int)socket(p->ai_family, p->ai_socktype, p->ai_protocol); - if (s < 0) continue; - if (connect(s, p->ai_addr, (int)p->ai_addrlen) == 0) { fd = s; break; } -#if defined(_WIN32) || defined(_WIN64) - closesocket(s); -#else - close(s); -#endif - } - freeaddrinfo(res); - return fd; -} - -static int viiper_send_line(int fd, const char* line) { - size_t n = strlen(line); -#if defined(_WIN32) || defined(_WIN64) - int wr = send(fd, line, (int)n, 0); - if (wr < 0) return -1; - wr = send(fd, "\n", 1, 0); - if (wr < 0) return -1; -#else - ssize_t wr = send(fd, line, n, 0); - if (wr < 0) return -1; - wr = send(fd, "\n", 1, 0); - if (wr < 0) return -1; -#endif - return 0; -} - -static int viiper_read_line(int fd, char** out) { - size_t cap = 256, len = 0; - char* buf = (char*)malloc(cap); - if (!buf) return -1; - for (;;) { - char ch; -#if defined(_WIN32) || defined(_WIN64) - int rd = recv(fd, &ch, 1, 0); -#else - ssize_t rd = recv(fd, &ch, 1, 0); -#endif - if (rd <= 0) { free(buf); return -1; } - if (ch == '\n') break; - if (len + 1 >= cap) { - cap *= 2; - char* nb = (char*)realloc(buf, cap); - if (!nb) { free(buf); return -1; } - buf = nb; - } - buf[len++] = ch; - } - buf[len] = '\0'; - *out = buf; - return 0; -} - -static int viiper_do(viiper_client_t* client, const char* path, const char* payload, char** out_line) { - if (!client || !client->host || client->port == 0) return -1; - int fd = viiper_connect(client->host, client->port); - if (fd < 0) return -1; - size_t need = strlen(path) + 1 + (payload ? strlen(payload) + 1 : 0); - char* line = (char*)malloc(need); - if (!line) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return -1; - } - if (payload && payload[0]) { - snprintf(line, need, "%s %s", path, payload); - } else { - snprintf(line, need, "%s", path); - } - int rc = viiper_send_line(fd, line); - free(line); - if (rc != 0) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return -1; - } - char* resp = NULL; - rc = viiper_read_line(fd, &resp); -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - if (rc != 0) return -1; - *out_line = resp; - return 0; -} - -/* ======================================================================== - * DTO parsers and free helpers - * ======================================================================== */ - -/* Free helpers */ -VIIPER_API void viiper_free_bus_list_response(viiper_bus_list_response_t* v){ if (!v) return; if (v->Buses) free(v->Buses); } -VIIPER_API void viiper_free_bus_create_response(viiper_bus_create_response_t* v){ (void)v; } -VIIPER_API void viiper_free_bus_remove_response(viiper_bus_remove_response_t* v){ (void)v; } -VIIPER_API void viiper_free_devices_list_response(viiper_devices_list_response_t* v){ if (!v) return; if (v->Devices){ for (size_t i=0;idevices_count;i++){ if (v->Devices[i].DevId) free((void*)v->Devices[i].DevId); if (v->Devices[i].Vid) free((void*)v->Devices[i].Vid); if (v->Devices[i].Pid) free((void*)v->Devices[i].Pid); if (v->Devices[i].Type) free((void*)v->Devices[i].Type); } free(v->Devices);} } -VIIPER_API void viiper_free_device_add_response(viiper_device_add_response_t* v){ if (!v) return; if (v->ID) free((void*)v->ID); } -VIIPER_API void viiper_free_device_remove_response(viiper_device_remove_response_t* v){ if (!v) return; if (v->DevId) free((void*)v->DevId); } -VIIPER_API void viiper_free_api_error(viiper_api_error_t* v){ if (!v) return; if (v->Error) free((void*)v->Error); } - -/* Parsers */ -static int viiper_parse_bus_list_response(const char* json, viiper_bus_list_response_t* out){ return json_parse_array_uint32(json, "buses", &out->Buses, &out->buses_count) == 0 ? 0 : -1; } -static int viiper_parse_bus_create_response(const char* json, viiper_bus_create_response_t* out){ return json_parse_uint32(json, "busId", &out->BusID)==0?0:-1; } -static int viiper_parse_bus_remove_response(const char* json, viiper_bus_remove_response_t* out){ return json_parse_uint32(json, "busId", &out->BusID)==0?0:-1; } -static int viiper_parse_devices_list_response(const char* json, viiper_devices_list_response_t* out){ return json_parse_array_device_info(json, "devices", &out->Devices, &out->devices_count)==0?0:-1; } -static int viiper_parse_device_add_response(const char* json, viiper_device_add_response_t* out){ return json_parse_string_alloc(json, "id", (char**)&out->ID)==0?0:-1; } -static int viiper_parse_device_remove_response(const char* json, viiper_device_remove_response_t* out){ if (json_parse_uint32(json, "busId", &out->BusID)!=0) return -1; return json_parse_string_alloc(json, "devId", (char**)&out->DevId)==0?0:-1; } - -/* ======================================================================== - * Management API - Implementations - * ======================================================================== */ -{{range .Routes}} -VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( - viiper_client_t* client{{ $params := pathParams .Path }}{{range $params}}, - const char* {{.}}{{end}}{{range .Arguments}}, - {{ctype .Type ""}} {{.Name}}{{end}}{{if .ResponseDTO}}, - viiper_{{snakecase .ResponseDTO}}_t* out{{end}} -) { - if (!client) return VIIPER_ERROR_INVALID_PARAM; - /* Build path by substituting params in order */ - char pathbuf[256]; - const char* pattern = "{{.Path}}"; - snprintf(pathbuf, sizeof pathbuf, "%s", pattern); - {{ $params := pathParams .Path }} - {{range $i, $p := $params}} - { /* replace {{$p}} placeholder with provided argument */ - const char* needle = "{{printf "{%s}" $p}}"; - char tmp[256]; tmp[0]='\0'; - char* pos = strstr(pathbuf, needle); - if (pos) { - size_t head = (size_t)(pos - pathbuf); - snprintf(tmp, sizeof tmp, "%.*s%s%s", (int)head, pathbuf, {{ $p }}, pos + strlen(needle)); - snprintf(pathbuf, sizeof pathbuf, "%s", tmp); - } - } - {{end}} - /* Build payload from args (space-separated) */ - char payload[256]; payload[0]='\0'; - {{if gt (len .Arguments) 0}} - { - char buf[256]; buf[0]='\0'; - {{range $i, $a := .Arguments}} - {{/* naive formatting: strings as-is, numbers as unsigned */}} - {{- if eq (ctype $a.Type "") "const char*" -}} - snprintf(buf + strlen(buf), sizeof(buf) - strlen(buf), "%s%s", (strlen(buf)>0?" ":""), {{$a.Name}} ? {{$a.Name}} : ""); - {{- else -}} - snprintf(buf + strlen(buf), sizeof(buf) - strlen(buf), "%s%u", (strlen(buf)>0?" ":""), (unsigned){{$a.Name}}); - {{- end}} - {{end}} - snprintf(payload, sizeof payload, "%s", buf); - } - {{end}} - char* line = NULL; - if (viiper_do(client, pathbuf, payload[0]?payload:NULL, &line) != 0) { - snprintf(client->error_msg, sizeof client->error_msg, "io error"); - return VIIPER_ERROR_IO; - } - /* rudimentary error detection: {"error":...} */ - if (line && strncmp(line, "{\"error\":", 9) == 0) { - snprintf(client->error_msg, sizeof client->error_msg, "%s", line); - free(line); - return VIIPER_ERROR_PROTOCOL; - } - {{if .ResponseDTO}} - if (out) { - int prc = 0; - {{if eq .ResponseDTO "BusListResponse"}} prc = viiper_parse_bus_list_response(line, out); - {{else if eq .ResponseDTO "BusCreateResponse"}} prc = viiper_parse_bus_create_response(line, out); - {{else if eq .ResponseDTO "BusRemoveResponse"}} prc = viiper_parse_bus_remove_response(line, out); - {{else if eq .ResponseDTO "DevicesListResponse"}} prc = viiper_parse_devices_list_response(line, out); - {{else if eq .ResponseDTO "DeviceAddResponse"}} prc = viiper_parse_device_add_response(line, out); - {{else if eq .ResponseDTO "DeviceRemoveResponse"}} prc = viiper_parse_device_remove_response(line, out); - {{else}} prc = 0; {{end}} - if (prc != 0) { snprintf(client->error_msg, sizeof client->error_msg, "parse error"); free(line); return VIIPER_ERROR_PROTOCOL; } - } - free(line); - {{else}} - free(line); - {{end}} - return VIIPER_OK; -} -{{end}} - -/* ======================================================================== - * Device Streaming API Implementation - * ======================================================================== */ - -#if defined(_WIN32) || defined(_WIN64) -static DWORD WINAPI viiper_device_receiver_thread(LPVOID arg) { -#else -static void* viiper_device_receiver_thread(void* arg) { -#endif - viiper_device_t* dev = (viiper_device_t*)arg; - unsigned char buf[4096]; - while (dev->running) { -#if defined(_WIN32) || defined(_WIN64) - DWORD timeout_ms = 200; - fd_set rfds; FD_ZERO(&rfds); FD_SET((SOCKET)dev->socket_fd, &rfds); - struct timeval tv; tv.tv_sec = 0; tv.tv_usec = timeout_ms * 1000; - int sel = select(0, &rfds, NULL, NULL, &tv); - if (sel < 0) break; - if (sel == 0) continue; /* timeout */ - int rd = recv(dev->socket_fd, (char*)buf, sizeof buf, 0); -#else - fd_set rfds; FD_ZERO(&rfds); FD_SET(dev->socket_fd, &rfds); - struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 200000; - int sel = select(dev->socket_fd+1, &rfds, NULL, NULL, &tv); - if (sel < 0) break; - if (sel == 0) continue; /* timeout */ - ssize_t rd = recv(dev->socket_fd, buf, sizeof buf, 0); -#endif - if (rd <= 0) break; - if (dev->callback) { - dev->callback(buf, (size_t)rd, dev->callback_user); - } - } -#if defined(_WIN32) || defined(_WIN64) - return 0; -#else - return NULL; -#endif -} - -VIIPER_API viiper_error_t viiper_device_create( - viiper_client_t* client, - uint32_t bus_id, - const char* dev_id, - viiper_device_t** out_device -) { - if (!client || !dev_id || !out_device) return VIIPER_ERROR_INVALID_PARAM; - int fd = viiper_connect(client->host, client->port); - if (fd < 0) return VIIPER_ERROR_CONNECT; - /* Send stream path: bus//\n */ - char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s\n", (unsigned)bus_id, dev_id); - size_t n = strlen(pathbuf); -#if defined(_WIN32) || defined(_WIN64) - if (send(fd, pathbuf, (int)n, 0) < 0) { closesocket(fd); return VIIPER_ERROR_IO; } -#else - if (send(fd, pathbuf, n, 0) < 0) { close(fd); return VIIPER_ERROR_IO; } -#endif - viiper_device_t* dev = (viiper_device_t*)calloc(1, sizeof(viiper_device_t)); - if (!dev) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return VIIPER_ERROR_MEMORY; - } - dev->socket_fd = fd; - dev->client = client; - dev->bus_id = bus_id; - size_t idlen = strlen(dev_id); - dev->dev_id = (char*)malloc(idlen+1); - if (!dev->dev_id) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - free(dev); - return VIIPER_ERROR_MEMORY; - } - memcpy(dev->dev_id, dev_id, idlen+1); - dev->running = 1; - /* Start receiver thread */ -#if defined(_WIN32) || defined(_WIN64) - dev->recv_thread = CreateThread(NULL, 0, viiper_device_receiver_thread, dev, 0, NULL); - if (!dev->recv_thread) { - closesocket(fd); - free(dev->dev_id); - free(dev); - return VIIPER_ERROR_IO; - } -#else - if (pthread_create(&dev->recv_thread, NULL, viiper_device_receiver_thread, dev) != 0) { - close(fd); - free(dev->dev_id); - free(dev); - return VIIPER_ERROR_IO; - } -#endif - *out_device = dev; - return VIIPER_OK; -} - -VIIPER_API viiper_error_t viiper_device_send( - viiper_device_t* device, - const void* input, - size_t input_size -) { - if (!device || !input) return VIIPER_ERROR_INVALID_PARAM; -#if defined(_WIN32) || defined(_WIN64) - int wr = send(device->socket_fd, (const char*)input, (int)input_size, 0); -#else - ssize_t wr = send(device->socket_fd, input, input_size, 0); -#endif - return (wr < 0) ? VIIPER_ERROR_IO : VIIPER_OK; -} - -VIIPER_API void viiper_device_on_output( - viiper_device_t* device, - viiper_output_cb callback, - void* user_data -) { - if (!device) return; - device->callback = callback; - device->callback_user = user_data; -} - -VIIPER_API void viiper_device_close(viiper_device_t* device) { - if (!device) return; - device->running = 0; -#if defined(_WIN32) || defined(_WIN64) - shutdown(device->socket_fd, SD_BOTH); - if (device->recv_thread) { - WaitForSingleObject(device->recv_thread, INFINITE); - CloseHandle(device->recv_thread); - } - closesocket(device->socket_fd); -#else - shutdown(device->socket_fd, SHUT_RDWR); - pthread_join(device->recv_thread, NULL); - close(device->socket_fd); -#endif - if (device->dev_id) free(device->dev_id); - free(device); -} -` - -func generateCommonSource(logger *slog.Logger, srcDir string, md *meta.Metadata) error { - out := filepath.Join(srcDir, "viiper.c") - t := template.Must(template.New("viiper.c").Funcs(tplFuncs(md)).Parse(commonSourceTmpl)) - f, err := os.Create(out) - if err != nil { - return fmt.Errorf("create source: %w", err) - } - defer f.Close() - if err := t.Execute(f, md); err != nil { - return fmt.Errorf("exec source tmpl: %w", err) - } - logger.Info("Generated common source", "file", out) - return nil -} diff --git a/viiper/internal/codegen/generator/c/source_device.go b/viiper/internal/codegen/generator/c/source_device.go deleted file mode 100644 index 3c90875c..00000000 --- a/viiper/internal/codegen/generator/c/source_device.go +++ /dev/null @@ -1,38 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - "viiper/internal/codegen/meta" -) - -const deviceSourceTmpl = `/* Auto-generated VIIPER - C SDK: device source ({{.Device}}) */ - -#include "viiper/viiper.h" -#include "viiper/viiper_{{.Device}}.h" - -` - -type deviceSourceData struct { - Device string - HasS2C bool -} - -func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.Metadata) error { - data := deviceSourceData{Device: device, HasS2C: hasWireTag(md, device, "s2c")} - out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) - t := template.Must(template.New("device.c").Parse(deviceSourceTmpl)) - f, err := os.Create(out) - if err != nil { - return fmt.Errorf("create device source: %w", err) - } - defer f.Close() - if err := t.Execute(f, data); err != nil { - return fmt.Errorf("exec device source tmpl: %w", err) - } - logger.Info("Generated device source", "device", device, "file", out) - return nil -} diff --git a/viiper/internal/codegen/generator/generator.go b/viiper/internal/codegen/generator/generator.go deleted file mode 100644 index 208f543e..00000000 --- a/viiper/internal/codegen/generator/generator.go +++ /dev/null @@ -1,139 +0,0 @@ -package generator - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - cgen "viiper/internal/codegen/generator/c" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" -) - -// Generator orchestrates SDK generation for all target languages -type Generator struct { - outputDir string - logger *slog.Logger -} - -// New creates a new Generator instance -func New(outputDir string, logger *slog.Logger) *Generator { - return &Generator{ - outputDir: outputDir, - logger: logger, - } -} - -// ScanAll runs all scanners to collect metadata -func (g *Generator) ScanAll() (*meta.Metadata, error) { - g.logger.Info("Scanning codebase for metadata") - - md := &meta.Metadata{ - DevicePackages: make(map[string]*scanner.DeviceConstants), - } - - // Scan routes - g.logger.Debug("Scanning API routes") - routes, err := scanner.ScanRoutesInPackage("internal/cmd") - if err != nil { - return nil, fmt.Errorf("failed to scan routes: %w", err) - } - md.Routes = routes - g.logger.Info("Found API routes", "count", len(routes)) - - // Scan DTOs - g.logger.Debug("Scanning DTOs") - dtos, err := scanner.ScanDTOsInPackage("pkg/apitypes") - if err != nil { - return nil, fmt.Errorf("failed to scan DTOs: %w", err) - } - md.DTOs = dtos - g.logger.Info("Found DTOs", "count", len(dtos)) - - // Discover and scan device packages - g.logger.Debug("Discovering device packages") - deviceBaseDir := "pkg/device" - entries, err := os.ReadDir(deviceBaseDir) - if err != nil { - return nil, fmt.Errorf("failed to read device directory: %w", err) - } - - var devicePaths []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - deviceName := entry.Name() - devicePath := filepath.Join(deviceBaseDir, deviceName) - devicePaths = append(devicePaths, devicePath) - - g.logger.Debug("Scanning device package", "device", deviceName) - deviceConsts, err := scanner.ScanDeviceConstants(devicePath) - if err != nil { - g.logger.Warn("Failed to scan device package", "device", deviceName, "error", err) - continue - } - - md.DevicePackages[deviceName] = deviceConsts - g.logger.Info("Scanned device package", - "device", deviceName, - "constants", len(deviceConsts.Constants), - "maps", len(deviceConsts.Maps)) - } - - // Scan wire tags from all device packages - g.logger.Debug("Scanning viiper:wire tags") - wireTags, err := scanner.ScanWireTags(devicePaths) - if err != nil { - return nil, fmt.Errorf("failed to scan wire tags: %w", err) - } - md.WireTags = wireTags - g.logger.Info("Scanned wire tags", "devices", len(wireTags.Tags)) - - g.logger.Debug("Enriching routes with handler arg info") - enriched, err := scanner.EnrichRoutesWithHandlerInfo(md.Routes, "internal/server/api/handler") - if err != nil { - return nil, fmt.Errorf("failed to enrich routes: %w", err) - } - md.Routes = enriched - - return md, nil -} - -// GenerateC generates the C SDK -func (g *Generator) GenerateC() error { - g.logger.Info("Generating C SDK") - - md, err := g.ScanAll() - if err != nil { - return err - } - - outputPath := filepath.Join(g.outputDir, "c") - if err := os.MkdirAll(outputPath, 0755); err != nil { - return fmt.Errorf("failed to create C output directory: %w", err) - } - - // Delegate to C generator subpackage - if err := cgen.Generate(g.logger, outputPath, md); err != nil { - return err - } - - g.logger.Info("C SDK generation complete", "output", outputPath) - return nil -} - -// GenerateCSharp generates the C# SDK -func (g *Generator) GenerateCSharp() error { - g.logger.Info("Generating C# SDK") - // TODO: Implement in Step 12 - return fmt.Errorf("C# generation not yet implemented") -} - -// GenerateTypeScript generates the TypeScript SDK -func (g *Generator) GenerateTypeScript() error { - g.logger.Info("Generating TypeScript SDK") - // TODO: Implement in Step 13 - return fmt.Errorf("TypeScript generation not yet implemented") -} diff --git a/viiper/internal/codegen/scanner/constants.go b/viiper/internal/codegen/scanner/constants.go deleted file mode 100644 index 58915375..00000000 --- a/viiper/internal/codegen/scanner/constants.go +++ /dev/null @@ -1,273 +0,0 @@ -package scanner - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "strconv" - "strings" -) - -// ConstantInfo represents a single constant definition -type ConstantInfo struct { - Name string `json:"name"` - Value interface{} `json:"value"` // Can be int, string, etc. - Type string `json:"type"` // e.g., "int", "uint8", "string" -} - -// MapInfo represents a map variable with its entries -type MapInfo struct { - Name string `json:"name"` - KeyType string `json:"keyType"` - ValueType string `json:"valueType"` - Entries map[string]interface{} `json:"entries"` // Key as string, value as interface{} -} - -// DeviceConstants holds all constants and maps for a device package -type DeviceConstants struct { - DeviceType string `json:"deviceType"` // "keyboard", "mouse", "xbox360" - Constants []ConstantInfo `json:"constants"` - Maps []MapInfo `json:"maps"` -} - -// ScanDeviceConstants scans a device package directory for constants and maps -func ScanDeviceConstants(devicePkgPath string) (*DeviceConstants, error) { - deviceType := filepath.Base(devicePkgPath) - result := &DeviceConstants{ - DeviceType: deviceType, - Constants: []ConstantInfo{}, - Maps: []MapInfo{}, - } - - entries, err := os.ReadDir(devicePkgPath) - if err != nil { - return nil, fmt.Errorf("failed to read directory %s: %w", devicePkgPath, err) - } - - fset := token.NewFileSet() - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue - } - - filePath := filepath.Join(devicePkgPath, entry.Name()) - file, err := parseFile(fset, filePath) - if err != nil { - continue - } - - for _, decl := range file.Decls { - if genDecl, ok := decl.(*ast.GenDecl); ok { - if genDecl.Tok == token.CONST { - result.Constants = append(result.Constants, extractConstants(genDecl)...) - } else if genDecl.Tok == token.VAR { - maps := extractMaps(genDecl) - result.Maps = append(result.Maps, maps...) - } - } - } - } - - return result, nil -} - -func parseFile(fset *token.FileSet, filePath string) (*ast.File, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, err - } - - return parseGoSource(fset, filePath, data) -} - -func parseGoSource(fset *token.FileSet, filename string, src []byte) (*ast.File, error) { - return parser.ParseFile(fset, filename, src, parser.ParseComments) -} - -func extractConstants(genDecl *ast.GenDecl) []ConstantInfo { - var constants []ConstantInfo - - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - - var typeName string - if valueSpec.Type != nil { - typeName = exprToString(valueSpec.Type) - } - - for i, name := range valueSpec.Names { - if !name.IsExported() { - continue - } - - constInfo := ConstantInfo{ - Name: name.Name, - Type: typeName, - } - - if i < len(valueSpec.Values) { - constInfo.Value = extractValue(valueSpec.Values[i]) - if typeName == "" { - constInfo.Type = inferType(constInfo.Value) - } - } - - constants = append(constants, constInfo) - } - } - - return constants -} - -func extractMaps(genDecl *ast.GenDecl) []MapInfo { - var maps []MapInfo - - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - - for i, name := range valueSpec.Names { - if !name.IsExported() { - continue - } - - var mapType *ast.MapType - - if valueSpec.Type != nil { - if mt, ok := valueSpec.Type.(*ast.MapType); ok { - mapType = mt - } - } - - if mapType == nil && i < len(valueSpec.Values) { - if compositeLit, ok := valueSpec.Values[i].(*ast.CompositeLit); ok { - if mt, ok := compositeLit.Type.(*ast.MapType); ok { - mapType = mt - } - } - } - - if mapType == nil { - continue - } - - mapInfo := MapInfo{ - Name: name.Name, - KeyType: exprToString(mapType.Key), - ValueType: exprToString(mapType.Value), - Entries: make(map[string]interface{}), - } - - if i < len(valueSpec.Values) { - if compositeLit, ok := valueSpec.Values[i].(*ast.CompositeLit); ok { - mapInfo.Entries = extractMapEntries(compositeLit) - } - } - - maps = append(maps, mapInfo) - } - } - - return maps -} - -func extractMapEntries(compositeLit *ast.CompositeLit) map[string]interface{} { - entries := make(map[string]interface{}) - - for _, elt := range compositeLit.Elts { - kvExpr, ok := elt.(*ast.KeyValueExpr) - if !ok { - continue - } - - key := extractValue(kvExpr.Key) - value := extractValue(kvExpr.Value) - - keyStr := fmt.Sprintf("%v", key) - entries[keyStr] = value - } - - return entries -} - -func extractValue(expr ast.Expr) interface{} { - switch e := expr.(type) { - case *ast.BasicLit: - switch e.Kind { - case token.INT: - if val, err := strconv.ParseInt(e.Value, 0, 64); err == nil { - return val - } - if val, err := strconv.ParseUint(e.Value, 0, 64); err == nil { - return val - } - case token.STRING: - return strings.Trim(e.Value, `"`) - case token.FLOAT: - if val, err := strconv.ParseFloat(e.Value, 64); err == nil { - return val - } - case token.CHAR: - val := strings.Trim(e.Value, "'") - if len(val) == 1 { - return val - } - return val - } - case *ast.Ident: - return e.Name - case *ast.BinaryExpr: - return fmt.Sprintf("%s %s %s", extractValue(e.X), e.Op.String(), extractValue(e.Y)) - case *ast.UnaryExpr: - return fmt.Sprintf("%s%v", e.Op.String(), extractValue(e.X)) - } - return nil -} - -func exprToString(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.SelectorExpr: - return exprToString(e.X) + "." + e.Sel.Name - case *ast.StarExpr: - return "*" + exprToString(e.X) - case *ast.ArrayType: - if e.Len != nil { - return fmt.Sprintf("[%s]%s", exprToString(e.Len), exprToString(e.Elt)) - } - return "[]" + exprToString(e.Elt) - case *ast.MapType: - return fmt.Sprintf("map[%s]%s", exprToString(e.Key), exprToString(e.Value)) - } - return "" -} - -func inferType(value interface{}) string { - switch v := value.(type) { - case int64: - if v >= 0 && v <= 255 { - return "uint8" - } - return "int" - case uint64: - if v <= 255 { - return "uint8" - } - return "uint" - case string: - return "string" - case float64: - return "float64" - default: - return "unknown" - } -} diff --git a/viiper/internal/codegen/scanner/handlers.go b/viiper/internal/codegen/scanner/handlers.go deleted file mode 100644 index 9c79fa04..00000000 --- a/viiper/internal/codegen/scanner/handlers.go +++ /dev/null @@ -1,279 +0,0 @@ -package scanner - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "strconv" - "strings" -) - -// HandlerArgInfo describes argument usage discovered in a handler function. -type HandlerArgInfo struct { - HandlerName string `json:"handlerName"` - UsesArgs bool `json:"usesArgs"` // Whether req.Args is accessed - ArgAccesses []ArgAccessInfo `json:"argAccesses"` // Specific arg accesses like req.Args[0] -} - -// ArgAccessInfo describes a specific access to req.Args. -type ArgAccessInfo struct { - Index int `json:"index"` // Index accessed (e.g., 0 for req.Args[0]) - Required bool `json:"required"` // Whether it's required (checked with len < N) - VarName string `json:"varName"` // Variable name if assigned (e.g., "busId") -} - -// ScanHandlerArgs scans handler function implementations to discover argument usage patterns. -// This helps determine what arguments each route expects. -func ScanHandlerArgs(pkgPath string) (map[string]HandlerArgInfo, error) { - matches, err := filepath.Glob(filepath.Join(pkgPath, "*.go")) - if err != nil { - return nil, fmt.Errorf("glob handler files: %w", err) - } - - handlerInfo := make(map[string]HandlerArgInfo) - - for _, file := range matches { - if strings.HasSuffix(file, "_test.go") { - continue - } - - info, err := scanHandlerFile(file) - if err != nil { - return nil, fmt.Errorf("scan %s: %w", file, err) - } - - for k, v := range info { - handlerInfo[k] = v - } - } - - return handlerInfo, nil -} - -func scanHandlerFile(filePath string) (map[string]HandlerArgInfo, error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - handlerInfo := make(map[string]HandlerArgInfo) - - ast.Inspect(node, func(n ast.Node) bool { - funcDecl, ok := n.(*ast.FuncDecl) - if !ok { - return true - } - - if funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) == 0 { - return true - } - - returnsHandlerFunc := false - for _, result := range funcDecl.Type.Results.List { - if selExpr, ok := result.Type.(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "HandlerFunc" { - returnsHandlerFunc = true - break - } - } - } - - if !returnsHandlerFunc { - return true - } - - handlerName := funcDecl.Name.Name - info := HandlerArgInfo{ - HandlerName: handlerName, - UsesArgs: false, - ArgAccesses: []ArgAccessInfo{}, - } - - lengthChecks := findLengthChecks(funcDecl.Body) - - varAssignments := findArgAssignments(funcDecl.Body) - - ast.Inspect(funcDecl.Body, func(n ast.Node) bool { - indexExpr, ok := n.(*ast.IndexExpr) - if !ok { - return true - } - - if selExpr, ok := indexExpr.X.(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "Args" { - info.UsesArgs = true - - if basicLit, ok := indexExpr.Index.(*ast.BasicLit); ok { - if basicLit.Kind == token.INT { - idx, _ := strconv.Atoi(basicLit.Value) - - required := isArgRequired(idx, lengthChecks) - - varName := varAssignments[idx] - if varName == "" { - varName = fmt.Sprintf("arg%d", idx) - } - - info.ArgAccesses = append(info.ArgAccesses, ArgAccessInfo{ - Index: idx, - Required: required, - VarName: varName, - }) - } - } - } - } - - return true - }) - - if info.UsesArgs || len(info.ArgAccesses) > 0 { - handlerInfo[handlerName] = info - } - - return true - }) - - return handlerInfo, nil -} - -// findLengthChecks finds `if len(req.Args) < N` or `if len(req.Args) >= N` patterns -// Returns a map of index -> required status -func findLengthChecks(body *ast.BlockStmt) map[int]bool { - checks := make(map[int]bool) - if body == nil { - return checks - } - - ast.Inspect(body, func(n ast.Node) bool { - ifStmt, ok := n.(*ast.IfStmt) - if !ok { - return true - } - - binaryExpr, ok := ifStmt.Cond.(*ast.BinaryExpr) - if !ok { - return true - } - - var lenValue int - var isLess bool - - if callExpr, ok := binaryExpr.X.(*ast.CallExpr); ok { - if ident, ok := callExpr.Fun.(*ast.Ident); ok && ident.Name == "len" { - if len(callExpr.Args) > 0 { - if selExpr, ok := callExpr.Args[0].(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "Args" { - if lit, ok := binaryExpr.Y.(*ast.BasicLit); ok { - lenValue, _ = strconv.Atoi(lit.Value) - isLess = (binaryExpr.Op == token.LSS) // < - - if isLess { - for i := 0; i < lenValue; i++ { - checks[i] = true - } - } - } - } - } - } - } - } - - return true - }) - - return checks -} - -func findArgAssignments(body *ast.BlockStmt) map[int]string { - assignments := make(map[int]string) - if body == nil { - return assignments - } - - ast.Inspect(body, func(n ast.Node) bool { - assignStmt, ok := n.(*ast.AssignStmt) - if !ok { - return true - } - - for rhsIdx, rhs := range assignStmt.Rhs { - var argIdx int = -1 - var found bool - - if indexExpr, ok := rhs.(*ast.IndexExpr); ok { - if selExpr, ok := indexExpr.X.(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "Args" { - if lit, ok := indexExpr.Index.(*ast.BasicLit); ok { - if lit.Kind == token.INT { - argIdx, _ = strconv.Atoi(lit.Value) - found = true - } - } - } - } - } - - if !found { - if callExpr, ok := rhs.(*ast.CallExpr); ok { - for _, arg := range callExpr.Args { - if indexExpr, ok := arg.(*ast.IndexExpr); ok { - if selExpr, ok := indexExpr.X.(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "Args" { - if lit, ok := indexExpr.Index.(*ast.BasicLit); ok { - if lit.Kind == token.INT { - argIdx, _ = strconv.Atoi(lit.Value) - found = true - break - } - } - } - } - } - if !found { - if nestedCall, ok := arg.(*ast.CallExpr); ok { - for _, nestedArg := range nestedCall.Args { - if indexExpr, ok := nestedArg.(*ast.IndexExpr); ok { - if selExpr, ok := indexExpr.X.(*ast.SelectorExpr); ok { - if selExpr.Sel.Name == "Args" { - if lit, ok := indexExpr.Index.(*ast.BasicLit); ok { - if lit.Kind == token.INT { - argIdx, _ = strconv.Atoi(lit.Value) - found = true - break - } - } - } - } - } - } - } - } - } - } - } - - if found && argIdx >= 0 { - if rhsIdx < len(assignStmt.Lhs) { - if ident, ok := assignStmt.Lhs[rhsIdx].(*ast.Ident); ok { - if ident.Name != "err" && ident.Name != "_" { - assignments[argIdx] = ident.Name - } - } - } - } - } - - return true - }) - - return assignments -} - -func isArgRequired(idx int, checks map[int]bool) bool { - return checks[idx] -} diff --git a/viiper/internal/codegen/scanner/routes_test.go b/viiper/internal/codegen/scanner/routes_test.go deleted file mode 100644 index d15a3603..00000000 --- a/viiper/internal/codegen/scanner/routes_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package scanner - -import ( - "encoding/json" - "testing" -) - -func TestScanRoutes(t *testing.T) { - // Scan the actual server.go file where routes are registered - routes, err := ScanRoutes("../../cmd/server.go") - if err != nil { - t.Fatalf("ScanRoutes failed: %v", err) - } - - if len(routes) == 0 { - t.Fatal("expected at least one route, got none") - } - - // Verify we found the expected routes - expectedPaths := map[string]bool{ - "bus/list": true, - "bus/create": true, - "bus/remove": true, - "bus/{id}/list": true, - "bus/{id}/add": true, - "bus/{id}/remove": true, - "bus/{busId}/{deviceid}": true, - } - - foundPaths := make(map[string]bool) - for _, route := range routes { - foundPaths[route.Path] = true - } - - for expectedPath := range expectedPaths { - if !foundPaths[expectedPath] { - t.Errorf("expected to find route %q, but it was not discovered", expectedPath) - } - } - - // Print discovered routes as JSON for manual inspection - t.Log("Discovered routes:") - for _, route := range routes { - data, _ := json.MarshalIndent(route, "", " ") - t.Logf("%s", data) - } -} - -func TestScanHandlerArgs(t *testing.T) { - // Scan handler implementations - handlerInfo, err := ScanHandlerArgs("../../server/api/handler") - if err != nil { - t.Fatalf("ScanHandlerArgs failed: %v", err) - } - - t.Logf("Found %d handlers with argument usage", len(handlerInfo)) - - // Print handler info for inspection - for name, info := range handlerInfo { - t.Logf("Handler: %s", name) - data, _ := json.MarshalIndent(info, " ", " ") - t.Logf(" %s", data) - } - - // Verify BusCreate uses args - if info, ok := handlerInfo["BusCreate"]; ok { - if !info.UsesArgs { - t.Errorf("expected BusCreate to use req.Args") - } - } -} - -func TestEnrichRoutesWithHandlerInfo(t *testing.T) { - // Scan routes - routes, err := ScanRoutes("../../cmd/server.go") - if err != nil { - t.Fatalf("ScanRoutes failed: %v", err) - } - - // Enrich with handler argument info - enriched, err := EnrichRoutesWithHandlerInfo(routes, "../../server/api/handler") - if err != nil { - t.Fatalf("EnrichRoutesWithHandlerInfo failed: %v", err) - } - - t.Log("Enriched routes:") - for _, route := range enriched { - data, _ := json.MarshalIndent(route, "", " ") - t.Logf("%s", data) - } - - // Verify bus/create has argument info - for _, route := range enriched { - if route.Path == "bus/create" && len(route.Arguments) > 0 { - t.Logf("bus/create correctly identified with %d argument(s)", len(route.Arguments)) - } - } -} diff --git a/viiper/internal/config/config.go b/viiper/internal/config/config.go deleted file mode 100644 index dabc98a2..00000000 --- a/viiper/internal/config/config.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package config defines the CLI structure and configuration for VIIPER. -package config - -import ( - "viiper/internal/cmd" -) - -type Log struct { - Level string `help:"Log level: trace, debug, info, warn, error" default:"info" env:"VIIPER_LOG_LEVEL"` - File string `help:"Log file path (default: none; logs only to console)" env:"VIIPER_LOG_FILE"` - RawFile string `help:"Raw packet log file path (default: none)" env:"VIIPER_LOG_RAW_FILE"` -} - -// CLI is the root command structure for Kong CLI parsing. -type CLI struct { - // Global - ConfigPath string `help:"Path to configuration file (json|yaml|toml)" name:"config" env:"VIIPER_CONFIG"` - Log `embed:"" prefix:"log."` - - Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server"` - Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` - - Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` - Codegen cmd.Codegen `cmd:"" help:"Generate client SDKs from server code"` -} diff --git a/viiper/internal/registry/devices.go b/viiper/internal/registry/devices.go deleted file mode 100644 index b4897a4c..00000000 --- a/viiper/internal/registry/devices.go +++ /dev/null @@ -1,7 +0,0 @@ -package registry - -import ( - _ "viiper/pkg/device/keyboard" // Register keyboard device handler - _ "viiper/pkg/device/mouse" // Register mouse device handler - _ "viiper/pkg/device/xbox360" // Register xbox360 device handler -) diff --git a/viiper/internal/server/api/config.go b/viiper/internal/server/api/config.go deleted file mode 100644 index 380bc318..00000000 --- a/viiper/internal/server/api/config.go +++ /dev/null @@ -1,10 +0,0 @@ -package api - -import "time" - -// ServerConfig represents the server subcommand configuration. -type ServerConfig struct { - Addr string `help:"API server listen address" default:":3242" env:"VIIPER_API_ADDR"` - ConnectionTimeout time.Duration `kong:"-"` - DeviceHandlerConnectTimeout time.Duration `help:"Time before auto-cleanup occurs when device handler has no active connection" default:"5s" env:"VIIPER_API_DEVICE_HANDLER_TIMEOUT"` -} diff --git a/viiper/internal/server/api/handler/bus_create.go b/viiper/internal/server/api/handler/bus_create.go deleted file mode 100644 index 2550e7ab..00000000 --- a/viiper/internal/server/api/handler/bus_create.go +++ /dev/null @@ -1,47 +0,0 @@ -package handler - -import ( - "encoding/json" - "log/slog" - "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" - "viiper/pkg/virtualbus" -) - -// BusCreate returns a handler that creates a new bus. -// Error logging is centralized in the API server; this handler only returns errors. -func BusCreate(s *usb.Server) api.HandlerFunc { - return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - if len(req.Args) >= 1 { - busId, err := strconv.ParseUint(req.Args[0], 10, 32) - if err != nil { - return err - } - b, err := virtualbus.NewWithBusId(uint32(busId)) - if err != nil { - return err - } - if err := s.AddBus(b); err != nil { - return err - } - out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) - if err != nil { - return err - } - res.JSON = string(out) - return nil - } - b := virtualbus.New() - if err := s.AddBus(b); err != nil { - return err - } - out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) - if err != nil { - return err - } - res.JSON = string(out) - return nil - } -} diff --git a/viiper/internal/server/api/handler/bus_device_add.go b/viiper/internal/server/api/handler/bus_device_add.go deleted file mode 100644 index ebab3a7a..00000000 --- a/viiper/internal/server/api/handler/bus_device_add.go +++ /dev/null @@ -1,81 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "log/slog" - "strconv" - "strings" - - "viiper/internal/server/api" - usbs "viiper/internal/server/usb" - "viiper/pkg/apitypes" - "viiper/pkg/device" -) - -// BusDeviceAdd returns a handler to add devices to a bus. -func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { - return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - idStr, ok := req.Params["id"] - if !ok { - return fmt.Errorf("missing id") - } - busID, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return err - } - b := s.GetBus(uint32(busID)) - if b == nil { - return fmt.Errorf("unknown bus") - } - if len(req.Args) < 1 { - return fmt.Errorf("missing device type") - } - name := strings.ToLower(req.Args[0]) - - reg := api.GetRegistration(name) - if reg == nil { - return fmt.Errorf("unknown device type: %s", name) - } - - dev := reg.CreateDevice() - devCtx, err := b.Add(dev) - if err != nil { - return err - } - - exportMeta := device.GetDeviceMeta(devCtx) - if exportMeta == nil { - return fmt.Errorf("failed to get device metadata from context") - } - - connTimer := device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Reset(apiSrv.Config().DeviceHandlerConnectTimeout) - } - go func() { - select { - case <-devCtx.Done(): - connTimer.Stop() - return - case <-connTimer.C: - deviceIDStr := fmt.Sprintf("%d", exportMeta.DevId) - if err := b.RemoveDeviceByID(deviceIDStr); err != nil { - logger.Error("timeout: failed to remove device", "busID", busID, "deviceID", deviceIDStr, "error", err) - } else { - logger.Info("timeout: removed device (no connection)", "busID", busID, "deviceID", deviceIDStr) - } - } - }() - - payload, err := json.Marshal(apitypes.DeviceAddResponse{ - ID: fmt.Sprintf("%d-%d", busID, exportMeta.DevId), - }) - if err != nil { - return err - } - - res.JSON = string(payload) - return nil - } -} diff --git a/viiper/internal/server/api/handler/bus_device_add_test.go b/viiper/internal/server/api/handler/bus_device_add_test.go deleted file mode 100644 index 387948dd..00000000 --- a/viiper/internal/server/api/handler/bus_device_add_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package handler_test - -import ( - "log/slog" - "net" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "viiper/internal/log" - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" -) - -// testRegistration implements api.DeviceRegistration for tests -type testRegistration struct { - creator func() pusb.Device - handler api.StreamHandlerFunc -} - -func (t *testRegistration) CreateDevice() pusb.Device { return t.creator() } -func (t *testRegistration) StreamHandler() api.StreamHandlerFunc { return t.handler } - -func TestBusDeviceAdd(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, s *usb.Server) - pathParams map[string]string - payload any - expectedResponse string - }{ - { - name: "add device to existing bus", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(80001) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - }, - pathParams: map[string]string{"id": "80001"}, - payload: "xbox360", - expectedResponse: `{"id":"80001-1"}`, - }, - { - name: "add device to non-existing bus", - setup: nil, - pathParams: map[string]string{"id": "99999"}, - payload: "xbox360", - expectedResponse: `{"error":"unknown bus"}`, - }, - { - name: "invalid bus number", - setup: nil, - pathParams: map[string]string{"id": "baz"}, - payload: "xbox360", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"baz\": invalid syntax"}`, - }, - { - name: "correct device id after add/remove", - setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(80005) - if err != nil { - t.Fatalf("create bus failed: %v", err) - } - if err := s.AddBus(b); err != nil { - t.Fatalf("add bus failed: %v", err) - } - if _, err := b.Add(xbox360.New()); err != nil { - t.Fatalf("add device failed: %v", err) - } - if err := b.RemoveDeviceByID("1"); err != nil { - t.Fatalf("remove device failed: %v", err) - } - }, - pathParams: map[string]string{"id": "80005"}, - payload: "xbox360", - expectedResponse: `{"id":"80005-1"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, srv, done := handlerTest.StartAPIServer(t, func(r *api.Router, s *usb.Server, apiSrv *api.Server) { - r.Register("bus/create", handler.BusCreate(s)) - r.Register("bus/{id}/add", handler.BusDeviceAdd(s, apiSrv)) - }) - defer done() - - c := apiclient.NewTransport(addr) - if tt.setup != nil { - tt.setup(t, srv) - } - line, err := c.Do("bus/{id}/add", tt.payload, tt.pathParams) - assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) - }) - } -} - -// Verify that a device added via API is auto-removed if no stream connects within the configured timeout. -func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { - // We need to control API DeviceHandlerConnectTimeout, so set up API server manually (not via StartAPIServer). - usbSrv := usb.New(usb.ServerConfig{Addr: "127.0.0.1:0"}, slog.Default(), log.NewRaw(nil)) - - // Create a bus directly on the USB server. - b, err := virtualbus.NewWithBusId(80100) - require.NoError(t, err) - require.NoError(t, usbSrv.AddBus(b)) - - // Choose a free TCP address for API server - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - addr := ln.Addr().String() - _ = ln.Close() - - // Start API server with a very short timeout - apiCfg := api.ServerConfig{Addr: addr, DeviceHandlerConnectTimeout: 200 * time.Millisecond} - apiSrv := api.New(usbSrv, addr, apiCfg, slog.Default()) - r := apiSrv.Router() - r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) - r.Register("bus/{id}/list", handler.BusDevicesList(usbSrv)) - require.NoError(t, apiSrv.Start()) - defer apiSrv.Close() - - // Register a minimal device registration for xbox360 that creates a real device - api.RegisterDevice("xbox360", &testRegistration{ - creator: func() pusb.Device { return xbox360.New() }, - handler: func(conn net.Conn, dev *pusb.Device, logger *slog.Logger) error { return nil }, - }) - - // Use API client to add device, then wait beyond timeout and verify removal - c := apiclient.New(addr) - _, err = c.DeviceAdd(80100, "xbox360") - require.NoError(t, err) - - // Immediately after add, the device should be present (server now registers bus/{id}/list) - list, err := c.DevicesList(80100) - require.NoError(t, err) - require.Len(t, list.Devices, 1) - - // Wait slightly beyond timeout to allow auto-removal - time.Sleep(350 * time.Millisecond) - - list2, err := c.DevicesList(80100) - require.NoError(t, err) - assert.Len(t, list2.Devices, 0) -} diff --git a/viiper/internal/server/api/handler/bus_device_remove.go b/viiper/internal/server/api/handler/bus_device_remove.go deleted file mode 100644 index f5f1b928..00000000 --- a/viiper/internal/server/api/handler/bus_device_remove.go +++ /dev/null @@ -1,38 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "log/slog" - "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" -) - -// BusDeviceRemove returns a handler that removes a device by device number. -func BusDeviceRemove(s *usb.Server) api.HandlerFunc { - return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - idStr, ok := req.Params["id"] - if !ok { - return fmt.Errorf("missing id") - } - busID, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return err - } - if len(req.Args) < 1 { - return fmt.Errorf("missing device number") - } - deviceID := req.Args[0] - if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { - return err - } - j, err := json.Marshal(apitypes.DeviceRemoveResponse{BusID: uint32(busID), DevId: deviceID}) - if err != nil { - return err - } - res.JSON = string(j) - return nil - } -} diff --git a/viiper/internal/server/api/handler/bus_devices_list.go b/viiper/internal/server/api/handler/bus_devices_list.go deleted file mode 100644 index 6cce5d1f..00000000 --- a/viiper/internal/server/api/handler/bus_devices_list.go +++ /dev/null @@ -1,71 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "log/slog" - "path/filepath" - "reflect" - "strconv" - "strings" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" -) - -// BusDevicesList returns a handler that lists devices on a bus. -func BusDevicesList(s *usb.Server) api.HandlerFunc { - return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - idStr, ok := req.Params["id"] - if !ok { - return fmt.Errorf("missing id") - } - busID, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return err - } - b := s.GetBus(uint32(busID)) - if b == nil { - return fmt.Errorf("unknown bus") - } - metas := b.GetAllDeviceMetas() - out := make([]apitypes.Device, 0, len(metas)) - for _, m := range metas { - dtype := inferDeviceType(m.Dev) - out = append(out, apitypes.Device{ - BusID: m.Meta.BusId, - DevId: fmt.Sprintf("%d", m.Meta.DevId), - Vid: fmt.Sprintf("0x%04x", m.Desc.Device.IDVendor), - Pid: fmt.Sprintf("0x%04x", m.Desc.Device.IDProduct), - Type: dtype, - }) - } - payload, err := json.Marshal(apitypes.DevicesListResponse{Devices: out}) - if err != nil { - return err - } - res.JSON = string(payload) - return nil - } -} - -// inferDeviceType attempts to derive a friendly device type name from the concrete type. -// For devices under pkg/devices/, we return the last path element (e.g., "xbox360"). -// Fallback to the lowercased concrete type name if the package path is unavailable. -func inferDeviceType(dev any) string { - if dev == nil { - return "" - } - t := reflect.TypeOf(dev) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - pkg := t.PkgPath() // e.g., "viiper/pkg/device/xbox360" - if pkg != "" { - base := filepath.Base(pkg) - if base != "." && base != string(filepath.Separator) { - return strings.ToLower(base) - } - } - return strings.ToLower(t.Name()) -} diff --git a/viiper/internal/server/api/handler/bus_remove.go b/viiper/internal/server/api/handler/bus_remove.go deleted file mode 100644 index a92b7313..00000000 --- a/viiper/internal/server/api/handler/bus_remove.go +++ /dev/null @@ -1,33 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "log/slog" - "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" -) - -// BusRemove returns a handler that removes a bus. -func BusRemove(s *usb.Server) api.HandlerFunc { - return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - if len(req.Args) < 1 { - return fmt.Errorf("missing busId") - } - busID, err := strconv.ParseUint(req.Args[0], 10, 32) - if err != nil { - return err - } - if err := s.RemoveBus(uint32(busID)); err != nil { - return err - } - out, err := json.Marshal(apitypes.BusRemoveResponse{BusID: uint32(busID)}) - if err != nil { - return err - } - res.JSON = string(out) - return nil - } -} diff --git a/viiper/internal/server/api/server.go b/viiper/internal/server/api/server.go deleted file mode 100644 index f3b9ca42..00000000 --- a/viiper/internal/server/api/server.go +++ /dev/null @@ -1,215 +0,0 @@ -package api - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net" - "strconv" - "strings" - - "viiper/internal/server/usb" - "viiper/pkg/device" - pusb "viiper/pkg/usb" -) - -// Server implements a small TCP API for managing virtual bus topology. -type Server struct { - usbs *usb.Server - addr string - ln net.Listener - logger *slog.Logger - router *Router - config ServerConfig -} - -// New creates a new ApiServer bound to a server.Server instance. -func New(s *usb.Server, addr string, config ServerConfig, logger *slog.Logger) *Server { - a := &Server{ - usbs: s, - addr: addr, - logger: logger, - config: config, - } - a.router = NewRouter() - return a -} - -// Router returns the router used by the API server so callers can register handlers. -func (a *Server) Router() *Router { return a.router } - -// USB returns the underlying USB server. -func (a *Server) USB() *usb.Server { return a.usbs } - -// Config returns the server configuration. -func (a *Server) Config() ServerConfig { return a.config } - -// Start listens on the configured address and serves incoming API commands. -func (a *Server) Start() error { - ln, err := net.Listen("tcp", a.addr) - if err != nil { - return err - } - a.ln = ln - a.logger.Info("API listening", "addr", a.addr) - go a.serve() - return nil -} - -// Close stops the API server. -func (a *Server) Close() { - if a.ln != nil { - _ = a.ln.Close() - } -} - -func (a *Server) serve() { - for { - c, err := a.ln.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { - a.logger.Info("API server stopped") - return - } - a.logger.Info("API accept error", "error", err) - return - } - go a.handleConn(c) - } -} - -func (a *Server) writeError(w io.Writer, msg string) { - problem := map[string]string{"error": msg} - problemJSON, _ := json.Marshal(problem) - fmt.Fprintf(w, "%s\n", string(problemJSON)) -} - -func (a *Server) writeOK(w io.Writer, rest string) { - if rest == "" { - fmt.Fprintln(w) - } else { - fmt.Fprintf(w, "%s\n", rest) - } -} - -func (a *Server) handleConn(conn net.Conn) { - defer conn.Close() - connLogger := a.logger.With("remote", conn.RemoteAddr().String()) - r := bufio.NewReader(conn) - w := conn - for { - line, err := r.ReadString('\n') - if err != nil { - if err != io.EOF { - connLogger.Error("read api line", "error", err) - } - return - } - line = strings.TrimSpace(line) - if line == "" { - continue - } - connLogger.Info("api cmd", "cmd", line) - fields := strings.Fields(line) - if len(fields) == 0 { - connLogger.Error("api empty command") - a.writeError(w, "empty") - continue - } - path := strings.ToLower(fields[0]) - args := fields[1:] - - if h, params := a.router.Match(path); h != nil { - req := &Request{Params: params, Args: args} - res := &Response{} - if err := h(req, res, connLogger); err != nil { - connLogger.Error("api handler error", "path", path, "error", err) - a.writeError(w, err.Error()) - continue - } - connLogger.Debug("api handler success", "path", path) - a.writeOK(w, res.JSON) - continue - } else if sh, params := a.router.MatchStream(path); sh != nil { - connLogger.Info("api stream begin", "path", path) - busIdStr, ok := params["busId"] - if !ok { - a.writeError(w, "missing busId parameter") - return - } - devIdStr, ok := params["deviceid"] - if !ok { - a.writeError(w, "missing deviceid parameter") - return - } - - busID, err := strconv.ParseUint(busIdStr, 10, 32) - if err != nil { - a.writeError(w, fmt.Sprintf("invalid busId: %v", err)) - return - } - bus := a.usbs.GetBus(uint32(busID)) - if bus == nil { - a.writeError(w, "bus not found") - return - } - var dev pusb.Device - var devCtx context.Context - metas := bus.GetAllDeviceMetas() - for _, meta := range metas { - if fmt.Sprintf("%d", meta.Meta.DevId) == devIdStr { - dev = meta.Dev - devCtx = bus.GetDeviceContext(dev) - break - } - } - if dev == nil || devCtx == nil { - a.writeError(w, "device not found") - return - } - - connTimer := device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Stop() - } - - // Stream handler takes ownership of connection - if err := sh(conn, &dev, connLogger); err != nil { - connLogger.Error("api stream handler error", "path", path, "error", err) - } - connLogger.Info("api stream end", "path", path) - - connTimer = device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Reset(a.config.DeviceHandlerConnectTimeout) - go func() { - select { - case <-devCtx.Done(): - connTimer.Stop() - return - case <-connTimer.C: - exportMeta := device.GetDeviceMeta(devCtx) - if exportMeta != nil { - deviceIDStr := fmt.Sprintf("%d", exportMeta.DevId) - if err := bus.RemoveDeviceByID(deviceIDStr); err != nil { - connLogger.Error("disconnect timeout: failed to remove device", "busID", busID, "deviceID", deviceIDStr, "error", err) - } else { - connLogger.Info("disconnect timeout: removed device (no reconnection)", "busID", busID, "deviceID", deviceIDStr) - } - } - } - }() - } - - return - } else { - connLogger.Error("api unknown path", "path", path) - a.writeError(w, "unknown path") - } - - } -} diff --git a/viiper/internal/server/api/server_test.go b/viiper/internal/server/api/server_test.go deleted file mode 100644 index 838f99c7..00000000 --- a/viiper/internal/server/api/server_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package api_test - -import ( - "fmt" - "log/slog" - "net" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "viiper/internal/log" - "viiper/internal/server/api" - srvusb "viiper/internal/server/usb" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" -) - -type testRegistration2 struct { - creator func() pusb.Device - handler api.StreamHandlerFunc -} - -func (t *testRegistration2) CreateDevice() pusb.Device { return t.creator() } -func (t *testRegistration2) StreamHandler() api.StreamHandlerFunc { return t.handler } - -func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { - cfg := srvusb.ServerConfig{Addr: "127.0.0.1:0"} - usbSrv := srvusb.New(cfg, slog.Default(), log.NewRaw(nil)) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - addr := ln.Addr().String() - _ = ln.Close() - - apiSrv := api.New(usbSrv, addr, api.ServerConfig{Addr: addr}, slog.Default()) - r := apiSrv.Router() - r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) - require.NoError(t, apiSrv.Start()) - defer apiSrv.Close() - - bus, err := virtualbus.NewWithBusId(70002) - require.NoError(t, err) - require.NoError(t, usbSrv.AddBus(bus)) - dev := xbox360.New() - _, err = bus.Add(dev) - require.NoError(t, err) - - var devID string - metas := bus.GetAllDeviceMetas() - require.Greater(t, len(metas), 0) - for _, m := range metas { - devID = fmt.Sprintf("%d", m.Meta.DevId) - } - require.NotEmpty(t, devID) - - sentinel := fmt.Errorf("boom") - api.RegisterDevice("xbox360", &testRegistration2{ - creator: func() pusb.Device { return xbox360.New() }, - handler: func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, - }) - - c, err := net.Dial("tcp", addr) - require.NoError(t, err) - _, err = fmt.Fprintf(c, "bus/%d/%s\n", bus.BusID(), devID) - require.NoError(t, err) - - buf := make([]byte, 1) - _ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - _, readErr := c.Read(buf) - require.Error(t, readErr) - _ = c.Close() -} diff --git a/viiper/internal/server/usb/config.go b/viiper/internal/server/usb/config.go deleted file mode 100644 index 5f7d4e1d..00000000 --- a/viiper/internal/server/usb/config.go +++ /dev/null @@ -1,9 +0,0 @@ -package usb - -import "time" - -// ServerConfig represents the server subcommand configuration. -type ServerConfig struct { - Addr string `help:"USB-IP server listen address" default:":3241" env:"VIIPER_USB_ADDR"` - ConnectionTimeout time.Duration `kong:"-"` -} diff --git a/viiper/internal/server/usb/server.go b/viiper/internal/server/usb/server.go deleted file mode 100644 index 2d242dbe..00000000 --- a/viiper/internal/server/usb/server.go +++ /dev/null @@ -1,617 +0,0 @@ -package usb - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "io" - "log/slog" - "net" - "strings" - "sync" - "syscall" - "time" - "viiper/internal/log" - "viiper/pkg/usb" - "viiper/pkg/usbip" - "viiper/pkg/virtualbus" -) - -const ( - // USB standard request codes - usbReqGetStatus = 0x00 - usbReqClearFeature = 0x01 - usbReqSetFeature = 0x03 - usbReqSetAddress = 0x05 - usbReqGetDescriptor = 0x06 - usbReqSetDescriptor = 0x07 - usbReqGetConfiguration = 0x08 - usbReqSetConfiguration = 0x09 - - // USB descriptor types - usbDescTypeDevice = 0x01 - usbDescTypeConfiguration = 0x02 - usbDescTypeString = 0x03 - usbDescTypeHID = 0x21 - usbDescTypeHIDReport = 0x22 - - // USB request types (bmRequestType) - usbReqTypeStandardToDevice = 0x00 - usbReqTypeStandardToInterface = 0x81 - usbReqTypeStandardFromDevice = 0x80 - - // USB configuration values - usbConfigValueDefault = 1 - usbConfigAttrBusPowered = 0x80 - usbConfigMaxPower100mA = 50 // In units of 2mA - - // URB header field offsets - urbHdrSize = 0x30 - urbHdrOffsetCommand = 0x00 - urbHdrOffsetSeqnum = 0x04 - urbHdrOffsetDevid = 0x08 - urbHdrOffsetDir = 0x0c - urbHdrOffsetEp = 0x10 - urbHdrOffsetUnlink = 0x14 - urbHdrOffsetFlags = 0x14 - urbHdrOffsetLength = 0x18 - urbHdrOffsetSetup = 0x28 - - // Standard header peek size - headerPeekSize = 8 - - // BUSID buffer size for import - busIDSize = 32 - - // Error codes - errConnReset = -104 // -ECONNRESET -) - -type Server struct { - config *ServerConfig - logger *slog.Logger - rawLogger log.RawLogger - busses map[uint32]*virtualbus.VirtualBus - busesMu sync.Mutex - ready chan struct{} - readyOnce sync.Once - ln net.Listener -} - -func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Server { - return &Server{ - config: &config, - logger: logger, - rawLogger: rawLogger, - busses: make(map[uint32]*virtualbus.VirtualBus), - ready: make(chan struct{}), - } -} - -// AddBus registers a bus with the server. If the bus number is already present, -// an error is returned. -func (s *Server) AddBus(bus *virtualbus.VirtualBus) error { - s.busesMu.Lock() - defer s.busesMu.Unlock() - if bus == nil { - return fmt.Errorf("bus is nil") - } - if _, ok := s.busses[bus.BusID()]; ok { - return fmt.Errorf("bus %d already registered", bus.BusID()) - } - s.busses[bus.BusID()] = bus - return nil -} - -// RemoveBus unregisters a bus from the server. -func (s *Server) RemoveBus(busID uint32) error { - s.busesMu.Lock() - bus, ok := s.busses[busID] - if !ok { - s.busesMu.Unlock() - return fmt.Errorf("bus %d not found", busID) - } - - devices := bus.Devices() - s.busesMu.Unlock() - - if len(devices) > 0 { - s.logger.Warn(fmt.Sprintf("Removing non-empty bus %d with %d device(s) attached; removing devices", busID, len(devices))) - for _, dev := range devices { - _ = bus.Remove(dev) - } - } - - s.busesMu.Lock() - delete(s.busses, busID) - s.busesMu.Unlock() - - return bus.Close() -} - -// RemoveDeviceByID removes a device by busId and cancels its connections. -func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { - s.busesMu.Lock() - bus, ok := s.busses[busID] - s.busesMu.Unlock() - - if !ok { - return fmt.Errorf("bus %d not found", busID) - } - - return bus.RemoveDeviceByID(deviceID) -} - -// ListBuses returns a snapshot of active bus numbers. -func (s *Server) ListBuses() []uint32 { - s.busesMu.Lock() - defer s.busesMu.Unlock() - out := make([]uint32, 0, len(s.busses)) - for k := range s.busses { - out = append(out, k) - } - return out -} - -// GetBus returns a bus by ID or nil if not present. -func (s *Server) GetBus(busID uint32) *virtualbus.VirtualBus { - s.busesMu.Lock() - defer s.busesMu.Unlock() - return s.busses[busID] -} - -// ListenAndServe starts the USB-IP server and handles incoming connections. -func (s *Server) ListenAndServe() error { - ln, err := net.Listen("tcp", s.config.Addr) - if err != nil { - return err - } - s.ln = ln - s.readyOnce.Do(func() { close(s.ready) }) - s.logger.Info("USBIP server listening", "addr", s.config.Addr) - for { - c, err := ln.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "use of closed network connection") { - s.logger.Info("USBIP server stopped") - return nil - } - s.logger.Error("Accept error", "error", err) - continue - } - s.logger.Info("Client connected", "remote", c.RemoteAddr()) - go func() { - if err := s.handleConn(c); err != nil { - if isClientDisconnect(err) { - s.logger.Info("Client disconnected", "error", err) - } else { - s.logger.Error("Connection handler error", "error", err) - } - } - }() - } -} - -// Ready returns a channel that is closed once the server has successfully bound -// to its listen address and is ready to accept connections. -func (s *Server) Ready() <-chan struct{} { return s.ready } - -// Close stops the USB server by closing its listener. -func (s *Server) Close() error { - if s.ln != nil { - return s.ln.Close() - } - return nil -} - -// -- - -func (s *Server) handleConn(conn net.Conn) error { - defer conn.Close() - conn = &logConn{Conn: conn, s: s} - if err := conn.SetDeadline(time.Now().Add(s.config.ConnectionTimeout)); err != nil { - s.logger.Warn("Failed to set deadline", "error", err) - } - - // Peek first 8 bytes to determine management op or URB stream. - var hdrBuf [headerPeekSize]byte - if err := usbip.ReadExactly(conn, hdrBuf[:]); err != nil { - return fmt.Errorf("read header: %w", err) - } - - ver := binary.BigEndian.Uint16(hdrBuf[0:2]) - code := binary.BigEndian.Uint16(hdrBuf[2:4]) - - if ver == usbip.Version && (code == usbip.OpReqDevlist || code == usbip.OpReqImport) { - switch code { - case usbip.OpReqDevlist: - s.logger.Info("OP_REQ_DEVLIST") - return s.handleDevList(conn) - case usbip.OpReqImport: - s.logger.Info("OP_REQ_IMPORT") - dev, err := s.handleImport(conn, hdrBuf[:]) - if err != nil { - return fmt.Errorf("handle import: %w", err) - } - return s.handleUrbStream(conn, dev) - } - } - - return fmt.Errorf("protocol violation: client sent URB data without OP_REQ_IMPORT") -} - -func (s *Server) handleDevList(conn net.Conn) error { - _ = conn.SetDeadline(time.Time{}) - var buf bytes.Buffer - rep := usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpRepDevlist, Status: 0} - _ = rep.Write(&buf) - metas := s.getAllDeviceMetas() - n := uint32(len(metas)) - dlh := usbip.DevListReplyHeader{NDevices: n} - _ = dlh.Write(&buf) - for _, m := range metas { - desc := m.Desc - meta := m.Meta - - exp := usbip.ExportedDevice{ - ExportMeta: meta, - Speed: desc.Device.Speed, - IDVendor: desc.Device.IDVendor, - IDProduct: desc.Device.IDProduct, - BcdDevice: desc.Device.BcdDevice, - BDeviceClass: desc.Device.BDeviceClass, - BDeviceSubClass: desc.Device.BDeviceSubClass, - BDeviceProtocol: desc.Device.BDeviceProtocol, - BConfigurationValue: usbConfigValueDefault, - BNumConfigurations: desc.Device.BNumConfigurations, - BNumInterfaces: uint8(len(desc.Interfaces)), - } - - for _, iface := range desc.Interfaces { - exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ - Class: iface.Descriptor.BInterfaceClass, - SubClass: iface.Descriptor.BInterfaceSubClass, - Protocol: iface.Descriptor.BInterfaceProtocol, - }) - } - _ = exp.WriteDevlist(&buf) - } - if _, err := conn.Write(buf.Bytes()); err != nil { - return fmt.Errorf("write devlist: %w", err) - } - return nil -} - -func (s *Server) handleImport(conn net.Conn, first8 []byte) (usb.Device, error) { - var rest [busIDSize]byte - if err := usbip.ReadExactly(conn, rest[:]); err != nil { - return nil, fmt.Errorf("read import busid: %w", err) - } - reqBus := string(rest[:bytes.IndexByte(rest[:], 0)]) - s.logger.Info("Import request", "busid", reqBus) - var chosen usb.Device - var chosenMeta *usbip.ExportMeta - var chosenDesc *virtualbus.DeviceDescriptor - for _, m := range s.getAllDeviceMetas() { - meta := m.Meta - end := bytes.IndexByte(meta.USBBusId[:], 0) - bid := string(meta.USBBusId[:end]) - if bid == reqBus { - chosen = m.Dev - chosenMeta = &meta - chosenDesc = &m.Desc - break - } - } - if chosen == nil || chosenMeta == nil || chosenDesc == nil { - return nil, fmt.Errorf("no device matches busid %s", reqBus) - } - var buf bytes.Buffer - rep := usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpRepImport, Status: 0} - _ = rep.Write(&buf) - exp := usbip.ExportedDevice{ - ExportMeta: *chosenMeta, - Speed: chosenDesc.Device.Speed, - IDVendor: chosenDesc.Device.IDVendor, - IDProduct: chosenDesc.Device.IDProduct, - BcdDevice: chosenDesc.Device.BcdDevice, - BDeviceClass: chosenDesc.Device.BDeviceClass, - BDeviceSubClass: chosenDesc.Device.BDeviceSubClass, - BDeviceProtocol: chosenDesc.Device.BDeviceProtocol, - BConfigurationValue: usbConfigValueDefault, - BNumConfigurations: chosenDesc.Device.BNumConfigurations, - BNumInterfaces: uint8(len(chosenDesc.Interfaces)), - } - for _, iface := range chosenDesc.Interfaces { - exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ - Class: iface.Descriptor.BInterfaceClass, - SubClass: iface.Descriptor.BInterfaceSubClass, - Protocol: iface.Descriptor.BInterfaceProtocol, - }) - } - _ = exp.WriteImport(&buf) - if _, err := conn.Write(buf.Bytes()); err != nil { - return nil, fmt.Errorf("write import reply failed: %w", err) - } - return chosen, nil -} - -// getAllDeviceMetas aggregates device metas from all registered busses. -func (s *Server) getAllDeviceMetas() []virtualbus.DeviceMeta { - s.busesMu.Lock() - defer s.busesMu.Unlock() - out := []virtualbus.DeviceMeta{} - for _, b := range s.busses { - out = append(out, b.GetAllDeviceMetas()...) - } - return out -} - -// getDeviceDescriptor locates the descriptor for a device across all buses. -func (s *Server) getDeviceDescriptor(dev usb.Device) *virtualbus.DeviceDescriptor { - s.busesMu.Lock() - defer s.busesMu.Unlock() - for _, b := range s.busses { - if desc := b.GetDeviceDescriptor(dev); desc != nil { - return desc - } - } - return nil -} - -type readBufferConn struct { - net.Conn - buf []byte -} - -func (r *readBufferConn) Read(p []byte) (int, error) { - if len(r.buf) > 0 { - n := copy(p, r.buf) - r.buf = r.buf[n:] - return n, nil - } - return r.Conn.Read(p) -} - -type logConn struct { - net.Conn - s *Server -} - -func (lc *logConn) Read(p []byte) (int, error) { - n, err := lc.Conn.Read(p) - if n > 0 { - lc.s.rawLogger.Log(true, p[:n]) - } - return n, err -} - -func (lc *logConn) Write(p []byte) (int, error) { - n, err := lc.Conn.Write(p) - if n > 0 { - lc.s.rawLogger.Log(false, p[:n]) - } - return n, err -} - -func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { - _ = conn.SetDeadline(time.Time{}) - - var ownerBus *virtualbus.VirtualBus - for _, bnum := range s.ListBuses() { - bus := s.GetBus(bnum) - if bus != nil && bus.GetDeviceDescriptor(dev) != nil { - ownerBus = bus - break - } - } - if ownerBus == nil { - return fmt.Errorf("device not found on any bus") - } - - ctx := ownerBus.GetDeviceContext(dev) - if ctx == nil { - return fmt.Errorf("no device context available from bus") - } - - for { - select { - case <-ctx.Done(): - s.logger.Info("device removed, closing URB stream") - return nil - default: - } - - var hdr [urbHdrSize]byte - if err := usbip.ReadExactly(conn, hdr[:]); err != nil { - return fmt.Errorf("read URB header: %w", err) - } - cmd := binary.BigEndian.Uint32(hdr[urbHdrOffsetCommand : urbHdrOffsetCommand+4]) - seq := binary.BigEndian.Uint32(hdr[urbHdrOffsetSeqnum : urbHdrOffsetSeqnum+4]) - devid := binary.BigEndian.Uint32(hdr[urbHdrOffsetDevid : urbHdrOffsetDevid+4]) - dir := binary.BigEndian.Uint32(hdr[urbHdrOffsetDir : urbHdrOffsetDir+4]) - ep := binary.BigEndian.Uint32(hdr[urbHdrOffsetEp : urbHdrOffsetEp+4]) - if cmd == usbip.CmdUnlinkCode { - unlinkSeq := binary.BigEndian.Uint32(hdr[urbHdrOffsetUnlink : urbHdrOffsetUnlink+4]) - s.logger.Debug("USBIP_CMD_UNLINK", "seq", seq, "unlink", unlinkSeq) - // Reply with -ECONNRESET - ret := usbip.RetUnlink{Basic: usbip.HeaderBasic{Command: usbip.RetUnlinkCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, Status: errConnReset} - _ = ret.Write(conn) - continue - } - if cmd != usbip.CmdSubmitCode { - return fmt.Errorf("unsupported cmd %d (seq=%d, devid=%d)", cmd, seq, devid) - } - xferFlags := binary.BigEndian.Uint32(hdr[urbHdrOffsetFlags : urbHdrOffsetFlags+4]) - xferLen := binary.BigEndian.Uint32(hdr[urbHdrOffsetLength : urbHdrOffsetLength+4]) - setup := hdr[urbHdrOffsetSetup:urbHdrSize] - - var outPayload []byte - if dir == usbip.DirOut && xferLen > 0 { - outPayload = make([]byte, xferLen) - if err := usbip.ReadExactly(conn, outPayload); err != nil { - return fmt.Errorf("read OUT payload: %w", err) - } - } - - respData := s.processSubmit(dev, ep, dir, setup, outPayload) - - ret := usbip.RetSubmit{ - Basic: usbip.HeaderBasic{Command: usbip.RetSubmitCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, - Status: 0, - ActualLength: uint32(len(respData)), - StartFrame: 0, - NumberOfPackets: 0, - ErrorCount: 0, - } - var out bytes.Buffer - if err := ret.Write(&out); err != nil { - return fmt.Errorf("build RET_SUBMIT header: %w", err) - } - if len(respData) > 0 { - out.Write(respData) - } - if _, err := conn.Write(out.Bytes()); err != nil { - return fmt.Errorf("write RET_SUBMIT: %w", err) - } - _ = xferFlags - _ = devid - } -} - -// isClientDisconnect tests whether an error represents a normal client -// disconnect (EOF, ECONNRESET, broken pipe, or the Windows WSAECONNRESET -// translated error). We treat those as normal client disconnects and log -// them at Info level instead of Error. -func isClientDisconnect(err error) bool { - if err == nil { - return false - } - if errors.Is(err, io.EOF) { - return true - } - var opErr *net.OpError - if errors.As(err, &opErr) { - // On many platforms the underlying error will be a syscall.Errno - switch t := opErr.Err.(type) { - case syscall.Errno: - if t == syscall.ECONNRESET || t == syscall.EPIPE { - return true - } - } - } - // Fallback to checking the message for platform-specific strings. - e := strings.ToLower(err.Error()) - if strings.Contains(e, "connection reset by peer") || strings.Contains(e, "forcibly closed") || strings.Contains(e, "an existing connection was forcibly closed") || strings.Contains(e, "aborted") { - return true - } - return false -} - -// processSubmit handles control transfers for enumeration on EP0. -func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []byte, out []byte) []byte { - if ep != 0 { - return dev.HandleTransfer(ep, dir, out) - } - if len(setup) != 8 { - return nil - } - bm := setup[0] - breq := setup[1] - wValue := binary.LittleEndian.Uint16(setup[2:4]) - wIndex := binary.LittleEndian.Uint16(setup[4:6]) - wLength := binary.LittleEndian.Uint16(setup[6:8]) - - if breq == usbReqSetAddress && bm == usbReqTypeStandardToDevice { - return nil - } - if breq == usbReqSetConfiguration && bm == usbReqTypeStandardToDevice { - return nil - } - if breq == usbReqGetConfiguration && bm == usbReqTypeStandardFromDevice { - return []byte{0x01} - } - - desc := s.getDeviceDescriptor(dev) - if desc == nil { - return nil - } - - if breq == usbReqGetDescriptor && bm == usbReqTypeStandardFromDevice { - dtype := uint8(wValue >> 8) - dindex := uint8(wValue & 0xff) - var data []byte - switch dtype { - case usbDescTypeDevice: - data = desc.Device.Bytes() - case usbDescTypeConfiguration: - data = buildConfigDescriptor(desc) - case usbDescTypeString: - if s, ok := desc.Strings[dindex]; ok { - data = virtualbus.EncodeStringDescriptor(s) - } - } - if len(data) == 0 { - return nil - } - if int(wLength) < len(data) { - return data[:wLength] - } - return data - } - if breq == usbReqGetDescriptor && bm == usbReqTypeStandardToInterface { - dtype := uint8(wValue >> 8) - iface := uint8(wIndex & 0xff) - var data []byte - if int(iface) < len(desc.Interfaces) { - ifaceConf := desc.Interfaces[iface] - switch dtype { - case usbDescTypeHID: - data = ifaceConf.HIDDescriptor - case usbDescTypeHIDReport: - data = ifaceConf.HIDReport - } - } - if len(data) == 0 { - return nil - } - if int(wLength) < len(data) { - return data[:wLength] - } - return data - } - _ = wIndex - _ = out - return nil -} - -// buildConfigDescriptor builds a configuration descriptor for the device. -func buildConfigDescriptor(desc *virtualbus.DeviceDescriptor) []byte { - var b bytes.Buffer - h := usb.ConfigHeader{ - WTotalLength: 0, // to be patched - BNumInterfaces: uint8(len(desc.Interfaces)), - BConfigurationValue: usbConfigValueDefault, - IConfiguration: 0, - BMAttributes: usbConfigAttrBusPowered, - BMaxPower: usbConfigMaxPower100mA, - } - h.Write(&b) - for _, iface := range desc.Interfaces { - iface.Descriptor.Write(&b) - if len(iface.HIDDescriptor) > 0 { - b.Write(iface.HIDDescriptor) - } - for _, ep := range iface.Endpoints { - ep.Write(&b) - } - if len(iface.VendorData) > 0 { - b.Write(iface.VendorData) - } - } - - data := b.Bytes() - binary.LittleEndian.PutUint16(data[2:4], uint16(len(data))) - return data -} diff --git a/viiper/pkg/apiclient/client.go b/viiper/pkg/apiclient/client.go deleted file mode 100644 index 15e72ccd..00000000 --- a/viiper/pkg/apiclient/client.go +++ /dev/null @@ -1,143 +0,0 @@ -package apiclient - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - - apitypes "viiper/pkg/apitypes" -) - -// Client provides a high-level interface to the VIIPER API, handling request -// formatting, response parsing, and error handling. -type Client struct{ transport *Transport } - -// New constructs a high-level API client using the internal low-level Transport. -// The addr parameter specifies the TCP address (host:port) of the VIIPER API server. -func New(addr string) *Client { return &Client{transport: NewTransport(addr)} } - -// NewWithConfig constructs a client with custom transport timeouts. -func NewWithConfig(addr string, cfg *Config) *Client { - return &Client{transport: NewTransportWithConfig(addr, cfg)} -} - -// WithTransport constructs a Client using a custom Transport implementation. -// This is primarily useful for testing or when advanced transport configuration is needed. -func WithTransport(t *Transport) *Client { return &Client{transport: t} } - -// BusCreate creates a new virtual USB bus with the specified bus number. -// Returns the created bus ID or an error if the bus number is already allocated. -func (c *Client) BusCreate(busID uint32) (*apitypes.BusCreateResponse, error) { - return c.BusCreateCtx(context.Background(), busID) -} - -func (c *Client) BusCreateCtx(ctx context.Context, busID uint32) (*apitypes.BusCreateResponse, error) { - const path = "bus/create" - line, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) - if err != nil { - return nil, err - } - return parse[apitypes.BusCreateResponse](line) -} - -// BusRemove removes an existing virtual USB bus and all devices attached to it. -// Returns the removed bus ID or an error if the bus does not exist. -func (c *Client) BusRemove(busID uint32) (*apitypes.BusRemoveResponse, error) { - return c.BusRemoveCtx(context.Background(), busID) -} - -func (c *Client) BusRemoveCtx(ctx context.Context, busID uint32) (*apitypes.BusRemoveResponse, error) { - const path = "bus/remove" - line, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) - if err != nil { - return nil, err - } - return parse[apitypes.BusRemoveResponse](line) -} - -// BusList retrieves a list of all active virtual USB bus numbers. -func (c *Client) BusList() (*apitypes.BusListResponse, error) { - return c.BusListCtx(context.Background()) -} - -func (c *Client) BusListCtx(ctx context.Context) (*apitypes.BusListResponse, error) { - const path = "bus/list" - line, err := c.transport.DoCtx(ctx, path, nil, nil) - if err != nil { - return nil, err - } - return parse[apitypes.BusListResponse](line) -} - -// DeviceAdd adds a new device of the specified type to the given bus. -// The devType parameter specifies the device type (e.g., "xbox360"). -// Returns the assigned bus ID (e.g., "1-1") or an error if the bus does not exist -// or the device type is unknown. -func (c *Client) DeviceAdd(busID uint32, devType string) (*apitypes.DeviceAddResponse, error) { - return c.DeviceAddCtx(context.Background(), busID, devType) -} - -func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string) (*apitypes.DeviceAddResponse, error) { - pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} - const path = "bus/{id}/add" - line, err := c.transport.DoCtx(ctx, path, devType, pathParams) - if err != nil { - return nil, err - } - return parse[apitypes.DeviceAddResponse](line) -} - -// DeviceRemove removes a device from the specified bus by its device ID. -// The busid parameter is the device number (e.g., "1") on the given bus. -// Active USB-IP connections to the device will be closed. -// Returns the removed device's bus and device ID or an error if not found. -func (c *Client) DeviceRemove(busID uint32, busid string) (*apitypes.DeviceRemoveResponse, error) { - return c.DeviceRemoveCtx(context.Background(), busID, busid) -} - -func (c *Client) DeviceRemoveCtx(ctx context.Context, busID uint32, busid string) (*apitypes.DeviceRemoveResponse, error) { - pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} - const path = "bus/{id}/remove" - line, err := c.transport.DoCtx(ctx, path, busid, pathParams) - if err != nil { - return nil, err - } - return parse[apitypes.DeviceRemoveResponse](line) -} - -// DevicesList retrieves a list of all devices attached to the specified bus. -// Each device entry includes bus ID, device ID, VID, PID, and device type. -func (c *Client) DevicesList(busID uint32) (*apitypes.DevicesListResponse, error) { - return c.DevicesListCtx(context.Background(), busID) -} - -func (c *Client) DevicesListCtx(ctx context.Context, busID uint32) (*apitypes.DevicesListResponse, error) { - pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} - // Endpoint path corrected to match server registration ("bus/{id}/list"). - // Previously used "bus/{id}/devices" which is not registered by the API server. - const path = "bus/{id}/list" - line, err := c.transport.DoCtx(ctx, path, nil, pathParams) - if err != nil { - return nil, err - } - return parse[apitypes.DevicesListResponse](line) -} - -func parse[T any](line string) (*T, error) { - if line == "" { - return nil, errors.New("empty response") - } - var ae apitypes.ApiError - if err := json.Unmarshal([]byte(line), &ae); err == nil && ae.Error != "" { - return nil, errors.New(ae.Error) - } - var out T - dec := json.NewDecoder(bytes.NewReader([]byte(line))) - dec.DisallowUnknownFields() - if err := dec.Decode(&out); err != nil { - return nil, fmt.Errorf("decode: %w", err) - } - return &out, nil -} diff --git a/viiper/pkg/apiclient/client_test.go b/viiper/pkg/apiclient/client_test.go deleted file mode 100644 index 21f1ed73..00000000 --- a/viiper/pkg/apiclient/client_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package apiclient - -import ( - "context" - "errors" - "testing" - - apitypes "viiper/pkg/apitypes" - - "github.com/stretchr/testify/assert" -) - -// mockTransport captures requests and returns predefined responses. -type mockState struct { - responses map[string]string - err error - lastPath string - lastPayload string -} - -func newMockTransport(ms *mockState) *Transport { - return NewMockTransport(func(path string, payload any, pathParams map[string]string) (string, error) { - if ms.err != nil { - return "", ms.err - } - var ps string - switch v := payload.(type) { - case string: - ps = v - case nil: - ps = "" - default: - ps = "" - } - ms.lastPath = path - ms.lastPayload = ps - if out, ok := ms.responses[path]; ok { - return out, nil - } - return "", nil - }) -} - -func TestHighLevelClient(t *testing.T) { - tests := []struct { - name string - setup func(ms *mockState) - call func(c *Client) (any, error) - wantErr string - assertFunc func(t *testing.T, got any) - }{ - { - name: "bus create success", - setup: func(ms *mockState) { ms.responses["bus/create"] = `{"busId":42}` }, - call: func(c *Client) (any, error) { return c.BusCreate(42) }, - assertFunc: func(t *testing.T, got any) { - _, ok := got.(*apitypes.BusCreateResponse) - assert.True(t, ok, "expected *apitypes.BusCreateResponse type") - }, - }, - { - name: "bus create error", - setup: func(ms *mockState) { ms.responses["bus/create"] = `{"error":"boom"}` }, - call: func(c *Client) (any, error) { return c.BusCreate(0) }, - wantErr: "boom", - }, - { - name: "devices list", - setup: func(ms *mockState) { - ms.responses["bus/{id}/list"] = `{"devices":[{"busId":1,"devId":"1","vid":"0x1234","pid":"0xabcd","type":"x"}]}` - }, - call: func(c *Client) (any, error) { return c.DevicesList(1) }, - assertFunc: func(t *testing.T, got any) { - assert.NotNil(t, got) - }, - }, - { - name: "transport failure", - setup: func(ms *mockState) { ms.err = errors.New("dial fail") }, - call: func(c *Client) (any, error) { return c.BusList() }, - wantErr: "dial fail", - }, - { - name: "blank response error", - setup: func(ms *mockState) { /* no response set so blank */ }, - call: func(c *Client) (any, error) { return c.BusList() }, - wantErr: "empty response", - }, - { - name: "devices list empty", - setup: func(ms *mockState) { - ms.responses["bus/{id}/list"] = `{"devices":[]}` - }, - call: func(c *Client) (any, error) { return c.DevicesList(1) }, - assertFunc: func(t *testing.T, got any) { - resp := got.(*apitypes.DevicesListResponse) - assert.Len(t, resp.Devices, 0) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ms := &mockState{responses: map[string]string{}} - if tt.setup != nil { - tt.setup(ms) - } - c := WithTransport(newMockTransport(ms)) - got, err := tt.call(c) - if tt.wantErr != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - assert.NoError(t, err) - if tt.assertFunc != nil { - tt.assertFunc(t, got) - } - }) - } -} - -func TestContextCancellation(t *testing.T) { - // Use a real transport but cancel the context before dialing. - c := WithTransport(NewTransport("127.0.0.1:9")) // address irrelevant due to early cancel - ctx, cancel := context.WithCancel(context.Background()) - cancel() - _, err := c.BusListCtx(ctx) - assert.Error(t, err) -} - -func TestStrictJSONDecode(t *testing.T) { - ms := &mockState{responses: map[string]string{}} - // extra field should cause decode error due to DisallowUnknownFields - ms.responses["bus/list"] = `{"buses":[1,2,3],"extra":true}` - c := WithTransport(newMockTransport(ms)) - _, err := c.BusList() - assert.Error(t, err) -} diff --git a/viiper/pkg/apiclient/stream_test.go b/viiper/pkg/apiclient/stream_test.go deleted file mode 100644 index 7308a4f0..00000000 --- a/viiper/pkg/apiclient/stream_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package apiclient - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDeviceStream_MockTransportError(t *testing.T) { - ms := &mockState{responses: map[string]string{}} - c := WithTransport(newMockTransport(ms)) - - _, err := c.OpenStream(context.Background(), 1, "1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported with mock transport") -} - -func TestAddDeviceAndConnect_ParsesDeviceID(t *testing.T) { - ms := &mockState{responses: map[string]string{}} - ms.responses["bus/{id}/add"] = `{"id":"42-7"}` - c := WithTransport(newMockTransport(ms)) - - // Should parse but fail on stream connection (mock transport) - _, resp, err := c.AddDeviceAndConnect(context.Background(), 42, "test") - require.NotNil(t, resp) - assert.Equal(t, "42-7", resp.ID) - assert.Error(t, err) // Stream connection will fail with mock - assert.Contains(t, err.Error(), "not supported with mock transport") -} - -func TestDeviceStream_ClosedStreamErrors(t *testing.T) { - // Create a minimal stream and close it - s := &DeviceStream{closed: true} - - buf := make([]byte, 10) - _, err := s.Read(buf) - assert.Error(t, err) - assert.Contains(t, err.Error(), "stream closed") - - _, err = s.Write([]byte("test")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "stream closed") - - // Close should be idempotent - assert.NoError(t, s.Close()) -} - -func TestDeviceStream_Deadlines(t *testing.T) { - // DeviceStream deadline methods delegate to the underlying connection. - // Without a real connection we can't test them, so just verify compilation. - t.Skip("deadline methods require real connection") -} - -type mockBinaryMarshaler struct { - data []byte -} - -func (m *mockBinaryMarshaler) MarshalBinary() ([]byte, error) { - return m.data, nil -} - -func (m *mockBinaryMarshaler) UnmarshalBinary(data []byte) error { - m.data = make([]byte, len(data)) - copy(m.data, data) - return nil -} - -func TestDeviceStream_WriteBinary(t *testing.T) { - s := &DeviceStream{closed: true} - - msg := &mockBinaryMarshaler{data: []byte("test")} - err := s.WriteBinary(msg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "stream closed") -} - -func TestDeviceStream_StartReading_RequiresConnection(t *testing.T) { - // StartReading requires a real connection to function properly - t.Skip("StartReading requires real connection for testing") -} diff --git a/viiper/pkg/apiclient/transport_test.go b/viiper/pkg/apiclient/transport_test.go deleted file mode 100644 index 04fbbb2b..00000000 --- a/viiper/pkg/apiclient/transport_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package apiclient - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestToPayloadBytes(t *testing.T) { - b, ok := toPayloadBytes(nil) - assert.True(t, ok) - assert.Nil(t, b) - - orig := []byte{0x01, 0x02, 0x03} - b, ok = toPayloadBytes(orig) - assert.True(t, ok) - assert.Equal(t, orig, b) - - b, ok = toPayloadBytes("hello") - assert.True(t, ok) - assert.Equal(t, []byte("hello"), b) - - type S struct { - A int `json:"a"` - B string `json:"b"` - } - b, ok = toPayloadBytes(S{A: 5, B: "x"}) - assert.True(t, ok) - var s S - assert.NoError(t, json.Unmarshal(b, &s)) - assert.Equal(t, 5, s.A) - assert.Equal(t, "x", s.B) -} diff --git a/viiper/pkg/apitypes/structs.go b/viiper/pkg/apitypes/structs.go deleted file mode 100644 index ef285541..00000000 --- a/viiper/pkg/apitypes/structs.go +++ /dev/null @@ -1,40 +0,0 @@ -package apitypes - -// Shared API response structs used by both handlers and clients. - -type ApiError struct { - Error string `json:"error"` -} - -type BusListResponse struct { - Buses []uint32 `json:"buses"` -} - -type BusCreateResponse struct { - BusID uint32 `json:"busId"` -} - -type BusRemoveResponse struct { - BusID uint32 `json:"busId"` -} - -type Device struct { - BusID uint32 `json:"busId"` - DevId string `json:"devId"` - Vid string `json:"vid"` - Pid string `json:"pid"` - Type string `json:"type"` -} - -type DevicesListResponse struct { - Devices []Device `json:"devices"` -} - -type DeviceAddResponse struct { - ID string `json:"id"` // Format: "-" -} - -type DeviceRemoveResponse struct { - BusID uint32 `json:"busId"` - DevId string `json:"devId"` -} diff --git a/viiper/pkg/device/keyboard/const.go b/viiper/pkg/device/keyboard/const.go deleted file mode 100644 index 4d8348d6..00000000 --- a/viiper/pkg/device/keyboard/const.go +++ /dev/null @@ -1,177 +0,0 @@ -package keyboard - -// Modifier key bitmasks -const ( - ModLeftCtrl = 0x01 - ModLeftShift = 0x02 - ModLeftAlt = 0x04 - ModLeftGUI = 0x08 // Windows/Command key - ModRightCtrl = 0x10 - ModRightShift = 0x20 - ModRightAlt = 0x40 - ModRightGUI = 0x80 -) - -// LED bitmasks -const ( - LEDNumLock = 0x01 - LEDCapsLock = 0x02 - LEDScrollLock = 0x04 - LEDCompose = 0x08 - LEDKana = 0x10 -) - -// HID Usage codes for keyboard keys (USB HID Keyboard/Keypad usage page) -const ( - // Letters A-Z - KeyA = 0x04 - KeyB = 0x05 - KeyC = 0x06 - KeyD = 0x07 - KeyE = 0x08 - KeyF = 0x09 - KeyG = 0x0A - KeyH = 0x0B - KeyI = 0x0C - KeyJ = 0x0D - KeyK = 0x0E - KeyL = 0x0F - KeyM = 0x10 - KeyN = 0x11 - KeyO = 0x12 - KeyP = 0x13 - KeyQ = 0x14 - KeyR = 0x15 - KeyS = 0x16 - KeyT = 0x17 - KeyU = 0x18 - KeyV = 0x19 - KeyW = 0x1A - KeyX = 0x1B - KeyY = 0x1C - KeyZ = 0x1D - - // Numbers 1-0 (top row) - Key1 = 0x1E - Key2 = 0x1F - Key3 = 0x20 - Key4 = 0x21 - Key5 = 0x22 - Key6 = 0x23 - Key7 = 0x24 - Key8 = 0x25 - Key9 = 0x26 - Key0 = 0x27 - - // Special keys - KeyEnter = 0x28 - KeyEscape = 0x29 - KeyBackspace = 0x2A - KeyTab = 0x2B - KeySpace = 0x2C - KeyMinus = 0x2D // - and _ - KeyEqual = 0x2E // = and + - KeyLeftBrace = 0x2F // [ and { - KeyRightBrace = 0x30 // ] and } - KeyBackslash = 0x31 // \ and | - KeyNonUSHash = 0x32 // Non-US # and ~ - KeySemicolon = 0x33 // ; and : - KeyApostrophe = 0x34 // ' and " - KeyGrave = 0x35 // ` and ~ - KeyComma = 0x36 // , and < - KeyPeriod = 0x37 // . and > - KeySlash = 0x38 // / and ? - KeyCapsLock = 0x39 - - // Function keys - KeyF1 = 0x3A - KeyF2 = 0x3B - KeyF3 = 0x3C - KeyF4 = 0x3D - KeyF5 = 0x3E - KeyF6 = 0x3F - KeyF7 = 0x40 - KeyF8 = 0x41 - KeyF9 = 0x42 - KeyF10 = 0x43 - KeyF11 = 0x44 - KeyF12 = 0x45 - - // Control keys - KeyPrintScreen = 0x46 - KeyScrollLock = 0x47 - KeyPause = 0x48 - KeyInsert = 0x49 - KeyHome = 0x4A - KeyPageUp = 0x4B - KeyDelete = 0x4C - KeyEnd = 0x4D - KeyPageDown = 0x4E - - // Arrow keys - KeyRight = 0x4F - KeyLeft = 0x50 - KeyDown = 0x51 - KeyUp = 0x52 - - // Numpad - KeyNumLock = 0x53 - KeyKpSlash = 0x54 // Keypad / - KeyKpAsterisk = 0x55 // Keypad * - KeyKpMinus = 0x56 // Keypad - - KeyKpPlus = 0x57 // Keypad + - KeyKpEnter = 0x58 // Keypad Enter - KeyKp1 = 0x59 // Keypad 1 and End - KeyKp2 = 0x5A // Keypad 2 and Down - KeyKp3 = 0x5B // Keypad 3 and PageDn - KeyKp4 = 0x5C // Keypad 4 and Left - KeyKp5 = 0x5D // Keypad 5 - KeyKp6 = 0x5E // Keypad 6 and Right - KeyKp7 = 0x5F // Keypad 7 and Home - KeyKp8 = 0x60 // Keypad 8 and Up - KeyKp9 = 0x61 // Keypad 9 and PageUp - KeyKp0 = 0x62 // Keypad 0 and Insert - KeyKpDot = 0x63 // Keypad . and Delete - - // Additional keys - KeyNonUSBackslash = 0x64 // Non-US \ and | - KeyApplication = 0x65 // Application (Windows Menu key) - KeyPower = 0x66 // Power (not commonly used) - KeyKpEqual = 0x67 // Keypad = - - // Extended function keys - KeyF13 = 0x68 - KeyF14 = 0x69 - KeyF15 = 0x6A - KeyF16 = 0x6B - KeyF17 = 0x6C - KeyF18 = 0x6D - KeyF19 = 0x6E - KeyF20 = 0x6F - KeyF21 = 0x70 - KeyF22 = 0x71 - KeyF23 = 0x72 - KeyF24 = 0x73 - - // Execution keys - KeyExecute = 0x74 - KeyHelp = 0x75 - KeyMenu = 0x76 - KeySelect = 0x77 - KeyStop = 0x78 - KeyAgain = 0x79 // Redo - KeyUndo = 0x7A - KeyCut = 0x7B - KeyCopy = 0x7C - KeyPaste = 0x7D - KeyFind = 0x7E - KeyMute = 0x7F - KeyVolumeUp = 0x80 - KeyVolumeDown = 0x81 - - // Media control keys - KeyMediaPlayPause = 0xE8 // Play/Pause - KeyMediaStop = 0xE9 // Stop - KeyMediaNext = 0xEB // Next Track - KeyMediaPrevious = 0xEC // Previous Track -) diff --git a/viiper/pkg/device/keyboard/device.go b/viiper/pkg/device/keyboard/device.go deleted file mode 100644 index e61908c3..00000000 --- a/viiper/pkg/device/keyboard/device.go +++ /dev/null @@ -1,202 +0,0 @@ -// Package keyboard provides a HID keyboard device implementation with full N-key rollover. -package keyboard - -import ( - "sync" - "sync/atomic" - - "viiper/pkg/usb" - "viiper/pkg/usbip" - "viiper/pkg/virtualbus" -) - -// Keyboard implements the Device interface for a full HID keyboard with LED support. -type Keyboard struct { - tick uint64 - inputState *InputState - stateMu sync.Mutex - ledState uint8 - ledCallback func(LEDState) -} - -// New returns a new Keyboard device. -func New() *Keyboard { - return &Keyboard{} -} - -// SetLEDCallback sets a callback that will be invoked when LED state changes. -func (k *Keyboard) SetLEDCallback(f func(LEDState)) { - k.ledCallback = f -} - -// GetLEDState returns the current LED state from the host. -func (k *Keyboard) GetLEDState() LEDState { - k.stateMu.Lock() - defer k.stateMu.Unlock() - return LEDState{ - NumLock: k.ledState&LEDNumLock != 0, - CapsLock: k.ledState&LEDCapsLock != 0, - ScrollLock: k.ledState&LEDScrollLock != 0, - Compose: k.ledState&LEDCompose != 0, - Kana: k.ledState&LEDKana != 0, - } -} - -// UpdateInputState updates the device's current input state (thread-safe). -func (k *Keyboard) UpdateInputState(state InputState) { - k.stateMu.Lock() - defer k.stateMu.Unlock() - k.inputState = &state -} - -// HandleTransfer implements interrupt IN/OUT for Keyboard. -func (k *Keyboard) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { - switch ep { - case 1: // 0x81 - keyboard input reports - atomic.AddUint64(&k.tick, 1) - - k.stateMu.Lock() - var st InputState - if k.inputState != nil { - st = *k.inputState - } - k.stateMu.Unlock() - return st.BuildReport() - default: - return nil - } - } - if dir == usbip.DirOut && ep == 1 { - // 0x01 - LED state from host - if len(out) >= 1 { - k.stateMu.Lock() - k.ledState = out[0] - k.stateMu.Unlock() - - if k.ledCallback != nil { - k.ledCallback(LEDState{ - NumLock: out[0]&LEDNumLock != 0, - CapsLock: out[0]&LEDCapsLock != 0, - ScrollLock: out[0]&LEDScrollLock != 0, - Compose: out[0]&LEDCompose != 0, - Kana: out[0]&LEDKana != 0, - }) - } - } - } - return nil -} - -// HID Report Descriptor for a full keyboard with 256-bit key bitmap and LED output. -var hidReportDescriptor = []byte{ - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x06, // Usage (Keyboard) - 0xA1, 0x01, // Collection (Application) - - // Input Report: Modifiers (1 byte) - 0x05, 0x07, // Usage Page (Keyboard/Keypad) - 0x19, 0xE0, // Usage Minimum (Left Control) - 0x29, 0xE7, // Usage Maximum (Right GUI) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x01, // Report Size (1) - 0x95, 0x08, // Report Count (8) - 0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte - - // Input Report: Reserved byte (1 byte) - 0x75, 0x08, // Report Size (8) - 0x95, 0x01, // Report Count (1) - 0x81, 0x01, // Input (Constant) - Reserved byte - - // Input Report: Key array bitmap (32 bytes = 256 bits) - 0x05, 0x07, // Usage Page (Keyboard/Keypad) - 0x19, 0x00, // Usage Minimum (0x00) - 0x29, 0xFF, // Usage Maximum (0xFF) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x01, // Report Size (1) - 0x96, 0x00, 0x01, // Report Count (256) - long item (0x96 for 2-byte count) - 0x81, 0x02, // Input (Data, Variable, Absolute) - Key bitmap - - // Output Report: LEDs (1 byte) - 0x05, 0x08, // Usage Page (LEDs) - 0x19, 0x01, // Usage Minimum (Num Lock) - 0x29, 0x05, // Usage Maximum (Kana) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x01, // Report Size (1) - 0x95, 0x05, // Report Count (5) - 0x91, 0x02, // Output (Data, Variable, Absolute) - LED bits - 0x75, 0x03, // Report Size (3) - 0x95, 0x01, // Report Count (1) - 0x91, 0x01, // Output (Constant) - LED padding - - 0xC0, // End Collection -} - -// Descriptor defines the static USB descriptor for the keyboard. -var Descriptor = virtualbus.DeviceDescriptor{ - Device: usb.DeviceDescriptor{ - BcdUSB: 0x0200, - BDeviceClass: 0x00, - BDeviceSubClass: 0x00, - BDeviceProtocol: 0x00, - BMaxPacketSize0: 0x40, // 64 bytes - IDVendor: 0x2E8A, - IDProduct: 0x0010, - BcdDevice: 0x0100, - IManufacturer: 0x01, - IProduct: 0x02, - ISerialNumber: 0x03, - BNumConfigurations: 0x01, - Speed: 2, // Full speed - }, - Interfaces: []virtualbus.InterfaceConfig{ - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, - BAlternateSetting: 0x00, - BNumEndpoints: 0x02, - BInterfaceClass: 0x03, // HID - BInterfaceSubClass: 0x00, // No Subclass - BInterfaceProtocol: 0x00, // None - IInterface: 0x00, - }, - HIDDescriptor: []byte{ - 0x09, // bLength - 0x21, // bDescriptorType (HID) - 0x11, 0x01, // bcdHID 1.11 - 0x00, // bCountryCode - 0x01, // bNumDescriptors - 0x22, // bDescriptorType (Report) - byte(len(hidReportDescriptor)), 0x00, // wDescriptorLength - }, - HIDReport: hidReportDescriptor, - Endpoints: []usb.EndpointDescriptor{ - { - BEndpointAddress: 0x81, - BMAttributes: 0x03, // Interrupt - WMaxPacketSize: 0x0040, - BInterval: 0x0A, // 10 ms - }, - { - BEndpointAddress: 0x01, - BMAttributes: 0x03, // Interrupt - WMaxPacketSize: 0x0008, - BInterval: 0x0A, // 10 ms - }, - }, - }, - }, - Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) - 1: "VIIPER", - 2: "HID Keyboard", - 3: "1337", - }, -} - -func (k *Keyboard) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor -} diff --git a/viiper/pkg/device/mouse/device.go b/viiper/pkg/device/mouse/device.go deleted file mode 100644 index 75576d47..00000000 --- a/viiper/pkg/device/mouse/device.go +++ /dev/null @@ -1,158 +0,0 @@ -// Package mouse provides a HID mouse device implementation. -package mouse - -import ( - "sync" - "sync/atomic" - - "viiper/pkg/usb" - "viiper/pkg/usbip" - "viiper/pkg/virtualbus" -) - -// Mouse implements the minimal Device interface for a 5-button HID mouse -// with vertical and horizontal wheels. -type Mouse struct { - tick uint64 - inputState *InputState - stateMu sync.Mutex -} - -// New returns a new Mouse device. -func New() *Mouse { - return &Mouse{} -} - -// UpdateInputState updates the device's current input state (thread-safe). -func (m *Mouse) UpdateInputState(state InputState) { - m.stateMu.Lock() - defer m.stateMu.Unlock() - m.inputState = &state -} - -// HandleTransfer implements interrupt IN for Mouse. -func (m *Mouse) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { - switch ep { - case 1: // 0x81 - main input reports - atomic.AddUint64(&m.tick, 1) - - m.stateMu.Lock() - var st InputState - if m.inputState != nil { - // Snapshot current state - st = *m.inputState - // Consume relative deltas so they are one-shot per poll cycle. - // Buttons persist until explicitly changed by the client. - m.inputState.DX = 0 - m.inputState.DY = 0 - m.inputState.Wheel = 0 - m.inputState.Pan = 0 - } - m.stateMu.Unlock() - return st.BuildReport() - default: - return nil - } - } - return nil -} - -// HID Report Descriptor for a 5-button mouse with vertical and horizontal wheels. -// Boot protocol compatible. -var hidReportDescriptor = []byte{ - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x02, // Usage (Mouse) - 0xA1, 0x01, // Collection (Application) - 0x09, 0x01, // Usage (Pointer) - 0xA1, 0x00, // Collection (Physical) - 0x05, 0x09, // Usage Page (Button) - 0x19, 0x01, // Usage Minimum (Button 1) - 0x29, 0x05, // Usage Maximum (Button 5) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x95, 0x05, // Report Count (5) - 0x75, 0x01, // Report Size (1) - 0x81, 0x02, // Input (Data, Variable, Absolute) - 0x95, 0x01, // Report Count (1) - 0x75, 0x03, // Report Size (3) - 0x81, 0x01, // Input - padding - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x30, // Usage (X) - 0x09, 0x31, // Usage (Y) - 0x09, 0x38, // Usage (Wheel) - 0x15, 0x81, // Logical Minimum (-127) - 0x25, 0x7F, // Logical Maximum (127) - 0x75, 0x08, // Report Size (8) - 0x95, 0x03, // Report Count (3) - 0x81, 0x06, // Input (Data, Variable, Relative) - 0x05, 0x0C, // Usage Page (Consumer) - 0x0A, 0x38, 0x02, // Usage (AC Pan) - 0x15, 0x81, // Logical Minimum (-127) - 0x25, 0x7F, // Logical Maximum (127) - 0x75, 0x08, // Report Size (8) - 0x95, 0x01, // Report Count (1) - 0x81, 0x06, // Input (Data, Variable, Relative) - 0xC0, // End Collection - 0xC0, // End Collection -} - -// Descriptor defines the static USB descriptor for the mouse. -var Descriptor = virtualbus.DeviceDescriptor{ - Device: usb.DeviceDescriptor{ - BcdUSB: 0x0200, - BDeviceClass: 0x00, - BDeviceSubClass: 0x00, - BDeviceProtocol: 0x00, - BMaxPacketSize0: 0x40, // 64 bytes - IDVendor: 0x2E8A, - IDProduct: 0x0011, - BcdDevice: 0x0100, - IManufacturer: 0x01, - IProduct: 0x02, - ISerialNumber: 0x03, - BNumConfigurations: 0x01, - Speed: 2, // Full speed - }, - Interfaces: []virtualbus.InterfaceConfig{ - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, - BAlternateSetting: 0x00, - BNumEndpoints: 0x01, - BInterfaceClass: 0x03, // HID - BInterfaceSubClass: 0x01, // Boot Interface - BInterfaceProtocol: 0x02, // Mouse - IInterface: 0x00, - }, - HIDDescriptor: []byte{ - 0x09, // bLength - 0x21, // bDescriptorType (HID) - 0x11, 0x01, // bcdHID 1.11 - 0x00, // bCountryCode - 0x01, // bNumDescriptors - 0x22, // bDescriptorType (Report) - byte(len(hidReportDescriptor)), 0x00, // wDescriptorLength - }, - HIDReport: hidReportDescriptor, - Endpoints: []usb.EndpointDescriptor{ - { - BEndpointAddress: 0x81, - BMAttributes: 0x03, // Interrupt - WMaxPacketSize: 0x0008, - BInterval: 0x0A, // 10 ms - }, - }, - }, - }, - Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) - 1: "VIIPER", - 2: "HID Mouse", - 3: "1337", - }, -} - -func (m *Mouse) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor -} diff --git a/viiper/pkg/device/mouse/inputstate.go b/viiper/pkg/device/mouse/inputstate.go deleted file mode 100644 index c427326a..00000000 --- a/viiper/pkg/device/mouse/inputstate.go +++ /dev/null @@ -1,61 +0,0 @@ -package mouse - -import ( - "io" -) - -// InputState represents the mouse state used to build a report. -// viiper:wire mouse c2s buttons:u8 dx:i8 dy:i8 wheel:i8 pan:i8 -type InputState struct { - // Button bitfield: bit 0=Left, 1=Right, 2=Middle, 3=Back, 4=Forward - Buttons uint8 - // Delta X/Y: signed 8-bit relative movement - DX, DY int8 - // Wheel: signed 8-bit vertical scroll - Wheel int8 - // Pan: signed 8-bit horizontal scroll - Pan int8 -} - -// BuildReport encodes an InputState into the 5-byte HID mouse report. -// -// Report layout (5 bytes): -// -// Byte 0: Button bitfield (bit 0=Left, 1=Right, 2=Middle, 3=Back, 4=Forward, bits 5-7=padding) -// Byte 1: DX (int8, -127 to +127) -// Byte 2: DY (int8) -// Byte 3: Wheel (int8) -// Byte 4: Pan (int8) -func (st InputState) BuildReport() []byte { - b := make([]byte, 5) - b[0] = st.Buttons & 0x1F // 5 buttons, mask upper bits - b[1] = byte(st.DX) - b[2] = byte(st.DY) - b[3] = byte(st.Wheel) - b[4] = byte(st.Pan) - return b -} - -// MarshalBinary encodes InputState to 5 bytes. -func (m *InputState) MarshalBinary() ([]byte, error) { - b := make([]byte, 5) - b[0] = m.Buttons - b[1] = byte(m.DX) - b[2] = byte(m.DY) - b[3] = byte(m.Wheel) - b[4] = byte(m.Pan) - return b, nil -} - -// UnmarshalBinary decodes 5 bytes into InputState. -func (m *InputState) UnmarshalBinary(data []byte) error { - if len(data) < 5 { - return io.ErrUnexpectedEOF - } - m.Buttons = data[0] - m.DX = int8(data[1]) - m.DY = int8(data[2]) - m.Wheel = int8(data[3]) - m.Pan = int8(data[4]) - return nil -} diff --git a/viiper/pkg/device/xbox360/device.go b/viiper/pkg/device/xbox360/device.go deleted file mode 100644 index 686b0a4c..00000000 --- a/viiper/pkg/device/xbox360/device.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package xbox360 provides an Xbox 360 controller device implementation. -package xbox360 - -import ( - "sync" - "sync/atomic" - - "viiper/pkg/usb" - "viiper/pkg/usbip" - "viiper/pkg/virtualbus" -) - -// Xbox360 implements only the minimal Device interface. -type Xbox360 struct { - tick uint64 - inputState *InputState - stateMu sync.Mutex - rumbleFunc func(XRumbleState) // called when rumble commands arrive -} - -// New returns a new Xbox360 device. -func New() *Xbox360 { - return &Xbox360{} -} - -// SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. -func (x *Xbox360) SetRumbleCallback(f func(XRumbleState)) { - x.rumbleFunc = f -} - -// UpdateInputState updates the device's current input state (thread-safe). -func (x *Xbox360) UpdateInputState(state InputState) { - x.stateMu.Lock() - defer x.stateMu.Unlock() - x.inputState = &state -} - -// HandleTransfer implements interrupt IN/OUT for Xbox360. -func (x *Xbox360) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { - if dir == usbip.DirIn { - switch ep { - case 1: // 0x81 - main input reports - atomic.AddUint64(&x.tick, 1) - - x.stateMu.Lock() - var st InputState - if x.inputState != nil { - st = *x.inputState - } - x.stateMu.Unlock() - return st.BuildReport() - default: - return nil - } - } - if dir == usbip.DirOut && ep == 1 { - if len(out) >= 8 { - rumble := XRumbleState{ - LeftMotor: out[3], - RightMotor: out[4], - } - if x.rumbleFunc != nil { - x.rumbleFunc(rumble) - } - } - } - return nil -} - -// Static descriptor/config for Xbox360, for registration with the bus. -var Descriptor = virtualbus.DeviceDescriptor{ - Device: usb.DeviceDescriptor{ - BcdUSB: 0x0200, - BDeviceClass: 0xff, - BDeviceSubClass: 0xff, - BDeviceProtocol: 0xff, - BMaxPacketSize0: 0x08, - IDVendor: 0x045e, - IDProduct: 0x028e, - BcdDevice: 0x0114, - IManufacturer: 0x01, - IProduct: 0x02, - ISerialNumber: 0x03, - BNumConfigurations: 0x01, - Speed: 2, // Full speed - }, - Interfaces: []virtualbus.InterfaceConfig{ - // Interface 0: ff/5d/01 with 2 interrupt endpoints - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, - BAlternateSetting: 0x00, - BNumEndpoints: 0x02, - BInterfaceClass: 0xff, - BInterfaceSubClass: 0x5d, - BInterfaceProtocol: 0x01, - IInterface: 0x00, - }, - HIDDescriptor: []byte{0x11, 0x21, 0x00, 0x01, 0x01, 0x25, 0x81, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 0x08, 0x00, 0x00}, - Endpoints: []usb.EndpointDescriptor{ - {BEndpointAddress: 0x81, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x04}, - {BEndpointAddress: 0x01, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x08}, - }, - }, - // Interface 1: ff/5d/03 with 4 interrupt endpoints - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x01, - BAlternateSetting: 0x00, - BNumEndpoints: 0x04, - BInterfaceClass: 0xff, - BInterfaceSubClass: 0x5d, - BInterfaceProtocol: 0x03, - IInterface: 0x00, - }, - HIDDescriptor: []byte{0x1b, 0x21, 0x00, 0x01, 0x01, 0x01, 0x82, 0x40, 0x01, 0x02, 0x20, 0x16, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - Endpoints: []usb.EndpointDescriptor{ - {BEndpointAddress: 0x82, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x02}, - {BEndpointAddress: 0x02, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x04}, - {BEndpointAddress: 0x83, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x40}, - {BEndpointAddress: 0x03, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x10}, - }, - }, - // Interface 2: ff/5d/02 with 1 interrupt endpoint - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x02, - BAlternateSetting: 0x00, - BNumEndpoints: 0x01, - BInterfaceClass: 0xff, - BInterfaceSubClass: 0x5d, - BInterfaceProtocol: 0x02, - IInterface: 0x00, - }, - HIDDescriptor: []byte{0x09, 0x21, 0x00, 0x01, 0x01, 0x22, 0x84, 0x07, 0x00}, - Endpoints: []usb.EndpointDescriptor{ - {BEndpointAddress: 0x84, BMAttributes: 0x03, WMaxPacketSize: 0x0020, BInterval: 0x10}, - }, - }, - // Interface 3: ff/fd/13 with vendor-specific descriptor - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x03, - BAlternateSetting: 0x00, - BNumEndpoints: 0x00, - BInterfaceClass: 0xff, - BInterfaceSubClass: 0xfd, - BInterfaceProtocol: 0x13, - IInterface: 0x04, - }, - VendorData: []byte{0x06, 0x41, 0x00, 0x01, 0x01, 0x03}, - }, - }, - Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) - 1: "©Microsoft Corporation", - 2: "Controller", - 3: "296013F", - }, -} - -func (x *Xbox360) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor -} diff --git a/viiper/pkg/usb/device.go b/viiper/pkg/usb/device.go deleted file mode 100644 index 7c1754b5..00000000 --- a/viiper/pkg/usb/device.go +++ /dev/null @@ -1,10 +0,0 @@ -package usb - -// Device is the minimal interface a device must implement. -// It only handles non-EP0 (interrupt/bulk) transfers. -type Device interface { - // HandleTransfer processes a non-EP0 transfer (interrupt/bulk). - // ep is the endpoint number (without direction). dir is protocol.DirIn or protocol.DirOut. - // For IN transfers, return the payload to send; for OUT, consume 'out' and return nil. - HandleTransfer(ep uint32, dir uint32, out []byte) []byte -} diff --git a/viiper/pkg/usb/usbdesc.go b/viiper/pkg/usb/usbdesc.go deleted file mode 100644 index cabcc032..00000000 --- a/viiper/pkg/usb/usbdesc.go +++ /dev/null @@ -1,158 +0,0 @@ -// Package usb contains helpers for building USB descriptors and data. -package usb - -import ( - "bytes" - "encoding/binary" -) - -// USB descriptor type constants -const ( - DeviceDescType = 0x01 - ConfigDescType = 0x02 - InterfaceDescType = 0x04 - EndpointDescType = 0x05 - HIDDescType = 0x21 - ReportDescType = 0x22 -) - -// Descriptor lengths in bytes (fixed values from USB spec) -const ( - DeviceDescLen = 18 - ConfigDescLen = 9 - InterfaceDescLen = 9 - EndpointDescLen = 7 - HIDDescLen = 9 -) - -// DeviceDescriptor represents the standard USB device descriptor. -// BLength is computed dynamically; BDescriptorType is implied DeviceDescType. -type DeviceDescriptor struct { - BcdUSB uint16 // LE - BDeviceClass uint8 - BDeviceSubClass uint8 - BDeviceProtocol uint8 - BMaxPacketSize0 uint8 - IDVendor uint16 // LE - IDProduct uint16 // LE - BcdDevice uint16 // LE - IManufacturer uint8 - IProduct uint8 - ISerialNumber uint8 - BNumConfigurations uint8 - Speed uint32 // USB speed: 1=low, 2=full, 3=high, 4=super -} - -// Bytes returns the binary representation of the DeviceDescriptor with BLength auto-filled. -func (d DeviceDescriptor) Bytes() []byte { - var b bytes.Buffer - b.WriteByte(DeviceDescLen) - b.WriteByte(DeviceDescType) - _ = binary.Write(&b, binary.LittleEndian, d.BcdUSB) - b.WriteByte(d.BDeviceClass) - b.WriteByte(d.BDeviceSubClass) - b.WriteByte(d.BDeviceProtocol) - b.WriteByte(d.BMaxPacketSize0) - _ = binary.Write(&b, binary.LittleEndian, d.IDVendor) - _ = binary.Write(&b, binary.LittleEndian, d.IDProduct) - _ = binary.Write(&b, binary.LittleEndian, d.BcdDevice) - b.WriteByte(d.IManufacturer) - b.WriteByte(d.IProduct) - b.WriteByte(d.ISerialNumber) - b.WriteByte(d.BNumConfigurations) - return b.Bytes() -} - -// ConfigHeader represents the USB configuration descriptor header (9 bytes). -type ConfigHeader struct { - WTotalLength uint16 // LE, to be patched after building - BNumInterfaces uint8 - BConfigurationValue uint8 - IConfiguration uint8 - BMAttributes uint8 - BMaxPower uint8 -} - -func (h ConfigHeader) Write(b *bytes.Buffer) { - b.WriteByte(ConfigDescLen) - b.WriteByte(ConfigDescType) - _ = binary.Write(b, binary.LittleEndian, h.WTotalLength) - b.WriteByte(h.BNumInterfaces) - b.WriteByte(h.BConfigurationValue) - b.WriteByte(h.IConfiguration) - b.WriteByte(h.BMAttributes) - b.WriteByte(h.BMaxPower) - -} - -// InterfaceDescriptor (9 bytes) for each interface altsetting. -type InterfaceDescriptor struct { - BInterfaceNumber uint8 - BAlternateSetting uint8 - BNumEndpoints uint8 - BInterfaceClass uint8 - BInterfaceSubClass uint8 - BInterfaceProtocol uint8 - IInterface uint8 -} - -func (i InterfaceDescriptor) Write(b *bytes.Buffer) { - b.WriteByte(InterfaceDescLen) - b.WriteByte(InterfaceDescType) - b.WriteByte(i.BInterfaceNumber) - b.WriteByte(i.BAlternateSetting) - b.WriteByte(i.BNumEndpoints) - b.WriteByte(i.BInterfaceClass) - b.WriteByte(i.BInterfaceSubClass) - b.WriteByte(i.BInterfaceProtocol) - b.WriteByte(i.IInterface) - -} - -// EndpointDescriptor (7 bytes) for each endpoint. -type EndpointDescriptor struct { - BEndpointAddress uint8 - BMAttributes uint8 - WMaxPacketSize uint16 // LE - BInterval uint8 -} - -func (e EndpointDescriptor) Write(b *bytes.Buffer) { - b.WriteByte(EndpointDescLen) - b.WriteByte(EndpointDescType) - b.WriteByte(e.BEndpointAddress) - b.WriteByte(e.BMAttributes) - _ = binary.Write(b, binary.LittleEndian, e.WMaxPacketSize) - b.WriteByte(e.BInterval) - -} - -// HIDDescriptor (class descriptor, 0x21) with one subordinate report descriptor (0x22). -type HIDDescriptor struct { - BcdHID uint16 // LE - BCountryCode uint8 - BNumDescriptors uint8 - ClassDescType uint8 // 0x22 (report) - WDescriptorLength uint16 // LE, report descriptor length -} - -func (h HIDDescriptor) Write(b *bytes.Buffer) { - b.WriteByte(HIDDescLen) - b.WriteByte(HIDDescType) - _ = binary.Write(b, binary.LittleEndian, h.BcdHID) - b.WriteByte(h.BCountryCode) - b.WriteByte(h.BNumDescriptors) - b.WriteByte(h.ClassDescType) - _ = binary.Write(b, binary.LittleEndian, h.WDescriptorLength) - -} - -// ReportDescriptor is a container for HID report descriptor bytes (0x22). -// Builders can populate Data to emit via Bytes(). -type ReportDescriptor struct { - Data []byte -} - -func (r ReportDescriptor) Bytes() []byte { - return r.Data -} diff --git a/viiper/pkg/virtualbus/virtualbus.go b/viiper/pkg/virtualbus/virtualbus.go deleted file mode 100644 index 7bb8c588..00000000 --- a/viiper/pkg/virtualbus/virtualbus.go +++ /dev/null @@ -1,290 +0,0 @@ -// Package virtualbus manages USB bus topology and auto-assigns device addresses. -package virtualbus - -import ( - "context" - "fmt" - "sync" - "time" - - "viiper/pkg/device" - "viiper/pkg/usb" - "viiper/pkg/usbip" -) - -const basepath = "/sys/devices/pci0000:00/0000:00:08.1/0000:00:04:00.3/usb" - -var ( - globalBusCounter uint32 - allocatedBusIds = make(map[uint32]bool) - globalMutex sync.Mutex -) - -// VirtualBus manages USB bus topology and auto-assigns device addresses. -type VirtualBus struct { - mutex sync.Mutex - busId uint32 - nextDevID uint32 - allocatedDevIDs map[uint32]bool - devices []busDevice -} - -// DeviceMeta exposes a registered device and its metadata for external queries. -type DeviceMeta struct { - Dev usb.Device - Desc DeviceDescriptor - Meta usbip.ExportMeta -} - -// InterfaceConfig holds all descriptors for a single interface for bus management. -type InterfaceConfig struct { - Descriptor usb.InterfaceDescriptor - Endpoints []usb.EndpointDescriptor - HIDDescriptor []byte // optional HID class descriptor (0x21) - HIDReport []byte // optional HID report descriptor (0x22) - VendorData []byte // optional vendor-specific bytes -} - -// DeviceDescriptor holds all static descriptor/config data for a device. -type DeviceDescriptor struct { - Device usb.DeviceDescriptor - Interfaces []InterfaceConfig - Strings map[uint8]string -} - -// EncodeStringDescriptor converts a UTF-8 string to a USB string descriptor byte array. -// The resulting descriptor has the format: -// -// Byte 0: bLength (total descriptor length) -// Byte 1: bDescriptorType (0x03 for string) -// Bytes 2+: UTF-16LE encoded string -func EncodeStringDescriptor(s string) []byte { - runes := []rune(s) - buf := make([]byte, 2+len(runes)*2) - buf[0] = uint8(len(buf)) // bLength - buf[1] = 0x03 // bDescriptorType (STRING) - for i, r := range runes { - buf[2+i*2] = uint8(r) - buf[2+i*2+1] = uint8(r >> 8) - } - return buf -} - -// DescriptorProvider is an optional device interface that exposes a static -// descriptor/config used when registering a device with a VirtualBus via -// `Add(dev)`. Devices that expose this will have their descriptors -// automatically consulted at registration time. -type DescriptorProvider interface { - GetDeviceDescriptor() DeviceDescriptor -} - -// New creates a new VirtualBus instance with a unique auto-assigned bus number. -func New() *VirtualBus { - globalMutex.Lock() - defer globalMutex.Unlock() - - busId := globalBusCounter - if busId == 0 { - busId = 1 - } - globalBusCounter = busId + 1 - allocatedBusIds[busId] = true - - return &VirtualBus{ - busId: busId, - nextDevID: 0, - allocatedDevIDs: make(map[uint32]bool), - } -} - -// NewWithBusId creates a new VirtualBus instance starting at a specific bus number. -// Returns an error if the bus number is already allocated. -func NewWithBusId(busId uint32) (*VirtualBus, error) { - globalMutex.Lock() - defer globalMutex.Unlock() - - if allocatedBusIds[busId] { - return nil, fmt.Errorf("bus number %d already allocated", busId) - } - allocatedBusIds[busId] = true - - return &VirtualBus{ - busId: busId, - nextDevID: 0, - allocatedDevIDs: make(map[uint32]bool), - }, nil -} - -// Add registers a device using a descriptor provider implemented by the device. -// This is a convenience wrapper so callers can simply do "bus.Add(dev)". -// The device must implement a method: -// -// GetDeviceDescriptor() DeviceDescriptorStruct -// -// which returns a static descriptor that will be used for bus registration. -// Returns a context containing the device's lifecycle and metadata (use GetDeviceMeta to extract). -func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { - if p, ok := dev.(DescriptorProvider); ok { - desc := p.GetDeviceDescriptor() - vb.mutex.Lock() - defer vb.mutex.Unlock() - - for _, d := range vb.devices { - if d.dev == dev { - return nil, fmt.Errorf("device already registered on this bus") - } - } - busID := vb.busId - var devID uint32 - for i := uint32(1); ; i++ { - if !vb.allocatedDevIDs[i] { - devID = i - vb.allocatedDevIDs[i] = true - break - } - } - - busDevID := fmt.Sprintf("%d-%d", busID, devID) - path := fmt.Sprintf("%s%d/%s", basepath, busID, busDevID) - - var meta usbip.ExportMeta - copy(meta.Path[:], path) - copy(meta.USBBusId[:], busDevID) - meta.BusId = busID - meta.DevId = devID - connTimer := time.NewTimer(0) - - ctx, cancel := context.WithCancel(context.Background()) - ctx = context.WithValue(ctx, device.ExportMetaKey, &meta) - ctx = context.WithValue(ctx, device.ConnTimerKey, connTimer) - - vb.devices = append(vb.devices, busDevice{dev: dev, desc: desc, meta: meta, ctx: ctx, cancel: cancel}) - return ctx, nil - } - return nil, fmt.Errorf("device does not implement GetDeviceDescriptor") -} - -// GetAllDeviceMetas returns a copy of all registered devices with their descriptors and export metadata. -func (vb *VirtualBus) GetAllDeviceMetas() []DeviceMeta { - vb.mutex.Lock() - defer vb.mutex.Unlock() - out := make([]DeviceMeta, 0, len(vb.devices)) - for _, d := range vb.devices { - out = append(out, DeviceMeta{Dev: d.dev, Desc: d.desc, Meta: d.meta}) - } - return out -} - -// BusID returns the bus number for this VirtualBus. -func (vb *VirtualBus) BusID() uint32 { - vb.mutex.Lock() - defer vb.mutex.Unlock() - return vb.busId -} - -// GetDeviceDescriptor looks up a device's descriptor/config by device reference. -// Returns nil if the device is not found. -func (vb *VirtualBus) GetDeviceDescriptor(dev usb.Device) *DeviceDescriptor { - vb.mutex.Lock() - defer vb.mutex.Unlock() - for i := range vb.devices { - if vb.devices[i].dev == dev { - return &vb.devices[i].desc - } - } - return nil -} - -// Devices returns all devices currently attached to this bus. -func (vb *VirtualBus) Devices() []usb.Device { - vb.mutex.Lock() - defer vb.mutex.Unlock() - out := make([]usb.Device, 0, len(vb.devices)) - for _, d := range vb.devices { - out = append(out, d.dev) - } - return out -} - -// RemoveDeviceByID removes a device by its ID (e.g., "1"). -// Returns error if not found. -func (vb *VirtualBus) RemoveDeviceByID(deviceID string) error { - vb.mutex.Lock() - defer vb.mutex.Unlock() - for i, d := range vb.devices { - if fmt.Sprintf("%d", d.meta.DevId) == deviceID { - if d.cancel != nil { - d.cancel() - } - delete(vb.allocatedDevIDs, d.meta.DevId) - vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) - return nil - } - } - return fmt.Errorf("device with id %s not found on bus %d", deviceID, vb.busId) -} - -// Remove unregisters a device from the bus. -// This removes the device from the internal list; it does not currently free -// the global bus number. Removal should be used for dynamic device teardown -// during runtime. -func (vb *VirtualBus) Remove(dev usb.Device) error { - vb.mutex.Lock() - defer vb.mutex.Unlock() - for i, d := range vb.devices { - if d.dev == dev { - if d.cancel != nil { - d.cancel() - } - delete(vb.allocatedDevIDs, d.meta.DevId) - vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) - return nil - } - } - return fmt.Errorf("device not found") -} - -// Close frees the bus number allocated to this VirtualBus, allowing it to be -// reused. After calling Close, this VirtualBus instance should not be used. -func (vb *VirtualBus) Close() error { - vb.mutex.Lock() - defer vb.mutex.Unlock() - - for i := range vb.devices { - if vb.devices[i].cancel != nil { - vb.devices[i].cancel() - } - vb.devices[i].ctx = nil - vb.devices[i].cancel = nil - } - - globalMutex.Lock() - defer globalMutex.Unlock() - - delete(allocatedBusIds, vb.busId) - return nil -} - -// Note: Contexts are owned by the bus and created at Add(). They are cancelled -// when a device is removed or the bus is closed. - -// GetDeviceContext returns the context for a specific device. -// Returns nil if the device is not found or has no active context. -func (vb *VirtualBus) GetDeviceContext(dev usb.Device) context.Context { - vb.mutex.Lock() - defer vb.mutex.Unlock() - for i := range vb.devices { - if vb.devices[i].dev == dev { - return vb.devices[i].ctx - } - } - return nil -} - -type busDevice struct { - dev usb.Device - desc DeviceDescriptor - meta usbip.ExportMeta - ctx context.Context - cancel context.CancelFunc -} diff --git a/viiperclient/client.go b/viiperclient/client.go new file mode 100644 index 00000000..dbb1327c --- /dev/null +++ b/viiperclient/client.go @@ -0,0 +1,182 @@ +package viiperclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/viipertypes" +) + +// Client provides a high-level interface to the VIIPER API, handling request +// formatting, response parsing, and error handling. +type Client struct{ transport *Transport } + +// New constructs a high-level API client using the internal low-level Transport. +// The addr parameter specifies the TCP address (host:port) of the VIIPER API server. +func New(addr string) *Client { return &Client{transport: NewTransport(addr)} } + +// NewWithPassword constructs a client that authenticates with the given password. +func NewWithPassword(addr, password string) *Client { + return &Client{transport: NewTransportWithPassword(addr, password)} +} + +// NewWithConfig constructs a client with custom transport timeouts. +func NewWithConfig(addr string, cfg *Config) *Client { + return &Client{transport: NewTransportWithConfig(addr, cfg)} +} + +// WithTransport constructs a Client using a custom Transport implementation. +// This is primarily useful for testing or when advanced transport configuration is needed. +func WithTransport(t *Transport) *Client { return &Client{transport: t} } + +// Ping returns the version and identity of the VIIPER server. +func (c *Client) Ping() (*viipertypes.PingResponse, error) { + return c.PingCtx(context.Background()) +} + +// PingCtx is the context-aware version of Ping. +func (c *Client) PingCtx(ctx context.Context) (*viipertypes.PingResponse, error) { + const path = "ping" + raw, err := c.transport.DoCtx(ctx, path, nil, nil) + if err != nil { + return nil, err + } + return parse[viipertypes.PingResponse](raw) +} + +// BusCreate creates a new virtual USB bus with the specified bus number. +// Returns the created bus ID or an error if the bus number is already allocated. +func (c *Client) BusCreate(busID uint32) (*viipertypes.BusCreateResponse, error) { + return c.BusCreateCtx(context.Background(), busID) +} + +func (c *Client) BusCreateCtx(ctx context.Context, busID uint32) (*viipertypes.BusCreateResponse, error) { + const path = "bus/create" + raw, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) + if err != nil { + return nil, err + } + return parse[viipertypes.BusCreateResponse](raw) +} + +// BusRemove removes an existing virtual USB bus and all devices attached to it. +// Returns the removed bus ID or an error if the bus does not exist. +func (c *Client) BusRemove(busID uint32) (*viipertypes.BusRemoveResponse, error) { + return c.BusRemoveCtx(context.Background(), busID) +} + +func (c *Client) BusRemoveCtx(ctx context.Context, busID uint32) (*viipertypes.BusRemoveResponse, error) { + const path = "bus/remove" + raw, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) + if err != nil { + return nil, err + } + return parse[viipertypes.BusRemoveResponse](raw) +} + +// BusList retrieves a list of all active virtual USB bus numbers. +func (c *Client) BusList() (*viipertypes.BusListResponse, error) { + return c.BusListCtx(context.Background()) +} + +func (c *Client) BusListCtx(ctx context.Context) (*viipertypes.BusListResponse, error) { + const path = "bus/list" + raw, err := c.transport.DoCtx(ctx, path, nil, nil) + if err != nil { + return nil, err + } + return parse[viipertypes.BusListResponse](raw) +} + +// DeviceAdd adds a new device of the specified type to the given bus. +// The devType parameter specifies the device type (e.g., "xbox360"). +// Returns the assigned bus ID (e.g., "1-1") or an error if the bus does not exist +// or the device type is unknown. +func (c *Client) DeviceAdd(busID uint32, devType string, o *device.CreateOptions) (*viipertypes.Device, error) { + return c.DeviceAddCtx(context.Background(), busID, devType, o) +} + +func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, o *device.CreateOptions) (*viipertypes.Device, error) { + pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} + const path = "bus/{id}/add" + + if o == nil { + o = &device.CreateOptions{} + } + var deviceSpecific map[string]any + if o.DeviceSpecific != "" { + err := json.Unmarshal([]byte(o.DeviceSpecific), &deviceSpecific) + if err != nil { + return nil, fmt.Errorf("invalid CreateOptions.DeviceSpecific JSON: %w", err) + } + } + req := viipertypes.DeviceCreateRequest{ + Type: &devType, + IDVendor: o.IDVendor, + IDProduct: o.IDProduct, + DeviceSpecific: deviceSpecific, + } + payloadBytes, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal device create request: %w", err) + } + raw, err := c.transport.DoCtx(ctx, path, string(payloadBytes), pathParams) + if err != nil { + return nil, err + } + return parse[viipertypes.Device](raw) +} + +// DeviceRemove removes a device from the specified bus by its device ID. +// The devID parameter is the device number (e.g., "1") on the given bus. +// Active USB-IP connections to the device will be closed. +// Returns the removed device's bus and device ID or an error if not found. +func (c *Client) DeviceRemove(busID uint32, devID string) (*viipertypes.DeviceRemoveResponse, error) { + return c.DeviceRemoveCtx(context.Background(), busID, devID) +} + +func (c *Client) DeviceRemoveCtx(ctx context.Context, busID uint32, devID string) (*viipertypes.DeviceRemoveResponse, error) { + pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} + const path = "bus/{id}/remove" + raw, err := c.transport.DoCtx(ctx, path, devID, pathParams) + if err != nil { + return nil, err + } + return parse[viipertypes.DeviceRemoveResponse](raw) +} + +// DevicesList retrieves a list of all devices attached to the specified bus. +// Each device entry includes bus ID, device ID, VID, PID, and device type. +func (c *Client) DevicesList(busID uint32) (*viipertypes.DevicesListResponse, error) { + return c.DevicesListCtx(context.Background(), busID) +} + +func (c *Client) DevicesListCtx(ctx context.Context, busID uint32) (*viipertypes.DevicesListResponse, error) { + pathParams := map[string]string{"id": fmt.Sprintf("%d", busID)} + const path = "bus/{id}/list" + raw, err := c.transport.DoCtx(ctx, path, nil, pathParams) + if err != nil { + return nil, err + } + return parse[viipertypes.DevicesListResponse](raw) +} + +func parse[T any](data string) (*T, error) { + if data == "" { + return nil, errors.New("empty response") + } + var problem viipertypes.APIError + if err := json.Unmarshal([]byte(data), &problem); err == nil && (problem.Status != 0 || problem.Title != "") { + return nil, &problem + } + var out T + dec := json.NewDecoder(bytes.NewReader([]byte(data))) + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} diff --git a/viiperclient/client_test.go b/viiperclient/client_test.go new file mode 100644 index 00000000..48c14414 --- /dev/null +++ b/viiperclient/client_test.go @@ -0,0 +1,117 @@ +package viiperclient_test + +import ( + "context" + "errors" + "testing" + + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + + "github.com/stretchr/testify/assert" +) + +// testClient constructs a client backed by a simple in-memory responder. +// responses maps full, already-filled paths (after path param substitution) to raw JSON payloads. +// If err is non-nil, every request returns that error, simulating dial failures. +func testClient(responses map[string]string, err error) *viiperclient.Client { + return viiperclient.WithTransport(viiperclient.NewMockTransport(func(path string, _ any, _ map[string]string) (string, error) { + if err != nil { + return "", err + } + if out, ok := responses[path]; ok { + return out, nil + } + return "", nil + })) +} + +func TestHighLevelClient(t *testing.T) { + tests := []struct { + name string + setup func(responses map[string]string) (err error) + call func(c *viiperclient.Client) (any, error) + wantErr string + assertFunc func(t *testing.T, got any) + }{ + { + name: "bus create success", + setup: func(responses map[string]string) error { responses["bus/create"] = `{"busId":42}`; return nil }, + call: func(c *viiperclient.Client) (any, error) { return c.BusCreate(42) }, + assertFunc: func(t *testing.T, got any) { + _, ok := got.(*viipertypes.BusCreateResponse) + assert.True(t, ok, "expected *viipertypes.BusCreateResponse type") + }, + }, + { + name: "bus create error structured", + setup: func(responses map[string]string) error { + responses["bus/create"] = `{"status":400,"title":"Bad Request","detail":"invalid busId"}` + return nil + }, + call: func(c *viiperclient.Client) (any, error) { return c.BusCreate(0) }, + wantErr: "400 Bad Request: invalid busId", + }, + { + name: "devices list", + setup: func(responses map[string]string) error { + responses["bus/{id}/list"] = `{"devices":[{"busId":1,"devId":"1","vid":"0x1234","pid":"0xabcd","type":"x"}]}` + return nil + }, + call: func(c *viiperclient.Client) (any, error) { return c.DevicesList(1) }, + assertFunc: func(t *testing.T, got any) { assert.NotNil(t, got) }, + }, + { + name: "transport failure", + setup: func(responses map[string]string) error { return errors.New("dial fail") }, + call: func(c *viiperclient.Client) (any, error) { return c.BusList() }, + wantErr: "dial fail", + }, + { + name: "blank response error", + setup: func(responses map[string]string) error { return nil }, + call: func(c *viiperclient.Client) (any, error) { return c.BusList() }, + wantErr: "empty response", + }, + { + name: "devices list empty", + setup: func(responses map[string]string) error { responses["bus/{id}/list"] = `{"devices":[]}`; return nil }, + call: func(c *viiperclient.Client) (any, error) { return c.DevicesList(1) }, + assertFunc: func(t *testing.T, got any) { + resp := got.(*viipertypes.DevicesListResponse) + assert.Len(t, resp.Devices, 0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + responses := map[string]string{} + errInject := error(nil) + if tt.setup != nil { + if e := tt.setup(responses); e != nil { + errInject = e + } + } + c := testClient(responses, errInject) + got, err := tt.call(c) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + assert.NoError(t, err) + if tt.assertFunc != nil { + tt.assertFunc(t, got) + } + }) + } +} + +func TestContextCancellation(t *testing.T) { + c := viiperclient.WithTransport(viiperclient.NewTransport("127.0.0.1:9")) // address irrelevant due to early cancel + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := c.BusListCtx(ctx) + assert.Error(t, err) +} diff --git a/viiper/pkg/apiclient/stream.go b/viiperclient/stream.go similarity index 81% rename from viiper/pkg/apiclient/stream.go rename to viiperclient/stream.go index 4fd1fd19..2e6b6ea0 100644 --- a/viiper/pkg/apiclient/stream.go +++ b/viiperclient/stream.go @@ -1,197 +1,218 @@ -package apiclient - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "net" - "sync" - "time" - - apitypes "viiper/pkg/apitypes" -) - -// DeviceStream represents a bidirectional connection to a device stream. -type DeviceStream struct { - conn net.Conn - BusID uint32 - DevID string - closed bool - - readCancel context.CancelFunc - readMu sync.Mutex -} - -// OpenStream connects to an existing device's stream channel. -// The device must already exist on the bus (use DeviceAdd first). -func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*DeviceStream, error) { - addr := c.transport.addr - if c.transport.mock != nil { - return nil, fmt.Errorf("stream connections not supported with mock transport") - } - - d := &net.Dialer{Timeout: c.transport.cfg.DialTimeout} - conn, err := d.DialContext(ctx, "tcp", addr) - if err != nil { - return nil, fmt.Errorf("dial: %w", err) - } - - streamPath := fmt.Sprintf("bus/%d/%s\n", busID, devID) - if _, err := conn.Write([]byte(streamPath)); err != nil { - conn.Close() - return nil, fmt.Errorf("write stream path: %w", err) - } - - ds := &DeviceStream{ - conn: conn, - BusID: busID, - DevID: devID, - } - return ds, nil -} - -// AddDeviceAndConnect creates a device on the specified bus and immediately connects to its stream. -// This is a convenience wrapper that combines DeviceAdd + OpenStream in one call. -func (c *Client) AddDeviceAndConnect(ctx context.Context, busID uint32, deviceType string) (*DeviceStream, *apitypes.DeviceAddResponse, error) { - resp, err := c.DeviceAddCtx(ctx, busID, deviceType) - if err != nil { - return nil, nil, err - } - - var devID string - _, err = fmt.Sscanf(resp.ID, "%d-%s", &busID, &devID) - if err != nil { - return nil, resp, fmt.Errorf("parse device ID: %w", err) - } - - stream, err := c.OpenStream(ctx, busID, devID) - if err != nil { - return nil, resp, err - } - - return stream, resp, nil -} - -// Write sends raw bytes to the device stream (client → device input). -func (s *DeviceStream) Write(data []byte) (int, error) { - if s.closed { - return 0, fmt.Errorf("stream closed") - } - return s.conn.Write(data) -} - -// WriteBinary marshals and sends a BinaryMarshaler to the device stream. -// This is the preferred way to send device input (e.g., xbox360.InputState, keyboard.InputState). -func (s *DeviceStream) WriteBinary(v encoding.BinaryMarshaler) error { - if s.closed { - return fmt.Errorf("stream closed") - } - data, err := v.MarshalBinary() - if err != nil { - return fmt.Errorf("marshal: %w", err) - } - _, err = s.conn.Write(data) - return err -} - -// Read receives raw bytes from the device stream (device → client feedback). -// For event-driven reading, use StartReading() instead to avoid blocking/polling. -func (s *DeviceStream) Read(buf []byte) (int, error) { - if s.closed { - return 0, fmt.Errorf("stream closed") - } - return s.conn.Read(buf) -} - -// StartReading begins asynchronously reading from the device stream in a background goroutine. -// You provide a decode function that reads exactly one message from the given *bufio.Reader -// and returns any value that implements encoding.BinaryUnmarshaler (the interface is only -// used for typing; StartReading does not call UnmarshalBinary itself). -// -// Example (xbox360 rumble, fixed 2 bytes): -// -// rumbleCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { -// var b [2]byte -// if _, err := io.ReadFull(r, b[:]); err != nil { return nil, err } -// msg := new(xbox360.XRumbleState) -// if err := msg.UnmarshalBinary(b[:]); err != nil { return nil, err } -// return msg, nil -// }) -func (s *DeviceStream) StartReading(ctx context.Context, chSize int, decode func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error)) (<-chan encoding.BinaryUnmarshaler, <-chan error) { - s.readMu.Lock() - defer s.readMu.Unlock() - - if s.readCancel != nil { - panic("StartReading called twice on the same stream") - } - - msgCh := make(chan encoding.BinaryUnmarshaler, chSize) - errCh := make(chan error, 1) - - readCtx, cancel := context.WithCancel(ctx) - s.readCancel = cancel - - go func() { - defer close(msgCh) - defer close(errCh) - defer cancel() - - r := bufio.NewReader(s.conn) - for { - select { - case <-readCtx.Done(): - errCh <- readCtx.Err() - return - default: - } - - if s.closed { - errCh <- io.EOF - return - } - - msg, err := decode(r) - if err != nil { - errCh <- err - return - } - - select { - case msgCh <- msg: - case <-readCtx.Done(): - errCh <- readCtx.Err() - return - } - } - }() - - return msgCh, errCh -} - -// SetReadDeadline sets the read deadline for the underlying connection. -func (s *DeviceStream) SetReadDeadline(t time.Time) error { - return s.conn.SetReadDeadline(t) -} - -// SetWriteDeadline sets the write deadline for the underlying connection. -func (s *DeviceStream) SetWriteDeadline(t time.Time) error { - return s.conn.SetWriteDeadline(t) -} - -// Close closes the stream connection and stops any background reading. -func (s *DeviceStream) Close() error { - if s.closed { - return nil - } - s.closed = true - - s.readMu.Lock() - if s.readCancel != nil { - s.readCancel() - } - s.readMu.Unlock() - - return s.conn.Close() -} +package viiperclient + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "log/slog" + "net" + "sync" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/viipertypes" +) + +// DeviceStream represents a bidirectional connection to a device stream. +type DeviceStream struct { + conn net.Conn + BusID uint32 + DevID string + closed bool + + readCancel context.CancelFunc + readMu sync.Mutex +} + +// OpenStream connects to an existing device's stream channel. +// The device must already exist on the bus (use DeviceAdd first). +func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*DeviceStream, error) { + addr := c.transport.addr + if c.transport.mock != nil { + return nil, fmt.Errorf("stream connections not supported with mock transport") + } + + d := &net.Dialer{Timeout: c.transport.cfg.DialTimeout} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("dial: %w", err) + } + + if tcpConn, ok := conn.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + slog.Warn("failed to set TCP_NODELAY", "error", err) + } + } + + if c.transport.cfg.Password != "" { + key, err := auth.DeriveKey(c.transport.cfg.Password) + if err != nil { + return nil, err + } + r := bufio.NewReader(conn) + clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, true) + if err != nil { + return nil, err + } + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + conn, err = auth.WrapConn(conn, sessionKey) + if err != nil { + conn.Close() // nolint + return nil, err + } + } + + streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) + if _, err := conn.Write([]byte(streamPath)); err != nil { + conn.Close() // nolint + return nil, fmt.Errorf("write stream path: %w", err) + } + + ds := &DeviceStream{ + conn: conn, + BusID: busID, + DevID: devID, + } + return ds, nil +} + +// AddDeviceAndConnect creates a device on the specified bus and immediately connects to its stream. +// This is a convenience wrapper that combines DeviceAdd + OpenStream in one call. +func (c *Client) AddDeviceAndConnect(ctx context.Context, busID uint32, deviceType string, o *device.CreateOptions) (*DeviceStream, *viipertypes.Device, error) { + resp, err := c.DeviceAddCtx(ctx, busID, deviceType, o) + if err != nil { + return nil, nil, err + } + + stream, err := c.OpenStream(ctx, busID, resp.DevID) + if err != nil { + return nil, resp, err + } + + return stream, resp, nil +} + +// Write sends raw bytes to the device stream (client → device input). +func (s *DeviceStream) Write(data []byte) (int, error) { + if s.closed { + return 0, fmt.Errorf("stream closed") + } + return s.conn.Write(data) +} + +// WriteBinary marshals and sends a BinaryMarshaler to the device stream. +// This is the preferred way to send device input (e.g., xbox360.InputState, keyboard.InputState). +func (s *DeviceStream) WriteBinary(v encoding.BinaryMarshaler) error { + if s.closed { + return fmt.Errorf("stream closed") + } + data, err := v.MarshalBinary() + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + _, err = s.conn.Write(data) + return err +} + +// Read receives raw bytes from the device stream (device → client feedback). +// For event-driven reading, use StartReading() instead to avoid blocking/polling. +func (s *DeviceStream) Read(buf []byte) (int, error) { + if s.closed { + return 0, fmt.Errorf("stream closed") + } + return s.conn.Read(buf) +} + +// StartReading begins asynchronously reading from the device stream in a background goroutine. +// You provide a decode function that reads exactly one message from the given *bufio.Reader +// and returns any value that implements encoding.BinaryUnmarshaler (the interface is only +// used for typing; StartReading does not call UnmarshalBinary itself). +// +// Example (xbox360 rumble, fixed 2 bytes): +// +// rumbleCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { +// var b [2]byte +// if _, err := io.ReadFull(r, b[:]); err != nil { return nil, err } +// msg := new(xbox360.XRumbleState) +// if err := msg.UnmarshalBinary(b[:]); err != nil { return nil, err } +// return msg, nil +// }) +func (s *DeviceStream) StartReading(ctx context.Context, chSize int, decode func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error)) (<-chan encoding.BinaryUnmarshaler, <-chan error) { + s.readMu.Lock() + defer s.readMu.Unlock() + + if s.readCancel != nil { + panic("StartReading called twice on the same stream") + } + + msgCh := make(chan encoding.BinaryUnmarshaler, chSize) + errCh := make(chan error, 1) + + readCtx, cancel := context.WithCancel(ctx) + s.readCancel = cancel + + go func() { + defer close(msgCh) + defer close(errCh) + defer cancel() + + r := bufio.NewReader(s.conn) + for { + select { + case <-readCtx.Done(): + errCh <- readCtx.Err() + return + default: + } + + if s.closed { + errCh <- io.EOF + return + } + + msg, err := decode(r) + if err != nil { + errCh <- err + return + } + + select { + case msgCh <- msg: + case <-readCtx.Done(): + errCh <- readCtx.Err() + return + } + } + }() + + return msgCh, errCh +} + +// SetReadDeadline sets the read deadline for the underlying connection. +func (s *DeviceStream) SetReadDeadline(t time.Time) error { + return s.conn.SetReadDeadline(t) +} + +// SetWriteDeadline sets the write deadline for the underlying connection. +func (s *DeviceStream) SetWriteDeadline(t time.Time) error { + return s.conn.SetWriteDeadline(t) +} + +// Close closes the stream connection and stops any background reading. +func (s *DeviceStream) Close() error { + if s.closed { + return nil + } + s.closed = true + + s.readMu.Lock() + if s.readCancel != nil { + s.readCancel() + } + s.readMu.Unlock() + + return s.conn.Close() +} diff --git a/viiperclient/stream_test.go b/viiperclient/stream_test.go new file mode 100644 index 00000000..6af6ef41 --- /dev/null +++ b/viiperclient/stream_test.go @@ -0,0 +1,296 @@ +package viiperclient_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "log/slog" + "net" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + htesting "github.com/Alia5/VIIPER/internal/_testing" + "github.com/Alia5/VIIPER/internal/log" + api "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/auth" + handler "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + "github.com/Alia5/VIIPER/virtualbus" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenStream_NotSupportedWithMockTransport(t *testing.T) { + c := testClient(map[string]string{}, nil) + _, err := c.OpenStream(context.Background(), 1, "1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported with mock transport") +} + +func TestAddDeviceAndConnect(t *testing.T) { + tests := []struct { + name string + setup func(responses map[string]string) error + wantDevice *viipertypes.Device + wantErrSubstr string + }{ + { + name: "success parse then stream error", + setup: func(responses map[string]string) error { + responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test"}` + return nil + }, + wantDevice: &viipertypes.Device{BusID: 42, DevID: "7", Vid: "0x1234", Pid: "0xabcd", Type: "test"}, + wantErrSubstr: "not supported with mock transport", + }, + { + name: "transport dial error", + setup: func(responses map[string]string) error { return errors.New("dial fail") }, + wantErrSubstr: "dial fail", + }, + { + name: "blank response error", + setup: func(responses map[string]string) error { return nil }, // no key => blank + wantErrSubstr: "empty response", + }, + { + name: "api error response", + setup: func(responses map[string]string) error { + responses["bus/{id}/add"] = `{"status":404,"title":"Not Found","detail":"bus 42 not found"}` + return nil + }, + wantErrSubstr: "bus 42 not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + responses := map[string]string{} + errInject := error(nil) + if e := tt.setup(responses); e != nil { + errInject = e + } + c := testClient(responses, errInject) + stream, resp, err := c.AddDeviceAndConnect(context.Background(), 42, "test", nil) + if tt.wantDevice != nil { + assert.Nil(t, stream) + require.NotNil(t, resp, "device response should be parsed") + assert.Equal(t, tt.wantDevice.DevID, resp.DevID) + assert.Equal(t, tt.wantDevice.BusID, resp.BusID) + assert.Equal(t, tt.wantDevice.Vid, resp.Vid) + assert.Equal(t, tt.wantDevice.Pid, resp.Pid) + assert.Equal(t, tt.wantDevice.Type, resp.Type) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrSubstr) + return + } + assert.Nil(t, resp) + assert.Nil(t, stream) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrSubstr) + }) + } +} + +func TestDeviceStream_Operations(t *testing.T) { + type operation func(t *testing.T, stream *viiperclient.DeviceStream) + + tests := []struct { + name string + busID uint32 + customRegistration bool + op operation + }{ + { + name: "read deadline timeout", + busID: 201, + op: func(t *testing.T, stream *viiperclient.DeviceStream) { + // Force immediate timeout by setting deadline in the past. + require.NoError(t, stream.SetReadDeadline(time.Now().Add(-10*time.Millisecond))) + buf := make([]byte, 2) + _, readErr := stream.Read(buf) + assert.Error(t, readErr) + if ne, ok := readErr.(net.Error); ok { + assert.True(t, ne.Timeout(), "expected timeout error") + } else { + assert.Fail(t, "expected net.Error timeout, got %v", readErr) + } + _ = stream.Close() + }, + }, + { + name: "closed stream read/write errors", + busID: 202, + customRegistration: true, + op: func(t *testing.T, stream *viiperclient.DeviceStream) { + require.NoError(t, stream.Close()) + buf := make([]byte, 1) + _, rErr := stream.Read(buf) + assert.Error(t, rErr) + assert.Contains(t, rErr.Error(), "stream closed") + _, wErr := stream.Write([]byte{0x01}) + assert.Error(t, wErr) + assert.Contains(t, wErr.Error(), "stream closed") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usbSrv := usb.New(usb.ServerConfig{Addr: "127.0.0.1:0"}, slog.Default(), log.NewRaw(nil)) + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + _ = ln.Close() + apiCfg := api.ServerConfig{Addr: addr, DeviceHandlerConnectTimeout: 500 * time.Millisecond} + apiSrv := api.New(usbSrv, addr, apiCfg, slog.Default()) + r := apiSrv.Router() + if tt.customRegistration { + testReg := htesting.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, + func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { + <-time.After(50 * time.Millisecond) + return nil + }, + ) + api.RegisterDevice("xbox360", testReg) + } + r.Register("bus/{id}/add", handler.BusDeviceAdd(usbSrv, apiSrv)) + r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) + require.NoError(t, apiSrv.Start()) + defer apiSrv.Close() //nolint:errcheck + + b, err := virtualbus.NewWithBusID(tt.busID) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(b)) + + c := viiperclient.New(addr) + stream, devResp, err := c.AddDeviceAndConnect(context.Background(), tt.busID, "xbox360", nil) + require.NoError(t, err) + require.NotNil(t, devResp) + require.NotNil(t, stream) + + tt.op(t, stream) + }) + } +} + +func TestEncryptedStream(t *testing.T) { + type testCase struct { + name string + password string + serverHandler func(t *testing.T, conn net.Conn) + streamPath string + expectedErr error + } + + echoStreamHandler := func(t *testing.T, conn net.Conn) { + defer conn.Close() //nolint:errcheck + r := bufio.NewReader(conn) + + key, err := auth.DeriveKey("test123") + assert.NoError(t, err) + + clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, false) + if err != nil { + var apiErr viipertypes.APIError + if errors.As(err, &apiErr) { + b, err := json.Marshal(apiErr) + if err != nil { + slog.Error("failed to marshal api error", "error", err) + return + } + _, _ = conn.Write(append(b, '\n')) + return + } + return + } + + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + secureConn, err := auth.WrapConn(conn, sessionKey) + assert.NoError(t, err) + + rr := bufio.NewReader(secureConn) + line, err := rr.ReadString('\x00') + if err != nil { + return + } + + assert.Equal(t, "bus/1/1\x00", line) + } + + cases := []testCase{ + { + name: "success", + password: "test123", + serverHandler: echoStreamHandler, + streamPath: "bus/1/1", + }, + { + name: "wrong password", + password: "wrongpass", + serverHandler: echoStreamHandler, + expectedErr: errors.New("401 Unauthorized: invalid password"), + }, + { + name: "bad handshake response", + password: "test123", + serverHandler: func(t *testing.T, conn net.Conn) { + defer conn.Close() //nolint:errcheck + _, _ = conn.Write([]byte("NO\x00" + strings.Repeat("x", 32))) + }, + expectedErr: errors.New(""), + }, + { + name: "server closes early", + password: "test123", + serverHandler: func(t *testing.T, conn net.Conn) { + _ = conn.Close() + }, + expectedErr: errors.New(""), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.NoError(t, err) + defer ln.Close() // nolint + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + tc.serverHandler(t, conn) + }() + + cfg := &viiperclient.Config{ + DialTimeout: 3 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + Password: tc.password, + } + client := viiperclient.NewWithConfig(ln.Addr().String(), cfg) + stream, err := client.OpenStream(context.Background(), 1, "1") + + if tc.expectedErr != nil { + assert.Error(t, err) + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.NotNil(t, stream) + _ = stream.Close() + }) + } +} diff --git a/viiper/pkg/apiclient/transport.go b/viiperclient/transport.go similarity index 53% rename from viiper/pkg/apiclient/transport.go rename to viiperclient/transport.go index ff34ecf6..646bb5d4 100644 --- a/viiper/pkg/apiclient/transport.go +++ b/viiperclient/transport.go @@ -1,14 +1,19 @@ -package apiclient +package viiperclient import ( "bufio" "context" "encoding/json" "fmt" + "io" + "log/slog" "net" "net/url" "strings" "time" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" ) // Config controls low-level transport behavior such as timeouts. @@ -16,6 +21,7 @@ type Config struct { DialTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration + Password string } func defaultConfig() Config { @@ -26,8 +32,12 @@ func defaultConfig() Config { } } -// Transport is the low-level VIIPER line protocol implementation used by higher-level API clients. -// It builds the command line as: " \n" with optional URL-escaped path params. +// Transport is the low-level VIIPER management protocol implementation used by higher-level API clients. +// Request framing: `[ SP ] \x00` (null terminator). The payload may contain any data +// including newlines (e.g. pretty JSON, binary) because only \x00 ends the request. +// Response framing: server writes a single JSON (or empty success) line terminated by `\n` and then +// closes the connection. We therefore read until EOF (connection close) and trim a single trailing +// newline if present. Embedded newlines in the response (future multi-line responses) are preserved. type Transport struct { addr string mock func(path string, payload any, pathParams map[string]string) (string, error) @@ -37,6 +47,12 @@ type Transport struct { // NewTransport creates a new low-level transport. func NewTransport(addr string) *Transport { return NewTransportWithConfig(addr, nil) } +func NewTransportWithPassword(addr, password string) *Transport { + cfg := defaultConfig() + cfg.Password = password + return NewTransportWithConfig(addr, &cfg) +} + // NewTransportWithConfig creates a new low-level transport with optional timeouts configuration. func NewTransportWithConfig(addr string, cfg *Config) *Transport { c := defaultConfig() @@ -62,14 +78,14 @@ func NewMockTransport(responder func(path string, payload any, pathParams map[st // string -> UTF-8 bytes // struct/other -> JSON marshaled bytes // nil -> no payload appended -func (c *Transport) Do(path string, payload any, pathParams map[string]string) (string, error) { - return c.DoCtx(context.Background(), path, payload, pathParams) +func (t *Transport) Do(path string, payload any, pathParams map[string]string) (string, error) { + return t.DoCtx(context.Background(), path, payload, pathParams) } // DoCtx is like Do but honors the provided context and configured timeouts. -func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathParams map[string]string) (string, error) { - if c.mock != nil { - return c.mock(path, payload, pathParams) +func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathParams map[string]string) (string, error) { + if t.mock != nil { + return t.mock(path, payload, pathParams) } fullPath := fillPath(path, pathParams) var lineBytes []byte @@ -81,35 +97,58 @@ func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if err := ctx.Err(); err != nil { return "", fmt.Errorf("dial: %w", err) } - d := &net.Dialer{Timeout: c.cfg.DialTimeout} - conn, err := d.DialContext(ctx, "tcp", c.addr) + d := &net.Dialer{Timeout: t.cfg.DialTimeout} + conn, err := d.DialContext(ctx, "tcp", t.addr) if err != nil { return "", fmt.Errorf("dial: %w", err) } - defer conn.Close() - if c.cfg.WriteTimeout > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) - } - if _, err := conn.Write(append(lineBytes, '\n')); err != nil { - return "", fmt.Errorf("write: %w", err) + defer conn.Close() //nolint:errcheck + + if tcpConn, ok := conn.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + slog.Warn("failed to set TCP_NODELAY", "error", err) + } } - r := bufio.NewReader(conn) - if c.cfg.ReadTimeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(c.cfg.ReadTimeout)) + + if t.cfg.WriteTimeout > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(t.cfg.WriteTimeout)) } - resp, err := r.ReadString('\n') - if err != nil { - if len(resp) == 0 { // no data received - return "", fmt.Errorf("read: %w", err) + + if t.cfg.Password != "" { + key, err := auth.DeriveKey(t.cfg.Password) + if err != nil { + return "", err + } + r := bufio.NewReader(conn) + clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, true) + if err != nil { + + if strings.Contains(err.Error(), "read handshake response: EOF") { + return "", apierror.ErrUnauthorized("invalid password") + } + return "", err + } + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + conn, err = auth.WrapConn(conn, sessionKey) + if err != nil { + conn.Close() // nolint + return "", err } } - if len(resp) == 0 { - return "", nil + + if _, err := conn.Write(append(lineBytes, '\x00')); err != nil { + return "", fmt.Errorf("write: %w", err) } - if resp[len(resp)-1] == '\n' { - resp = resp[:len(resp)-1] + if t.cfg.ReadTimeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(t.cfg.ReadTimeout)) } - return resp, nil + respBytes, err := io.ReadAll(conn) + if err != nil && len(respBytes) == 0 { + return "", fmt.Errorf("read: %w", err) + } + resp := string(respBytes) + + return strings.TrimSuffix(resp, "\n"), nil } func fillPath(pattern string, params map[string]string) string { diff --git a/viiperclient/transport_test.go b/viiperclient/transport_test.go new file mode 100644 index 00000000..346a0da4 --- /dev/null +++ b/viiperclient/transport_test.go @@ -0,0 +1,274 @@ +package viiperclient_test + +import ( + "bufio" + "encoding/json" + "errors" + "log/slog" + "net" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" + + "github.com/stretchr/testify/assert" +) + +func startTestServer(t *testing.T, response string) (addr string, gotReqLine *string, closeFn func()) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.NoError(t, err) + got := new(string) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() //nolint:errcheck + var buf []byte + var tmp [1]byte + for { + err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if err != nil { + slog.Error("Failed to set connectionReadDeadline", "error", err) + } + _, rerr := conn.Read(tmp[:]) + if rerr != nil { + break + } + b := tmp[0] + buf = append(buf, b) + if b == '\x00' { + break + } + } + *got = string(buf) + if response != "" { + _, _ = conn.Write([]byte(response)) + } + }() + return ln.Addr().String(), got, func() { _ = ln.Close() } +} + +func TestTransportPayloadEncoding(t *testing.T) { + type S struct { + A int `json:"a"` + B string `json:"b"` + } + type testCase struct { + name string + payload any + expectedLine string // full request including terminator (for non-struct where deterministic) + validateJSON bool // whether to JSON-unmarshal payload part instead of direct equality + } + + cases := []testCase{ + { + name: "nil payload", + payload: nil, + expectedLine: "echo\x00", + }, + { + name: "empty string payload", + payload: "", + expectedLine: "echo\x00", + }, + { + name: "bytes payload", + payload: []byte("rawbytes"), + expectedLine: "echo rawbytes\x00", + }, + { + name: "string payload", + payload: "hello world", + expectedLine: "echo hello world\x00", + }, + { + name: "string payload with newline", + payload: "multi\nline", + expectedLine: "echo multi\nline\x00", + }, + { + name: "struct payload json marshaled", + payload: S{A: 7, B: "zzz"}, + validateJSON: true, + }, + { + name: "multi-line JSON string payload", + payload: "{\n\"x\":1\n}", + expectedLine: "echo {\n\"x\":1\n}\x00", + }, + } + + for _, tc := range cases { + addr, got, closeFn := startTestServer(t, "ok\n") + client := viiperclient.NewTransport(addr) + out, err := client.Do("echo", tc.payload, nil) + closeFn() + assert.NoError(t, err, tc.name) + assert.Equal(t, "ok", out, tc.name) + + if tc.validateJSON { + b, merr := json.Marshal(tc.payload) + assert.NoError(t, merr, tc.name) + expectedPrefix := "echo " + string(b) + "\x00" + assert.Equal(t, expectedPrefix, *got, tc.name) + line := strings.TrimSuffix(strings.TrimPrefix(*got, "echo "), "\x00") + var s S + assert.NoError(t, json.Unmarshal([]byte(line), &s), tc.name) + assert.Equal(t, tc.payload, s, tc.name) + continue + } + + assert.Equal(t, tc.expectedLine, *got, tc.name) + } +} + +func TestTransportMultiLineResponse(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.NoError(t, err) + defer ln.Close() // nolint + + resp := "{\n \"a\": 1,\n \"b\": 2\n}\n" // multi-line + trailing newline + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 0, 128) + tmp := make([]byte, 1) + for { + err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if err != nil { + slog.Error("Failed to set connectionReadDeadline", "error", err) + } + _, err = conn.Read(tmp) + if err != nil { + break + } + b := tmp[0] + buf = append(buf, b) // nolint + if b == '\x00' { // end of request + break + } + } + _, _ = conn.Write([]byte(resp)) + conn.Close() // nolint + }() + + client := viiperclient.NewTransport(ln.Addr().String()) + out, err := client.Do("echo", nil, nil) + assert.NoError(t, err) + assert.Equal(t, "{\n \"a\": 1,\n \"b\": 2\n}", out) +} + +func TestEncryptedTransport(t *testing.T) { + type testCase struct { + name string + password string + serverHandler func(t *testing.T, conn net.Conn) + line string + expectedErr error + } + + echoHandler := func(t *testing.T, conn net.Conn) { + defer conn.Close() //nolint:errcheck + r := bufio.NewReader(conn) + + key, err := auth.DeriveKey("test123") + assert.NoError(t, err) + + clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, false) + if err != nil { + var apiErr viipertypes.APIError + if errors.As(err, &apiErr) { + b, err := json.Marshal(apiErr) + if err != nil { + slog.Error("failed to marshal api error", "error", err) + return + } + _, _ = conn.Write(append(b, '\n')) + return + } + return + } + + sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) + secureConn, err := auth.WrapConn(conn, sessionKey) + assert.NoError(t, err) + + rr := bufio.NewReader(secureConn) + line, err := rr.ReadString('\x00') + if err != nil { + return + } + + _, err = secureConn.Write([]byte(line)) + assert.NoError(t, err) + } + + cases := []testCase{ + { + name: "success", + password: "test123", + serverHandler: echoHandler, + line: "echo hi", + }, + { + name: "wrong password", + password: "wrongpass", + serverHandler: echoHandler, + expectedErr: errors.New("401 Unauthorized: invalid password"), + }, + { + name: "bad handshake response", + password: "test123", + serverHandler: func(t *testing.T, conn net.Conn) { + defer conn.Close() //nolint:errcheck + _, _ = conn.Write([]byte("NO\x00" + strings.Repeat("x", 32))) + }, + expectedErr: errors.New(""), + }, + { + name: "server closes early", + password: "test123", + serverHandler: func(t *testing.T, conn net.Conn) { + _ = conn.Close() + }, + expectedErr: errors.New(""), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.NoError(t, err) + defer ln.Close() // nolint + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + tc.serverHandler(t, conn) + }() + + client := viiperclient.NewTransportWithPassword(ln.Addr().String(), tc.password) + path, payload, _ := strings.Cut(tc.line, " ") + out, err := client.Do(path, payload, nil) + + if tc.expectedErr != nil { + assert.Error(t, err) + assert.ErrorContains(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + resp := strings.TrimSuffix(out, "\x00") + assert.Equal(t, tc.line, resp) + }) + } +} diff --git a/viipertypes/structs.go b/viipertypes/structs.go new file mode 100644 index 00000000..d6decafd --- /dev/null +++ b/viipertypes/structs.go @@ -0,0 +1,212 @@ +package viipertypes + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "golang.org/x/exp/constraints" +) + +// APIError represents an RFC 7807 (problem+json) error response. +type APIError struct { + // Status is the HTTP-style status code (e.g., 400, 404, 500) + Status int `json:"status"` + // Title is a short, human-readable summary of the problem type + Title string `json:"title"` + // Detail is a human-readable explanation specific to this occurrence + Detail string `json:"detail"` +} + +func (e APIError) Error() string { + if e.Status == 0 && e.Title == "" { + return "unknown error" + } + if e.Status == 0 { + return fmt.Sprintf("%s: %s", e.Title, e.Detail) + } + return fmt.Sprintf("%d %s: %s", e.Status, e.Title, e.Detail) +} + +// -- + +type PingResponse struct { + Server string `json:"server"` + Version string `json:"version"` +} + +type BusListResponse struct { + Buses []uint32 `json:"buses"` +} + +type BusCreateResponse struct { + BusID uint32 `json:"busId"` +} + +type BusRemoveResponse struct { + BusID uint32 `json:"busId"` +} + +type Device struct { + BusID uint32 `json:"busId"` + DevID string `json:"devId"` + Vid string `json:"vid"` + Pid string `json:"pid"` + Type string `json:"type"` + DeviceSpecific map[string]any `json:"deviceSpecific"` +} + +type DevicesListResponse struct { + Devices []Device `json:"devices"` +} + +type DeviceRemoveResponse struct { + BusID uint32 `json:"busId"` + DevID string `json:"devId"` +} + +type DeviceCreateRequest struct { + Type *string `json:"type"` + IDVendor *uint16 `json:"idVendor,omitempty"` + IDProduct *uint16 `json:"idProduct,omitempty"` + DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"` +} + +type DualSenseTrafficSetRequest struct { + Enabled bool `json:"enabled"` + Clear bool `json:"clear"` +} + +type DualSenseTrafficEvent struct { + TimeUTC string `json:"timeUtc"` + Direction string `json:"direction"` + Source string `json:"source"` + ReportType string `json:"reportType,omitempty"` + ReportID string `json:"reportId,omitempty"` + Request string `json:"request,omitempty"` + Value string `json:"value,omitempty"` + Index string `json:"index,omitempty"` + Length int `json:"length"` + Hex string `json:"hex,omitempty"` + Summary string `json:"summary,omitempty"` + DecodedOutput string `json:"decodedOutput,omitempty"` +} + +type DualSenseTrafficResponse struct { + Enabled bool `json:"enabled"` + Count int `json:"count"` + Events []DualSenseTrafficEvent `json:"events,omitempty"` +} + +// UnmarshalJSON implements custom unmarshaling to accept both uint16 and hex string formats +// for idVendor and idProduct (e.g., "0x12ac" or 4780). +func (d *DeviceCreateRequest) UnmarshalJSON(data []byte) error { + // Parse into a temporary structure with flexible types + var raw struct { + Type *string `json:"type"` + IDVendor any `json:"idVendor,omitempty"` + IDProduct any `json:"idProduct,omitempty"` + DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + d.Type = raw.Type + + if raw.IDVendor != nil { + val, err := parseNumberOrHex[uint16](raw.IDVendor) + if err != nil { + return fmt.Errorf("idVendor: %w", err) + } + d.IDVendor = &val + } + + if raw.IDProduct != nil { + val, err := parseNumberOrHex[uint16](raw.IDProduct) + if err != nil { + return fmt.Errorf("idProduct: %w", err) + } + d.IDProduct = &val + } + + d.DeviceSpecific = raw.DeviceSpecific + + return nil +} + +// parseUint16OrHex accepts either a JSON number or a hex string like "0x12ac" +func parseNumberOrHex[N constraints.Integer](v any) (N, error) { + var zero N + switch val := v.(type) { + case float64: + var minVal, maxVal float64 + switch any(zero).(type) { + case int8: + minVal, maxVal = math.MinInt8, math.MaxInt8 + case int16: + minVal, maxVal = math.MinInt16, math.MaxInt16 + case int32: + minVal, maxVal = math.MinInt32, math.MaxInt32 + case int64, int: + minVal, maxVal = math.MinInt64, math.MaxInt64 + case uint8: + minVal, maxVal = 0, math.MaxUint8 + case uint16: + minVal, maxVal = 0, math.MaxUint16 + case uint32: + minVal, maxVal = 0, math.MaxUint32 + case uint64, uint: + minVal, maxVal = 0, math.MaxUint64 + default: + return zero, fmt.Errorf("unsupported integer type %T", zero) + } + if val < minVal || val > maxVal { + return zero, fmt.Errorf("value %v out of range for type %T", val, zero) + } + return N(val), nil + case string: + s := strings.TrimSpace(val) + base := 10 + if strings.HasPrefix(strings.ToLower(s), "0x") { + s = s[2:] + base = 16 + } else if len(s) > 0 { + if strings.ContainsAny(s, "abcdefABCDEF") { + base = 16 + } + } + var bitSize int + switch any(zero).(type) { + case int8, uint8: + bitSize = 8 + case int16, uint16: + bitSize = 16 + case int32, uint32: + bitSize = 32 + case int64, uint64, int, uint: + bitSize = 64 + default: + return zero, fmt.Errorf("unsupported integer type %T", zero) + } + switch any(zero).(type) { + case int, int8, int16, int32, int64: + parsed, err := strconv.ParseInt(s, base, bitSize) + if err != nil { + return zero, fmt.Errorf("invalid hex/numeric string %q: %w", val, err) + } + return N(parsed), nil + default: + parsed, err := strconv.ParseUint(s, base, bitSize) + if err != nil { + return zero, fmt.Errorf("invalid hex/numeric string %q: %w", val, err) + } + return N(parsed), nil + } + default: + return zero, fmt.Errorf("expected number or hex string, got %T", v) + } +} diff --git a/virtualbus/virtualbus.go b/virtualbus/virtualbus.go new file mode 100644 index 00000000..95918cc0 --- /dev/null +++ b/virtualbus/virtualbus.go @@ -0,0 +1,244 @@ +// Package virtualbus manages USB bus topology and auto-assigns device addresses. +package virtualbus + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +const basepath = "/sys/devices/pci0000:00/0000:00:08.1/0000:00:04:00.3/usb" + +var ( + allocatedBusIds = make(map[uint32]bool) + globalMtx sync.Mutex +) + +// VirtualBus manages USB bus topology and auto-assigns device addresses. +type VirtualBus struct { + mtx sync.Mutex + busID uint32 + nextDevID uint32 + allocatedDevIDs map[uint32]bool + devices []busDevice + emptyCtx context.Context + emptyCancel context.CancelFunc +} + +// DeviceMeta exposes a registered device and its metadata for external queries. +type DeviceMeta struct { + Dev usb.Device + Meta usbip.ExportMeta +} + +// New creates a new VirtualBus instance with a unique auto-assigned bus number. +func New(busID uint32) *VirtualBus { + globalMtx.Lock() + defer globalMtx.Unlock() + + allocatedBusIds[busID] = true + + return &VirtualBus{ + busID: busID, + nextDevID: 0, + allocatedDevIDs: make(map[uint32]bool), + } +} + +// NewWithBusID creates a new VirtualBus instance starting at a specific bus number. +// Returns an error if the bus number is already allocated. +func NewWithBusID(busID uint32) (*VirtualBus, error) { + globalMtx.Lock() + defer globalMtx.Unlock() + + if allocatedBusIds[busID] { + return nil, fmt.Errorf("bus number %d already allocated", busID) + } + allocatedBusIds[busID] = true + + return &VirtualBus{ + busID: busID, + nextDevID: 0, + allocatedDevIDs: make(map[uint32]bool), + }, nil +} + +// Add registers a device using a descriptor provider implemented by the device. +// This is a convenience wrapper so callers can simply do "bus.Add(dev)". +// The device must implement a method: +// +// GetDeviceDescriptor() DeviceDescriptorStruct +// +// which returns a static descriptor that will be used for bus registration. +// Returns a context containing the device's lifecycle and metadata (use GetDeviceMeta to extract). +func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { + vb.mtx.Lock() + defer vb.mtx.Unlock() + + if vb.emptyCancel != nil { + vb.emptyCancel() + vb.emptyCancel = nil + vb.emptyCtx = nil + } + + for _, d := range vb.devices { + if d.dev == dev { + return nil, fmt.Errorf("device already registered on this bus") + } + } + busID := vb.busID + var devID uint32 + for i := uint32(1); ; i++ { + if !vb.allocatedDevIDs[i] { + devID = i + vb.allocatedDevIDs[i] = true + break + } + } + + busDevID := fmt.Sprintf("%d-%d", busID, devID) + path := fmt.Sprintf("%s%d/%s", basepath, busID, busDevID) + + var meta usbip.ExportMeta + copy(meta.Path[:], path) + copy(meta.USBBusID[:], busDevID) + meta.BusID = busID + meta.DevID = devID + connTimer := time.NewTimer(0) + + ctx, cancel := context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, device.ExportMetaKey, &meta) + ctx = context.WithValue(ctx, device.ConnTimerKey, connTimer) + + vb.devices = append(vb.devices, busDevice{dev: dev, meta: meta, ctx: ctx, cancel: cancel}) + return ctx, nil +} + +// GetAllDeviceMetas returns a copy of all registered devices with their descriptors and export metadata. +func (vb *VirtualBus) GetAllDeviceMetas() []DeviceMeta { + vb.mtx.Lock() + defer vb.mtx.Unlock() + out := make([]DeviceMeta, 0, len(vb.devices)) + for _, d := range vb.devices { + out = append(out, DeviceMeta{Dev: d.dev, Meta: d.meta}) + } + return out +} + +// BusID returns the bus number for this VirtualBus. +func (vb *VirtualBus) BusID() uint32 { + vb.mtx.Lock() + defer vb.mtx.Unlock() + return vb.busID +} + +// Devices returns all devices currently attached to this bus. +func (vb *VirtualBus) Devices() []usb.Device { + vb.mtx.Lock() + defer vb.mtx.Unlock() + out := make([]usb.Device, 0, len(vb.devices)) + for _, d := range vb.devices { + out = append(out, d.dev) + } + return out +} + +func (vb *VirtualBus) GetBusEmptyContext() context.Context { + vb.mtx.Lock() + defer vb.mtx.Unlock() + if len(vb.devices) > 0 { + return nil + } + if vb.emptyCtx == nil { + vb.emptyCtx, vb.emptyCancel = context.WithCancel(context.Background()) + } + return vb.emptyCtx +} + +// RemoveDeviceByID removes a device by its ID (e.g., "1"). +// Returns error if not found. +func (vb *VirtualBus) RemoveDeviceByID(deviceID string) error { + vb.mtx.Lock() + defer vb.mtx.Unlock() + for i, d := range vb.devices { + if fmt.Sprintf("%d", d.meta.DevID) == deviceID { + if d.cancel != nil { + d.cancel() + } + delete(vb.allocatedDevIDs, d.meta.DevID) + vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) + + return nil + } + } + return fmt.Errorf("device with id %s not found on bus %d", deviceID, vb.busID) +} + +// Remove unregisters a device from the bus. +// This removes the device from the internal list; it does not currently free +// the global bus number. Removal should be used for dynamic device teardown +// during runtime. +func (vb *VirtualBus) Remove(dev usb.Device) error { + vb.mtx.Lock() + defer vb.mtx.Unlock() + for i, d := range vb.devices { + if d.dev == dev { + if d.cancel != nil { + d.cancel() + } + delete(vb.allocatedDevIDs, d.meta.DevID) + vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) + return nil + } + } + return fmt.Errorf("device not found") +} + +// Close frees the bus number allocated to this VirtualBus, allowing it to be +// reused. After calling Close, this VirtualBus instance should not be used. +func (vb *VirtualBus) Close() error { + vb.mtx.Lock() + defer vb.mtx.Unlock() + + for i := range vb.devices { + if vb.devices[i].cancel != nil { + vb.devices[i].cancel() + } + vb.devices[i].ctx = nil + vb.devices[i].cancel = nil + } + + globalMtx.Lock() + defer globalMtx.Unlock() + + delete(allocatedBusIds, vb.busID) + return nil +} + +// Note: Contexts are owned by the bus and created at Add(). They are cancelled +// when a device is removed or the bus is closed. + +// GetDeviceContext returns the context for a specific device. +// Returns nil if the device is not found or has no active context. +func (vb *VirtualBus) GetDeviceContext(dev usb.Device) context.Context { + vb.mtx.Lock() + defer vb.mtx.Unlock() + for i := range vb.devices { + if vb.devices[i].dev == dev { + return vb.devices[i].ctx + } + } + return nil +} + +type busDevice struct { + dev usb.Device + meta usbip.ExportMeta + ctx context.Context + cancel context.CancelFunc +} diff --git a/vsc.code-workspace b/vsc.code-workspace index 2732e783..5237a91e 100644 --- a/vsc.code-workspace +++ b/vsc.code-workspace @@ -1,8 +1,5 @@ { "folders": [ - { - "path": "viiper" - }, { "path": "docs" }, @@ -24,5 +21,8 @@ "recommendations": [ "golang.go" ] + }, + "settings": { + "powershell.cwd": "root" } } \ No newline at end of file