From 74bdce5617cd7314705fff2cd161712d6065dac4 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 14:16:20 +0100 Subject: [PATCH 001/298] Update license badge link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 214a4323..c858d1e9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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) +[![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE.txt) [![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) From 23a3fd0faaadb8587118a3e142aa344a89a40fc1 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 14:21:30 +0100 Subject: [PATCH 002/298] Update README.md / docs --- README.md | 9 +++++++-- docs/index.md | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c858d1e9..8c8553d7 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,13 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. +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. + +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. ### ✨ Features diff --git a/docs/index.md b/docs/index.md index 74ea2274..251a55a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,8 +19,13 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. +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. + +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 From 545610a2d47c089d42d8d392f5dfdf18cf937e0b Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 14:52:06 +0100 Subject: [PATCH 003/298] Fix API server being optional --- docs/cli/configuration.md | 6 +----- docs/cli/server.md | 16 +++------------- viiper/internal/cmd/server.go | 32 +++++++++++++++++--------------- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0d3d8cc2..59a358a9 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -23,7 +23,7 @@ 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_CONNECTION_TIMEOUT` | `--connection-timeout` | `30s` | Connection operation timeout | @@ -90,10 +90,6 @@ Proxy: } ``` -Notes: - -- The configuration file never contains secrets; environment variables or external secret stores are recommended for sensitive values. - ## Configuration Examples ### Using Environment Variables diff --git a/docs/cli/server.md b/docs/cli/server.md index f97173c7..0c2116c7 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -15,7 +15,7 @@ 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. **API Server** - Management API for device/bus control ## Options @@ -34,7 +34,7 @@ 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` @@ -44,9 +44,6 @@ API server listen address. If empty, the API server is disabled. ```bash # Enable API on custom port viiper server --api.addr=:8080 - -# Disable API server -viiper server --api.addr= ``` ### `--api.device-handler-timeout` @@ -85,14 +82,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,6 +111,7 @@ 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`. diff --git a/viiper/internal/cmd/server.go b/viiper/internal/cmd/server.go index 3575fee2..9901015a 100644 --- a/viiper/internal/cmd/server.go +++ b/viiper/internal/cmd/server.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "fmt" "log/slog" "os" "os/signal" @@ -41,22 +42,23 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { case <-usbSrv.Ready(): } - var apiSrv *api.Server - if s.ApiServerConfig.Addr != "" { - apiSrv = api.New(usbSrv, s.ApiServerConfig.Addr, s.ApiServerConfig, logger) + 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).") + } - 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 - } + 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 { From 96709555e2a3f5677415f304be279a95e3dabda4 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 14:52:17 +0100 Subject: [PATCH 004/298] Update docs --- docs/cli/codegen.md | 4 +++- docs/cli/proxy.md | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index abef8dbf..b59708ed 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -11,11 +11,13 @@ viiper codegen [flags] ## 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 SDKs with: + - Management API clients - Device-agnostic stream wrappers - Per-device encode/decode functions diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index 9d766a83..626c6720 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -27,7 +27,7 @@ Proxy listen address (where clients connect). **Example:** ```bash -viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3241 +viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3240 ``` ### `--upstream` @@ -39,7 +39,7 @@ viiper proxy --listen-addr=:9000 --upstream=192.168.1.100:3241 **Example:** ```bash -viiper proxy --upstream=192.168.1.100:3241 +viiper proxy --upstream=192.168.1.100:3240 ``` ### `--connection-timeout` @@ -52,7 +52,7 @@ Connection timeout for proxy operations. **Example:** ```bash -viiper proxy --upstream=192.168.1.100:3241 --connection-timeout=60s +viiper proxy --upstream=192.168.1.100:3240 --connection-timeout=60s ``` ## Examples @@ -62,27 +62,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 +92,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 +102,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 +110,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,7 +118,7 @@ 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 +viiper proxy --upstream=real-server:3240 --log.level=debug --log.raw-file=traffic.log ``` ## Proxy Architecture From 2eb72cb6fa338f04235f5ca09188ac066930c57a Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 14:52:30 +0100 Subject: [PATCH 005/298] Fix white shapes in viiper.svg --- docs/viiper.svg | 159 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) 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 From bb18ff2e054e07fb06147a0dcd34ad2192b2d435 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 15:13:40 +0100 Subject: [PATCH 006/298] Update docs --- .github/workflows/docs-deploy.yml | 2 -- docs/devices/keyboard.md | 9 ++++++++- docs/devices/mouse.md | 9 ++++++++- docs/devices/xbox360.md | 9 ++++++++- docs/getting-started/quickstart.md | 2 -- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 5f635601..eab52fc9 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -44,7 +44,6 @@ jobs: { echo "# Changelog" echo - echo "This section lists version-specific changelogs." echo } > docs/changelog/index.md for f in docs/changelog/*.md; do @@ -76,7 +75,6 @@ jobs: { echo "# Changelog" echo - echo "This section lists version-specific changelogs." echo } > docs/changelog/index.md for f in docs/changelog/*.md; do diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index cffb4611..1f215863 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -1,4 +1,4 @@ -# HID Keyboard (virtual) +# HID Keyboard 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. @@ -8,6 +8,13 @@ A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, plu - OUT: 0x01 (LED output report) - Device type id (for API add): `keyboard` +## Client SDK Support + +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/keyboard`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) + ## HID report format (host-facing) Input (device → host): 34 bytes diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index bf8ed4d2..1def933c 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -1,4 +1,4 @@ -# HID Mouse (virtual) +# HID Mouse A standard 5-button mouse with vertical and horizontal scroll wheels. Reports relative motion deltas and supports up to five buttons. @@ -6,6 +6,13 @@ A standard 5-button mouse with vertical and horizontal scroll wheels. Reports re - Interface/Endpoint: IN 0x81 (mouse input report) - Device type id (for API add): `mouse` +## Client SDK Support + +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/mouse`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) + ## HID report format (host-facing) Input (device → host): 5 bytes diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 9f421208..1827a06d 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -1,4 +1,4 @@ -# 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. @@ -6,6 +6,13 @@ The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most - Interfaces/Endpoints: single HID interface with one IN interrupt endpoint and one OUT interrupt endpoint for rumble - Device type id (for API add): `xbox360` +## Client SDK Support + +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/xbox360`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) + ## Adding the device Use the API to create a bus and add an Xbox 360 controller: diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 790f4b7e..c64d536e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -2,8 +2,6 @@ 🚧 **Documentation in progress** 🚧 -This section will cover basic usage examples for getting started with VIIPER. - ## Basic Usage Check the [CLI Reference](../cli/overview.md) for detailed command documentation. From 5e1ba8252f2b9fa63ff80c4b5e6f84f331885e5a Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 19:49:57 +0100 Subject: [PATCH 007/298] C# SDK Gen Changelog(feat) --- .github/workflows/build_base.yml | 3 +- Makefile | 2 +- docs/clients/csharp.md | 478 ++++++++++++++++++ docs/clients/generator.md | 70 ++- examples/csharp/.gitignore | 2 + examples/csharp/virtual_keyboard/Program.cs | 159 ++++++ .../virtual_keyboard/VirtualKeyboard.csproj | 11 + examples/csharp/virtual_mouse/Program.cs | 88 ++++ .../csharp/virtual_mouse/VirtualMouse.csproj | 11 + examples/csharp/virtual_x360_pad/Program.cs | 90 ++++ .../virtual_x360_pad/VirtualX360Pad.csproj | 11 + mkdocs.yml | 1 + viiper/internal/cmd/codegen.go | 20 +- viiper/internal/codegen/common/naming.go | 85 ++++ viiper/internal/codegen/common/version.go | 46 ++ viiper/internal/codegen/generator/c/gen.go | 9 +- .../codegen/generator/c/header_common.go | 24 +- .../codegen/generator/c/header_device.go | 8 + .../internal/codegen/generator/c/helpers.go | 180 +++++-- .../codegen/generator/c/source_device.go | 18 +- .../codegen/generator/csharp/client.go | 178 +++++++ .../codegen/generator/csharp/constants.go | 424 ++++++++++++++++ .../codegen/generator/csharp/device.go | 154 ++++++ .../codegen/generator/csharp/device_types.go | 217 ++++++++ .../internal/codegen/generator/csharp/gen.go | 73 +++ .../codegen/generator/csharp/helpers.go | 57 +++ .../codegen/generator/csharp/project.go | 61 +++ .../codegen/generator/csharp/types.go | 85 ++++ .../internal/codegen/generator/generator.go | 92 ++-- viiper/internal/codegen/scanner/constants.go | 9 +- viiper/pkg/device/keyboard/codemap.go | 160 ------ viiper/pkg/device/keyboard/const.go | 159 ++++++ 32 files changed, 2711 insertions(+), 274 deletions(-) create mode 100644 docs/clients/csharp.md create mode 100644 examples/csharp/.gitignore create mode 100644 examples/csharp/virtual_keyboard/Program.cs create mode 100644 examples/csharp/virtual_keyboard/VirtualKeyboard.csproj create mode 100644 examples/csharp/virtual_mouse/Program.cs create mode 100644 examples/csharp/virtual_mouse/VirtualMouse.csproj create mode 100644 examples/csharp/virtual_x360_pad/Program.cs create mode 100644 examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj create mode 100644 viiper/internal/codegen/common/naming.go create mode 100644 viiper/internal/codegen/common/version.go create mode 100644 viiper/internal/codegen/generator/csharp/client.go create mode 100644 viiper/internal/codegen/generator/csharp/constants.go create mode 100644 viiper/internal/codegen/generator/csharp/device.go create mode 100644 viiper/internal/codegen/generator/csharp/device_types.go create mode 100644 viiper/internal/codegen/generator/csharp/gen.go create mode 100644 viiper/internal/codegen/generator/csharp/helpers.go create mode 100644 viiper/internal/codegen/generator/csharp/project.go create mode 100644 viiper/internal/codegen/generator/csharp/types.go delete mode 100644 viiper/pkg/device/keyboard/codemap.go diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index c2a54777..13dd6513 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -74,7 +74,8 @@ jobs: 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 + VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") + go build -trimpath -ldflags "-s -w -X viiper/internal/codegen/common.Version=${VERSION}" -o ../dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} ./cmd/viiper ls -la ../dist - name: Upload artifact diff --git a/Makefile b/Makefile index 358b72b5..84f2b0ca 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ 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) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X viiper/internal/codegen/common.Version=$(VERSION) BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" # Windows detection and environment setup diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md new file mode 100644 index 00000000..24dd4f67 --- /dev/null +++ b/docs/clients/csharp.md @@ -0,0 +1,478 @@ +# C# SDK Documentation + +The VIIPER C# SDK provides a modern, type-safe .NET client library for interacting with VIIPER servers and controlling virtual devices. + +## Overview + +The C# SDK 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 8.0 with nullable reference types +- **Zero external dependencies**: Uses only built-in .NET libraries + +!!! 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=csharp +``` + +Build the SDK: + +```bash +cd ../clients/csharp +dotnet build -c Release Viiper.Client +``` + +### Adding to Your Project + +**Project Reference (recommended for local development):** + +```xml + + + +``` + +**NuGet Package (when available):** + +```bash +dotnet add package Viiper.Client +``` + +## Quick Start + +```csharp +using Viiper.Client; +using Viiper.Client.Devices.Keyboard; + +// Connect to management API +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("my-bus"); + busId = resp.BusID; +} +else +{ + busId = buses.Buses[0]; +} + +// Add device and connect +var addResp = await client.BusDeviceAddAsync(busId, "keyboard"); +var deviceId = addResp.ID.Split('-')[1]; // Extract device ID from "busId-devId" +var device = await client.ConnectDeviceAsync(busId, deviceId); + +Console.WriteLine($"Connected to device {addResp.ID}"); + +// 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, deviceId); +``` + +## Device Stream API + +### Creating a Device Stream + +The simplest way to add a device and connect: + +```csharp +var addResp = await client.BusDeviceAddAsync(busId, "xbox360"); +var deviceId = addResp.ID.Split('-')[1]; +var device = await client.ConnectDeviceAsync(busId, deviceId); +``` + +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 = (ushort)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 Output (Events) + +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); +``` + +## Generated Constants and Maps + +The C# SDK 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 SDK 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 +``` + +## Practical Example: Typing Text + +Using the generated maps to type a string: + +```csharp +async Task TypeString(ViiperDevice device, string text) +{ + foreach (char c in text) + { + if (!CharToKey.TryGetValue((byte)c, out var key)) + continue; + + byte mods = ShiftChars.ContainsKey((byte)c) + ? (byte)Mod.LeftShift + : (byte)0; + + // Press + await device.SendAsync(new KeyboardInput + { + Modifiers = mods, + Count = 1, + Keys = new[] { (byte)key } + }); + await Task.Delay(50); + + // Release + await device.SendAsync(new KeyboardInput + { + Modifiers = 0, + Count = 0, + Keys = Array.Empty() + }); + await Task.Delay(50); + } +} + +// Usage +await TypeString(device, "Hello, World!"); +``` + +## Device-Specific Wire Formats + +### Keyboard Input + +```csharp +public struct KeyboardInput +{ + public byte Modifiers; // Modifier flags (Ctrl, Shift, Alt, GUI) + public byte Count; // Number of keys in Keys array + public byte[] Keys; // Key codes (max 6 for HID compliance) +} +``` + +**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) + +### Keyboard Output (LEDs) + +```csharp +// Single byte with LED flags +byte leds = data[0]; +bool numLock = (leds & (byte)LED.NumLock) != 0; +``` + +### Xbox360 Input + +```csharp +public struct Xbox360Input +{ + public ushort Buttons; // Button flags + public byte LeftTrigger; // 0-255 + public byte RightTrigger; // 0-255 + public short ThumbLX; // -32768 to 32767 + public short ThumbLY; // -32768 to 32767 + public short ThumbRX; // -32768 to 32767 + public short ThumbRY; // -32768 to 32767 +} +``` + +**Wire format:** Fixed 14 bytes, packed structure + +### Xbox360 Output (Rumble) + +```csharp +// Two bytes: left motor + right motor (0-255 each) +byte leftMotor = data[0]; +byte rightMotor = data[1]; +``` + +### Mouse Input + +```csharp +public struct MouseInput +{ + public byte Buttons; // Button flags + public short X; // Relative X movement + public short Y; // Relative Y movement + public sbyte Wheel; // Vertical scroll + public sbyte Pan; // Horizontal scroll +} +``` + +**Wire format:** Fixed 8 bytes, packed structure + +## 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 +``` + +## Project Structure + +Generated SDK layout: + +``` +clients/csharp/Viiper.Client/ +├── ViiperClient.cs # Management API client +├── ViiperDevice.cs # Device stream wrapper +├── Types/ +│ ├── BusListResponse.cs # API response types +│ ├── BusCreateResponse.cs +│ └── ... +└── Devices/ + ├── Keyboard/ + │ ├── KeyboardInput.cs # Wire format struct + │ └── KeyboardConstants.cs # Enums + maps + ├── Mouse/ + │ ├── MouseInput.cs + │ └── MouseConstants.cs + └── Xbox360/ + ├── Xbox360Input.cs + ├── Xbox360Output.cs + └── Xbox360Constants.cs +``` + +## 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 SDKs work +- [Go SDK Documentation](go.md): Reference implementation patterns +- [C SDK Documentation](c.md): Alternative SDK for native integration +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details + +--- + +For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/generator.md b/docs/clients/generator.md index aecc1031..0b341080 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -56,10 +56,13 @@ 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 `pkg/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 (C#/TS) or `#define` macros (C) based on common prefixes +- **Maps**: Converted to Dictionary/Map/lookup functions with helper methods ## Code Generation Flow @@ -69,7 +72,7 @@ No special tags are required. Exported Go constants are emitted with language-ap 2. Reflect response DTOs from `pkg/apitypes/*.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 `pkg/device/*/const.go` (automatic) **Emit Phase:** For each language, generate management client, DTO types, device streams, constants, and build configs. @@ -132,7 +135,7 @@ typedef struct { #pragma pack(pop) ``` -## Example: Constant Export +## Example: Constant and Map Export **Go source (`pkg/device/keyboard/const.go`):** @@ -144,6 +147,13 @@ const ( KeyB = 0x05 // ... ) + +var CharToKey = map[byte]byte{ + 'a': KeyA, + 'b': KeyB, + '\n': KeyEnter, + // ... +} ``` **Emitted C header (`viiper_keyboard.h`):** @@ -153,7 +163,43 @@ const ( #define VIIPER_KEYBOARD_MODLEFTSHIFT 0x2 #define VIIPER_KEYBOARD_KEYA 0x4 #define VIIPER_KEYBOARD_KEYB 0x5 -// ... + +// Map lookup function +int viiper_keyboard_char_to_key_lookup(uint8_t key, uint8_t* out_value); +``` + +**Emitted C# (`KeyboardConstants.cs`):** + +```csharp +public enum Mod : uint +{ + LeftCtrl = 0x01, + LeftShift = 0x02, + // ... +} + +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 @@ -162,26 +208,28 @@ 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 +- `pkg/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. +- **C**: `#define` macros for constants; switch-based lookup functions for maps; manual memory management for variable-length fields; builds with CMake. +- **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; `Map` or plain objects for maps; manual byte encoding; ESM/CJS compatible. ## Current SDK Status - **C**: ✅ Complete -- **C#**: 🚧 Planned +- **C#**: ✅ Complete - **TypeScript**: 🚧 Planned ## Further Reading - [Design Document](../design.md): Architectural rationale and detailed generation strategy - [C SDK Documentation](c.md): C-specific usage, build, and examples +- [C# SDK Documentation](csharp.md): C#-specific usage, async patterns, and map helpers - [Go Client Documentation](go.md): Go reference client usage --- 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_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs new file mode 100644 index 00000000..f648929a --- /dev/null +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -0,0 +1,159 @@ +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) + { + Exception? lastErr = null; + busId = 0; + for (uint i = 1; i <= 100; i++) + { + try + { + var resp = await client.BusCreateAsync(i.ToString()); + busId = resp.BusID; + createdBus = true; + break; + } + catch (Exception ex) + { + lastErr = ex; + } + } + if (busId == 0) + { + Console.WriteLine($"BusCreate failed: {lastErr}"); + return; + } + Console.WriteLine($"Created bus {busId}"); + } + else + { + busId = list.Buses.Min(); + Console.WriteLine($"Using existing bus {busId}"); + } +} + +string fullDevId; +string devId; +ViiperDevice device; +try +{ + // 2) Add device and connect + var add = await client.BusDeviceAddAsync(busId, "keyboard"); + fullDevId = add.ID; // e.g., "1-1" + // Parse "busId-devId" format to extract just the device part + var parts = fullDevId.Split('-'); + devId = parts.Length > 1 ? parts[1] : fullDevId; + device = await client.ConnectDeviceAsync(busId, devId); + Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); +} +catch (Exception ex) +{ + if (createdBus) + { + try { await client.BusRemoveAsync(busId.ToString()); } 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(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } + if (createdBus) + { + try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } + } +} + +// Subscribe to LED output (1 byte frames) +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} Compose={(leds & (byte)LED.Compose) != 0} Kana={(leds & (byte)LED.Kana) != 0}"); +}; + +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..760e2860 --- /dev/null +++ b/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.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..2d16c870 --- /dev/null +++ b/examples/csharp/virtual_mouse/Program.cs @@ -0,0 +1,88 @@ +using Viiper.Client; +using Viiper.Client.Devices.Mouse; + +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) + { + Exception? lastErr = null; + busId = 0; + for (uint i = 1; i <= 100; i++) + { + try { var r = await client.BusCreateAsync(i.ToString()); busId = r.BusID; createdBus = true; break; } + catch (Exception ex) { lastErr = ex; } + } + if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } + Console.WriteLine($"Created bus {busId}"); + } + else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } +} + +// Add device and connect +string fullDevId; string devId; ViiperDevice device; +try +{ + var add = await client.BusDeviceAddAsync(busId, "mouse"); + fullDevId = add.ID; // e.g., "1-1" + var parts = fullDevId.Split('-'); + devId = parts.Length > 1 ? parts[1] : fullDevId; + device = await client.ConnectDeviceAsync(busId, devId); + Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); +} +catch (Exception ex) +{ + if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); } 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(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } + if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } } +} + +// Send movement/click/scroll every 3s +var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); +sbyte dir = 1; const sbyte step = 50; +Console.WriteLine("Every 3s: move diagonally by 50px, click left, scroll +1. Ctrl+C to stop."); +while (await timer.WaitForNextTickAsync()) +{ + var dx = (sbyte)(step * dir); + var dy = (sbyte)(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..760e2860 --- /dev/null +++ b/examples/csharp/virtual_mouse/VirtualMouse.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.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..6ead87e5 --- /dev/null +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -0,0 +1,90 @@ +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) + { + Exception? lastErr = null; + busId = 0; + for (uint i = 1; i <= 100; i++) + { + try { var r = await client.BusCreateAsync(i.ToString()); busId = r.BusID; createdBus = true; break; } + catch (Exception ex) { lastErr = ex; } + } + if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } + Console.WriteLine($"Created bus {busId}"); + } + else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } +} + +// Add device and connect +string fullDevId; string devId; ViiperDevice device; +try +{ + var add = await client.BusDeviceAddAsync(busId, "xbox360"); + fullDevId = add.ID; // e.g., "1-1" + var parts = fullDevId.Split('-'); + devId = parts.Length > 1 ? parts[1] : fullDevId; + device = await client.ConnectDeviceAsync(busId, devId); + Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); +} +catch (Exception ex) +{ + if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); } 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(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } + if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } } +} + +// Read rumble output (2 bytes) and log +device.OnOutput += data => +{ + if (data.Length < 2) return; + byte left = data[0]; byte right = data[1]; + Console.WriteLine($"← Rumble: Left={left}, Right={right}"); +}; + +// 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..760e2860 --- /dev/null +++ b/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 91928461..179fe4a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Go Client: clients/go.md - Generator Documentation: clients/generator.md - C SDK: clients/c.md + - C# SDK: clients/csharp.md - Devices: - Xbox 360 Controller: devices/xbox360.md - Keyboard: devices/keyboard.md diff --git a/viiper/internal/cmd/codegen.go b/viiper/internal/cmd/codegen.go index ef590b26..7170c86c 100644 --- a/viiper/internal/cmd/codegen.go +++ b/viiper/internal/cmd/codegen.go @@ -15,23 +15,9 @@ 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() + if c.Lang == "all" { + return gen.GenAll() } + return gen.GenerateLang(c.Lang) - return nil } diff --git a/viiper/internal/codegen/common/naming.go b/viiper/internal/codegen/common/naming.go new file mode 100644 index 00000000..fb9c8dc4 --- /dev/null +++ b/viiper/internal/codegen/common/naming.go @@ -0,0 +1,85 @@ +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 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 { + 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()) +} + +// 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/viiper/internal/codegen/common/version.go b/viiper/internal/codegen/common/version.go new file mode 100644 index 00000000..b3398a09 --- /dev/null +++ b/viiper/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/viiper/internal/codegen/generator/c/gen.go b/viiper/internal/codegen/generator/c/gen.go index 1aaacdca..aeee8c5f 100644 --- a/viiper/internal/codegen/generator/c/gen.go +++ b/viiper/internal/codegen/generator/c/gen.go @@ -17,6 +17,13 @@ import ( // - src/viiper_.c (per-device) // - CMakeLists.txt 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") srcDir := filepath.Join(outputDir, "src") @@ -27,7 +34,7 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return fmt.Errorf("create src dir: %w", err) } - if err := generateCommonHeader(logger, includeDir, md); err != nil { + if err := generateCommonHeader(logger, includeDir, md, major, minor, patch); err != nil { return err } diff --git a/viiper/internal/codegen/generator/c/header_common.go b/viiper/internal/codegen/generator/c/header_common.go index 02e06587..1b440624 100644 --- a/viiper/internal/codegen/generator/c/header_common.go +++ b/viiper/internal/codegen/generator/c/header_common.go @@ -33,9 +33,9 @@ extern "C" { #endif /* Version information */ -#define VIIPER_VERSION_MAJOR 0 -#define VIIPER_VERSION_MINOR 1 -#define VIIPER_VERSION_PATCH 0 +#define VIIPER_VERSION_MAJOR {{.Major}} +#define VIIPER_VERSION_MINOR {{.Minor}} +#define VIIPER_VERSION_PATCH {{.Patch}} /* Forward declarations */ typedef struct viiper_client viiper_client_t; @@ -153,7 +153,7 @@ VIIPER_API void viiper_device_close(viiper_device_t* device); #endif /* VIIPER_H */ ` -func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata) error { +func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata, major, minor, patch int) error { out := filepath.Join(includeDir, "viiper.h") t := template.Must(template.New("viiper.h").Funcs(tplFuncs(md)).Parse(commonHeaderTmpl)) f, err := os.Create(out) @@ -161,7 +161,21 @@ func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metad return fmt.Errorf("create header: %w", err) } defer f.Close() - if err := t.Execute(f, md); err != nil { + + // Create data with version and metadata + data := struct { + *meta.Metadata + Major int + Minor int + Patch int + }{ + Metadata: md, + Major: major, + Minor: minor, + Patch: patch, + } + + if err := t.Execute(f, data); err != nil { return fmt.Errorf("exec header tmpl: %w", err) } logger.Info("Generated C header", "file", out) diff --git a/viiper/internal/codegen/generator/c/header_device.go b/viiper/internal/codegen/generator/c/header_device.go index bd182527..4df8c063 100644 --- a/viiper/internal/codegen/generator/c/header_device.go +++ b/viiper/internal/codegen/generator/c/header_device.go @@ -27,6 +27,14 @@ const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H {{end}} {{- end}} +/* ======================================================================== + * {{.Device}} Lookup Maps + * ======================================================================== */ +{{- range .Pkg.Maps }} +/* {{.Name}} lookup function */ +{{mapFuncDecl $.Device .}} +{{- end}} + /* ======================================================================== * {{.Device}} Input/Output Structures * ======================================================================== */ diff --git a/viiper/internal/codegen/generator/c/helpers.go b/viiper/internal/codegen/generator/c/helpers.go index dafe38d5..546b08a7 100644 --- a/viiper/internal/codegen/generator/c/helpers.go +++ b/viiper/internal/codegen/generator/c/helpers.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "text/template" + "viiper/internal/codegen/common" "viiper/internal/codegen/meta" "viiper/internal/codegen/scanner" ) @@ -11,15 +12,16 @@ import ( func tplFuncs(md *meta.Metadata) template.FuncMap { return template.FuncMap{ "ctype": cType, - "snakecase": toSnakeCase, + "snakecase": common.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, + "mapFuncDecl": mapFuncDecl, + "mapFuncImpl": mapFuncImpl, } } @@ -60,23 +62,12 @@ func cType(goType, kind string) string { return "viiper_device_info_t" } if kind == "struct" { - return fmt.Sprintf("viiper_%s_t*", toSnakeCase(goType)) + return fmt.Sprintf("viiper_%s_t*", common.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 @@ -107,7 +98,7 @@ func renderCWireFields(tag *scanner.WireTag) string { 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))) + lines = append(lines, fmt.Sprintf("%s* %s; size_t %s_count;", cbase, field.Name, common.ToSnakeCase(field.Name))) continue } lines = append(lines, fmt.Sprintf("%s %s;", wireBaseToC(field.Type), field.Name)) @@ -115,17 +106,6 @@ func renderCWireFields(tag *scanner.WireTag) string { 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": @@ -160,8 +140,6 @@ func indent(spaces int, s string) string { return strings.Join(parts, "\n") } -func hasResponse(handler string) bool { return false } - func orderedPathParams(path string) []string { if path == "" { return nil @@ -180,7 +158,7 @@ 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; size_t %s_count;%s", cElem, f.Name, common.ToSnakeCase(f.Name), optComment(f)) } return fmt.Sprintf("%s %s;%s", cType(f.Type, f.TypeKind), f.Name, optComment(f)) } @@ -191,3 +169,147 @@ func optComment(f scanner.FieldInfo) string { } return "" } + +func mapFuncDecl(device string, mapInfo scanner.MapInfo) string { + keyType := mapGoTypeToCType(mapInfo.KeyType) + valueType := mapGoTypeToCType(mapInfo.ValueType) + funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) + + return fmt.Sprintf("int %s(%s key, %s* out_value);", funcName, keyType, valueType) +} + +func mapFuncImpl(device string, mapInfo scanner.MapInfo) string { + keyType := mapGoTypeToCType(mapInfo.KeyType) + valueType := mapGoTypeToCType(mapInfo.ValueType) + funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) + + var builder strings.Builder + builder.WriteString(fmt.Sprintf("int %s(%s key, %s* out_value) {\n", funcName, keyType, valueType)) + builder.WriteString(" if (!out_value) return 0;\n") + builder.WriteString(" switch (key) {\n") + + for keyStr, value := range mapInfo.Entries { + cKey := formatCMapKey(keyStr, mapInfo.KeyType, device) + cValue := formatCMapValue(value, mapInfo.ValueType, device) + builder.WriteString(fmt.Sprintf(" case %s: *out_value = %s; return 1;\n", cKey, cValue)) + } + + builder.WriteString(" default: return 0;\n") + builder.WriteString(" }\n") + builder.WriteString("}\n") + + return builder.String() +} + +func mapGoTypeToCType(goType string) string { + switch goType { + case "byte", "uint8": + return "uint8_t" + case "uint16": + return "uint16_t" + case "uint32", "uint": + return "uint32_t" + case "uint64": + return "uint64_t" + case "int8": + return "int8_t" + case "int16": + return "int16_t" + case "int32", "int": + return "int32_t" + case "int64": + return "int64_t" + case "bool": + return "int" + case "string": + return "const char*" + default: + return goType + } +} + +func formatCMapKey(key string, goType string, device string) string { + switch goType { + case "byte", "uint8": + if len(key) == 1 { + // Escape special characters + switch key[0] { + case '\n': + return "'\\n'" + case '\r': + return "'\\r'" + case '\t': + return "'\\t'" + case '\\': + return "'\\\\'" + case '\'': + return "'\\''" + case 0: + return "'\\0'" + } + if key[0] >= 32 && key[0] <= 126 { + return fmt.Sprintf("'%s'", key) + } + return fmt.Sprintf("0x%02X", key[0]) + } + if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { + prefix := common.ExtractPrefix(key) + if prefix != "" { + constName := strings.ToUpper(key) + return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) + } + } + return key + case "string": + return fmt.Sprintf("\"%s\"", key) + default: + return key + } +} + +func formatCMapValue(value interface{}, goType string, device string) string { + switch goType { + case "byte", "uint8", "uint16", "uint32", "uint64": + if str, ok := value.(string); ok && !strings.Contains(str, " ") { + if len(str) > 0 && (str[0] >= 'A' && str[0] <= 'Z') { + prefix := common.ExtractPrefix(str) + if prefix != "" { + constName := strings.ToUpper(str) + return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) + } + } + return str + } + if num, ok := value.(int64); ok { + return fmt.Sprintf("0x%X", num) + } + if num, ok := value.(uint64); ok { + return fmt.Sprintf("0x%X", num) + } + return fmt.Sprintf("%v", value) + case "bool": + if b, ok := value.(bool); ok { + if b { + return "1" + } + return "0" + } + if str, ok := value.(string); ok { + if str == "true" { + return "1" + } + if str == "false" { + return "0" + } + return str + } + return "0" + case "string": + if str, ok := value.(string); ok { + return fmt.Sprintf("\"%s\"", str) + } + return fmt.Sprintf("\"%v\"", value) + default: + return fmt.Sprintf("%v", value) + } +} diff --git a/viiper/internal/codegen/generator/c/source_device.go b/viiper/internal/codegen/generator/c/source_device.go index 3c90875c..79bf4496 100644 --- a/viiper/internal/codegen/generator/c/source_device.go +++ b/viiper/internal/codegen/generator/c/source_device.go @@ -7,6 +7,7 @@ import ( "path/filepath" "text/template" "viiper/internal/codegen/meta" + "viiper/internal/codegen/scanner" ) const deviceSourceTmpl = `/* Auto-generated VIIPER - C SDK: device source ({{.Device}}) */ @@ -14,17 +15,30 @@ const deviceSourceTmpl = `/* Auto-generated VIIPER - C SDK: device source ({{.De #include "viiper/viiper.h" #include "viiper/viiper_{{.Device}}.h" +/* ======================================================================== + * {{.Device}} Map Implementations + * ======================================================================== */ +{{- range .Pkg.Maps }} + +{{mapFuncImpl $.Device .}} +{{- end}} ` type deviceSourceData struct { Device string HasS2C bool + Pkg *scanner.DeviceConstants } func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.Metadata) error { - data := deviceSourceData{Device: device, HasS2C: hasWireTag(md, device, "s2c")} + pkg := md.DevicePackages[device] + data := deviceSourceData{ + Device: device, + HasS2C: hasWireTag(md, device, "s2c"), + Pkg: pkg, + } out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) - t := template.Must(template.New("device.c").Parse(deviceSourceTmpl)) + t := template.Must(template.New("device.c").Funcs(tplFuncs(md)).Parse(deviceSourceTmpl)) f, err := os.Create(out) if err != nil { return fmt.Errorf("create device source: %w", err) diff --git a/viiper/internal/codegen/generator/csharp/client.go b/viiper/internal/codegen/generator/csharp/client.go new file mode 100644 index 00000000..47b82049 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/client.go @@ -0,0 +1,178 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + "viiper/internal/codegen/meta" + "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 bool _disposed; + + /// + /// Creates a new VIIPER client instance + /// + /// VIIPER server hostname or IP address + /// VIIPER API server port (default: 3242) + public ViiperClient(string host, int port = 3242) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + } +{{range .Routes}}{{if eq .Method "Register"}} + /// + /// {{.Handler}}: {{.Path}} + /// {{range .Arguments}} + /// {{.Type}}{{end}}{{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}}; + {{if .Arguments}}string? payload = {{range $i, $arg := .Arguments}}{{if $i}} + " " + {{end}}{{toCamelCase $arg.Name}}{{if ne $arg.Type "string"}}?.ToString(){{end}}{{end}};{{else}}string? payload = null;{{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); + + using var stream = client.GetStream(); + + // Build command line: "path arg1 arg2 ...\n" (matches Go transport protocol) + string commandLine = path.ToLowerInvariant(); + if (!string.IsNullOrEmpty(payload)) + { + commandLine += " " + payload; + } + commandLine += "\n"; + + var requestBytes = Encoding.UTF8.GetBytes(commandLine); + await stream.WriteAsync(requestBytes, cancellationToken); + + var buffer = new byte[8192]; + var responseBuilder = new StringBuilder(); + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken)) > 0) + { + responseBuilder.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead)); + if (responseBuilder.ToString().Contains('\n')) + break; + } + + var responseJson = responseBuilder.ToString().TrimEnd('\n'); + 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); + + var stream = client.GetStream(); + var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\n"; + 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); + } +} +` + +// generateClient creates the ViiperClient management API class +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, + "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() + + 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))) + } + + for _, arg := range route.Arguments { + csharpType := goTypeToCSharp(arg.Type) + if arg.Optional { + csharpType += "?" + } + params = append(params, fmt.Sprintf("%s %s", csharpType, toCamelCase(arg.Name))) + } + + if len(params) == 0 { + return "" + } + + return strings.Join(params, ", ") + ", " +} diff --git a/viiper/internal/codegen/generator/csharp/constants.go b/viiper/internal/codegen/generator/csharp/constants.go new file mode 100644 index 00000000..39780500 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/constants.go @@ -0,0 +1,424 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" + "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 + } + + if len(deviceConsts.Constants) == 0 && len(deviceConsts.Maps) == 0 { + 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 := convertMapsToCShar(deviceConsts.Maps) + + data := struct { + Device string + EnumGroups []enumGroup + Maps []mapData + }{ + Device: pascalDevice, + 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() + + tmpl := template.Must(template.New("constants").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 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 { + prefix := common.ExtractPrefix(c.Name) + if prefix == "" { + continue + } + + if _, exists := groups[prefix]; !exists { + groups[prefix] = &enumGroup{ + Name: prefix, + Constants: []constantInfo{}, + } + } + + name := strings.TrimPrefix(c.Name, prefix) + if len(name) > 0 && name[0] >= '0' && name[0] <= '9' { + name = "Num" + name + } + groups[prefix].Constants = append(groups[prefix].Constants, constantInfo{ + Name: name, + Value: formatConstValue(c.Value), + 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 shouldGenerateEnum(eg enumGroup) bool { + return len(eg.Constants) >= 3 +} + +func inferEnumType(constants []constantInfo) string { + return "uint" +} + +func isFlags(constants []constantInfo) bool { + for _, c := range constants { + if strings.HasPrefix(c.Value, "0x") { + var val uint64 + fmt.Sscanf(c.Value, "0x%x", &val) + if val > 0 && (val&(val-1)) == 0 { + return true + } + if val > 0 { + return true + } + } + } + return false +} + +func formatConstValue(value interface{}) string { + switch v := value.(type) { + case int64: + return fmt.Sprintf("0x%X", v) + case uint64: + return fmt.Sprintf("0x%X", v) + case int: + return fmt.Sprintf("0x%X", v) + case string: + return fmt.Sprintf("\"%s\"", v) + case float64: + return fmt.Sprintf("%f", 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 "string": + return "string" + case "char": + return "char" + case "bool": + return "bool" + default: + return "int" + } +} + +// inferValueTypeFromEntries scans map values to detect if they're enum constants +// Returns the inferred C# type name (e.g., "Key") or empty string if not detected +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 convertMapsToCShar(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 := make([]string, 0, len(m.Entries)) + for k := range m.Entries { + keys = append(keys, k) + } + sort.Strings(keys) + + 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 { + if key[0] == '\'' { + return "(byte)'\\''" + } else if key[0] == '\\' { + 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') { + prefix := common.ExtractPrefix(key) + if prefix != "" { + member := strings.TrimPrefix(key, prefix) + if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { + member = "Num" + member + } + return fmt.Sprintf("(byte)%s.%s", prefix, 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, " ") { + prefix := common.ExtractPrefix(str) + if prefix != "" { + member := strings.TrimPrefix(str, prefix) + if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { + member = "Num" + member + } + return fmt.Sprintf("%s.%s", prefix, member) + } + return str + } + return formatConstValue(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 formatConstValue(value) + default: + return formatConstValue(value) + } +} + +const constantsTemplate = `using System; +using System.Collections.Generic; + +namespace Viiper.Client.Devices.{{.Device}}; + +{{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 {{.ValueType}} GetValueOrDefault({{.KeyType}} key, {{.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/viiper/internal/codegen/generator/csharp/device.go b/viiper/internal/codegen/generator/csharp/device.go new file mode 100644 index 00000000..269a0a7b --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/device.go @@ -0,0 +1,154 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + "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 NetworkStream _stream; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _readLoop; + private bool _disposed; + + /// + /// Raised when output data is received from the device (raw binary frame). + /// + public event Action? OnOutput; + + internal ViiperDevice(TcpClient client, NetworkStream stream) + { + _client = client; + _stream = stream; + _readLoop = Task.Run(ReadLoopAsync); + } + + /// + /// 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() + { + var buffer = new byte[4096]; + try + { + while (!_cts.IsCancellationRequested) + { + var read = await _stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false); + if (read <= 0) + { + break; // connection closed + } + var frame = new byte[read]; + Buffer.BlockCopy(buffer, 0, frame, 0, read); + OnOutput?.Invoke(frame); + } + } + catch (OperationCanceledException) + { + // normal during shutdown + } + catch (Exception) + { + // swallow; user can detect via absence of further events + } + } + + 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 { /* ignore */ } + _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(); + try { await _readLoop.ConfigureAwait(false); } catch { /* ignore */ } + _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() + 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/viiper/internal/codegen/generator/csharp/device_types.go b/viiper/internal/codegen/generator/csharp/device_types.go new file mode 100644 index 00000000..7faf4fbe --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/device_types.go @@ -0,0 +1,217 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "viiper/internal/codegen/meta" + "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() + + data := struct { + Device string + ClassName string + Fields []wireField + HasArray bool + ArrayInfo *arrayFieldInfo + }{ + Device: device, + ClassName: className, + } + + for _, field := range tag.Fields { + wf := wireField{ + Name: toPascalCase(field.Name), + GoType: field.Type, + CSType: mapGoTypeToCSharp(field.Type), + } + + if strings.Contains(field.Spec, "*") { + parts := strings.Split(field.Spec, "*") + if len(parts) == 2 { + data.HasArray = true + data.ArrayInfo = &arrayFieldInfo{ + FieldName: wf.Name, + CountFieldName: toPascalCase(parts[1]), + ElementType: wf.CSType, + } + wf.IsArray = true + } + } + + 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 + GoType string + CSType string + IsArray bool +} + +type arrayFieldInfo struct { + FieldName string + CountFieldName string + ElementType string +} + +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}} public required {{.CSType}}{{if .IsArray}}[]{{end}} {{.Name}} { get; set; } +{{end}} + public void Write(BinaryWriter writer) + { +{{if .HasArray}} // Write fixed fields +{{range .Fields}}{{if not .IsArray}} writer.Write({{.Name}}); +{{end}}{{end}} + // Write variable-length array + for (int i = 0; i < {{.ArrayInfo.CountFieldName}}; i++) + { + writer.Write({{.ArrayInfo.FieldName}}[i]); + } +{{else}}{{range .Fields}} writer.Write({{.Name}}); +{{end}}{{end}} } + + /// + /// Read from binary stream (for receiving output from server). + /// + public static {{.Device}}{{.ClassName}} Read(BinaryReader reader) + { +{{if .HasArray}} // Read fixed fields +{{range .Fields}}{{if not .IsArray}} var {{toCamel .Name}} = reader.Read{{readerMethod .CSType}}(); +{{end}}{{end}} + // Read variable-length array + var {{toCamel .ArrayInfo.FieldName}} = new {{.ArrayInfo.ElementType}}[{{toCamel .ArrayInfo.CountFieldName}}]; + for (int i = 0; i < {{toCamel .ArrayInfo.CountFieldName}}; i++) + { + {{toCamel .ArrayInfo.FieldName}}[i] = reader.Read{{readerMethod .ArrayInfo.ElementType}}(); + } + + return new {{.Device}}{{.ClassName}} + { +{{range .Fields}}{{if not .IsArray}} {{.Name}} = {{toCamel .Name}}, +{{end}}{{end}} {{.ArrayInfo.FieldName}} = {{toCamel .ArrayInfo.FieldName}} + }; +{{else}} return new {{.Device}}{{.ClassName}} + { +{{range .Fields}} {{.Name}} = reader.Read{{readerMethod .CSType}}(), +{{end}} }; +{{end}} } +} +` diff --git a/viiper/internal/codegen/generator/csharp/gen.go b/viiper/internal/codegen/generator/csharp/gen.go new file mode 100644 index 00000000..84b50e65 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/gen.go @@ -0,0 +1,73 @@ +package csharp + +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: +// - Viiper.Client/Viiper.Client.csproj +// - Viiper.Client/ViiperClient.cs (management API) +// - Viiper.Client/ViiperDevice.cs (device stream wrapper) +// - Viiper.Client/Types/*.cs (DTOs) +// - Viiper.Client/Devices/*/*.cs (per-device types and constants) +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 := 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 := common.GenerateLicense(logger, outputDir); err != nil { + return err + } + + logger.Info("Generated C# SDK", "dir", outputDir) + return nil +} diff --git a/viiper/internal/codegen/generator/csharp/helpers.go b/viiper/internal/codegen/generator/csharp/helpers.go new file mode 100644 index 00000000..2836af83 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/helpers.go @@ -0,0 +1,57 @@ +package csharp + +import ( + "strings" + "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 { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + + switch goType { + 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 toPascalCase(goType) + } +} + +func writeFileHeader() string { + return `// Auto-generated VIIPER C# SDK +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +` +} diff --git a/viiper/internal/codegen/generator/csharp/project.go b/viiper/internal/codegen/generator/csharp/project.go new file mode 100644 index 00000000..8443f734 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/project.go @@ -0,0 +1,61 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + "viiper/internal/codegen/meta" +) + +const projectTemplate = ` + + + net8.0 + Viiper.Client + enable + latest + enable + + Viiper.Client + {{.Version}} + Peter Repukat + VIIPER Client SDK for C# + MIT + https://github.com/Alia5/VIIPER + https://github.com/Alia5/VIIPER + git + viiper;usbip;virtual-device;input-emulation;hid + + + +` + +func generateProject(logger *slog.Logger, projectDir string, md *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() + + 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/viiper/internal/codegen/generator/csharp/types.go b/viiper/internal/codegen/generator/csharp/types.go new file mode 100644 index 00000000..795b8ac8 --- /dev/null +++ b/viiper/internal/codegen/generator/csharp/types.go @@ -0,0 +1,85 @@ +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + "viiper/internal/codegen/meta" +) + +const dtoTemplate = `{{writeFileHeader}}using System.Text.Json.Serialization; + +namespace Viiper.Client.Types; + +{{range .DTOs}} +/// +/// {{.Name}} DTO for management API +/// +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() + + 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 == "slice" || strings.HasPrefix(typeStr, "[]") { + elemType := strings.TrimPrefix(typeStr, "[]") + csharpElemType := goTypeToCSharp(elemType) + return csharpElemType + "[]" + } + + if typeKind == "struct" { + return toPascalCase(typeStr) + } + + return goTypeToCSharp(typeStr) +} diff --git a/viiper/internal/codegen/generator/generator.go b/viiper/internal/codegen/generator/generator.go index 208f543e..cb068198 100644 --- a/viiper/internal/codegen/generator/generator.go +++ b/viiper/internal/codegen/generator/generator.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" cgen "viiper/internal/codegen/generator/c" + "viiper/internal/codegen/generator/csharp" "viiper/internal/codegen/meta" "viiper/internal/codegen/scanner" ) @@ -16,7 +17,14 @@ type Generator struct { logger *slog.Logger } -// New creates a new Generator instance +// LanguageGenerator defines a function that generates an SDK for a language into outputDir. +type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Metadata) error + +var generators = map[string]LanguageGenerator{ + "c": cgen.Generate, + "csharp": csharp.Generate, +} + func New(outputDir string, logger *slog.Logger) *Generator { return &Generator{ outputDir: outputDir, @@ -24,6 +32,47 @@ func New(outputDir string, logger *slog.Logger) *Generator { } } +func (g *Generator) GenAll() error { + for lang := range generators { + if err := g.GenerateLang(lang); err != nil { + return fmt.Errorf("generate %s SDK: %w", lang, err) + } + } + return nil +} + +// GenerateLang runs the SDK generator for the provided language key. +// It scans metadata once per invocation and passes it to the language-specific generator. +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 SDK", "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("SDK generation complete", "language", lang, "output", outputPath) + return nil +} + // ScanAll runs all scanners to collect metadata func (g *Generator) ScanAll() (*meta.Metadata, error) { g.logger.Info("Scanning codebase for metadata") @@ -32,7 +81,6 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { DevicePackages: make(map[string]*scanner.DeviceConstants), } - // Scan routes g.logger.Debug("Scanning API routes") routes, err := scanner.ScanRoutesInPackage("internal/cmd") if err != nil { @@ -41,7 +89,6 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { 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 { @@ -50,7 +97,6 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { 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) @@ -82,7 +128,6 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { "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 { @@ -100,40 +145,3 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { 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 index 58915375..46c26e60 100644 --- a/viiper/internal/codegen/scanner/constants.go +++ b/viiper/internal/codegen/scanner/constants.go @@ -28,7 +28,7 @@ type MapInfo struct { // DeviceConstants holds all constants and maps for a device package type DeviceConstants struct { - DeviceType string `json:"deviceType"` // "keyboard", "mouse", "xbox360" + DeviceType string `json:"deviceType"` Constants []ConstantInfo `json:"constants"` Maps []MapInfo `json:"maps"` } @@ -216,11 +216,10 @@ func extractValue(expr ast.Expr) interface{} { return val } case token.CHAR: - val := strings.Trim(e.Value, "'") - if len(val) == 1 { - return val + if unquoted, err := strconv.Unquote(e.Value); err == nil { + return unquoted } - return val + return strings.Trim(e.Value, "'") } case *ast.Ident: return e.Name diff --git a/viiper/pkg/device/keyboard/codemap.go b/viiper/pkg/device/keyboard/codemap.go deleted file mode 100644 index 566a395c..00000000 --- a/viiper/pkg/device/keyboard/codemap.go +++ /dev/null @@ -1,160 +0,0 @@ -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, -} diff --git a/viiper/pkg/device/keyboard/const.go b/viiper/pkg/device/keyboard/const.go index 4d8348d6..24b01e40 100644 --- a/viiper/pkg/device/keyboard/const.go +++ b/viiper/pkg/device/keyboard/const.go @@ -175,3 +175,162 @@ const ( 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, +} From 754cc2fdf428834e14d8ec9c69043b545d486e83 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 20:25:48 +0100 Subject: [PATCH 008/298] Update docs --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 251a55a5..85cc5115 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,5 +39,5 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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) +- ✅ Multiple client SDKs for easy integration; see [Client SDKs](api/overview.md) MIT Licensed From 5664e83b31336ba085c4a54bfb991ebab6798d1c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 22:12:43 +0100 Subject: [PATCH 009/298] Typescript SDK Gen Changelog(feat) --- docs/api/overview.md | 7 +- docs/cli/codegen.md | 26 - docs/clients/generator.md | 10 +- docs/clients/typescript.md | 477 ++++++++++++++++++ examples/typescript/.gitignore | 2 + examples/typescript/package-lock.json | 76 +++ examples/typescript/package.json | 21 + examples/typescript/tsconfig.json | 11 + examples/typescript/virtual_keyboard.ts | 186 +++++++ examples/typescript/virtual_mouse.ts | 172 +++++++ examples/typescript/virtual_x360_pad.ts | 176 +++++++ mkdocs.yml | 1 + .../internal/codegen/generator/generator.go | 6 +- .../generator/typescript/binary_utils.go | 56 ++ .../codegen/generator/typescript/client.go | 153 ++++++ .../codegen/generator/typescript/constants.go | 248 +++++++++ .../generator/typescript/device_types.go | 171 +++++++ .../generator/typescript/device_wrapper.go | 63 +++ .../codegen/generator/typescript/gen.go | 79 +++ .../codegen/generator/typescript/helpers.go | 26 + .../codegen/generator/typescript/index.go | 82 +++ .../codegen/generator/typescript/project.go | 83 +++ .../codegen/generator/typescript/types.go | 67 +++ 23 files changed, 2162 insertions(+), 37 deletions(-) create mode 100644 docs/clients/typescript.md create mode 100644 examples/typescript/.gitignore create mode 100644 examples/typescript/package-lock.json create mode 100644 examples/typescript/package.json create mode 100644 examples/typescript/tsconfig.json create mode 100644 examples/typescript/virtual_keyboard.ts create mode 100644 examples/typescript/virtual_mouse.ts create mode 100644 examples/typescript/virtual_x360_pad.ts create mode 100644 viiper/internal/codegen/generator/typescript/binary_utils.go create mode 100644 viiper/internal/codegen/generator/typescript/client.go create mode 100644 viiper/internal/codegen/generator/typescript/constants.go create mode 100644 viiper/internal/codegen/generator/typescript/device_types.go create mode 100644 viiper/internal/codegen/generator/typescript/device_wrapper.go create mode 100644 viiper/internal/codegen/generator/typescript/gen.go create mode 100644 viiper/internal/codegen/generator/typescript/helpers.go create mode 100644 viiper/internal/codegen/generator/typescript/index.go create mode 100644 viiper/internal/codegen/generator/typescript/project.go create mode 100644 viiper/internal/codegen/generator/typescript/types.go diff --git a/docs/api/overview.md b/docs/api/overview.md index 505ed03d..ee78146c 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -5,9 +5,12 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de !!! 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 SDK](../clients/c.md): Generated C library with type-safe device streams + - [C# SDK](../clients/csharp.md): Generated .NET library with async/await support + - [TypeScript SDK](../clients/typescript.md): Generated Node.js library with EventEmitter streams + The documentation below is provided for reference and for implementing clients in languages not yet supported by the generator. diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index b59708ed..96e9c957 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -62,32 +62,6 @@ viiper codegen --lang=csharp 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 diff --git a/docs/clients/generator.md b/docs/clients/generator.md index 0b341080..82abefb0 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -217,20 +217,16 @@ Run codegen when any of these change: - **C**: `#define` macros for constants; switch-based lookup functions for maps; manual memory management for variable-length fields; builds with CMake. - **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; `Map` or plain objects for maps; manual byte encoding; ESM/CJS compatible. +- **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`. -## Current SDK Status - -- **C**: ✅ Complete -- **C#**: ✅ Complete -- **TypeScript**: 🚧 Planned ## Further Reading - [Design Document](../design.md): Architectural rationale and detailed generation strategy +- [Go Client Documentation](go.md): Go reference client usage - [C SDK Documentation](c.md): C-specific usage, build, and examples - [C# SDK Documentation](csharp.md): C#-specific usage, async patterns, and map helpers -- [Go Client Documentation](go.md): Go reference client usage +- [TypeScript SDK Documentation](typescript.md): TypeScript-specific usage, EventEmitter patterns, and examples --- diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md new file mode 100644 index 00000000..c3fc258b --- /dev/null +++ b/docs/clients/typescript.md @@ -0,0 +1,477 @@ +# TypeScript SDK Documentation + +The VIIPER TypeScript SDK provides a modern, type-safe Node.js client library for interacting with VIIPER servers and controlling virtual devices. + +## Overview + +The TypeScript SDK features: + +- **Type-safe API**: Structured request/response types with proper TypeScript definitions +- **Event-driven**: EventEmitter-based output handling for device feedback (LEDs, rumble) +- **Auto-generated**: Generated from server code with device-specific Input/Output classes +- **Modern Node.js**: Targets Node.js 18+ with ES modules +- **Zero external dependencies**: Uses only built-in Node.js libraries + +!!! note "License" + The TypeScript 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 TypeScript SDK is generated from the VIIPER server codebase: + +```bash +cd viiper +go run ./cmd/viiper codegen --lang=typescript +``` + +Build the SDK: + +```bash +cd ../clients/typescript +npm install +npm run build +``` + +### Adding to Your Project + +**Project Reference (recommended for local development):** + +```json +{ + "dependencies": { + "viiperclient": "file:../../clients/typescript" + } +} +``` + +**NPM Package (when available):** + +```bash +npm install viiperclient +``` + +## Quick Start + +```typescript +import { ViiperClient, Keyboard } from "viiperclient"; + +const { KeyboardInput, Key, Mod } = Keyboard; + +// Connect to management API +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("my-bus"); + busID = resp.busId; +} else { + busID = busesResp.buses[0]; +} + +// Add device and connect +const { device, response } = await client.addDeviceAndConnect(busID, "keyboard"); +const deviceId = response.id.split('-')[1]; // Extract device ID from "busId-devId" + +console.log(`Connected to device ${response.id}`); + +// Send keyboard input +const input = new KeyboardInput({ + Modifiers: Mod.LeftShift, + Count: 1, + Keys: [Key.H] +}); +await device.send(input); + +// Cleanup +await client.busdeviceremove(busID, deviceId); +``` + +## Device Stream API + +### Creating a Device Stream + +The simplest way to add a device and connect: + +```typescript +const { device, response } = await client.addDeviceAndConnect(busID, "xbox360"); +const deviceId = response.id.split('-')[1]; +``` + +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 Output (Events) + +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(); +``` + +### 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 { LED } = Keyboard; + +const { LED } = Keyboard; + +const numLock = (leds & LED.NumLock) !== 0; +const capsLock = (leds & LED.CapsLock) !== 0; +``` + +### Helper Maps + +The SDK 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 +``` + +## Practical Example: Typing Text + +Using the generated maps to type a string: + +```typescript +import { ViiperDevice, Keyboard } from "viiperclient"; + +const { KeyboardInput, CharToKeyGet, ShiftCharsHas, Mod } = Keyboard; + +async function typeString(device: ViiperDevice, text: string): Promise { + for (const ch of text) { + const cp = ch.codePointAt(0)!; + const key = CharToKeyGet(cp); + if (key === undefined) continue; + + const mods = ShiftCharsHas(cp) ? Mod.LeftShift : 0; + + // Press + await device.send(new KeyboardInput({ + Modifiers: mods, + Count: 1, + Keys: [key] + })); + await new Promise(r => setTimeout(r, 50)); + + // Release + await device.send(new KeyboardInput({ + Modifiers: 0, + Count: 0, + Keys: [] + })); + await new Promise(r => setTimeout(r, 50)); + } +} + +// Usage +await typeString(device, "Hello, World!"); +``` + +## Device-Specific Wire Formats + +### Keyboard Input + +```typescript +interface KeyboardInput { + Modifiers: number; // Modifier flags (Ctrl, Shift, Alt, GUI) + Count: number; // Number of keys in Keys array + Keys: number[]; // Key codes (max 6 for HID compliance) +} +``` + +**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) + +### Keyboard Output (LEDs) + +```typescript +// Single byte with LED flags +const leds = data.readUInt8(0); +const numLock = (leds & LED.NumLock) !== 0; +``` + +### Xbox360 Input + +```typescript +interface Xbox360Input { + Buttons: number; // Button flags + Lt: number; // Left trigger (0-255) + Rt: number; // Right trigger (0-255) + Lx: number; // Left stick X (-32768 to 32767) + Ly: number; // Left stick Y (-32768 to 32767) + Rx: number; // Right stick X (-32768 to 32767) + Ry: number; // Right stick Y (-32768 to 32767) +} +``` + +**Wire format:** Fixed 14 bytes, packed structure + +### Xbox360 Output (Rumble) + +```typescript +// Two bytes: left motor + right motor (0-255 each) +const leftMotor = data.readUInt8(0); +const rightMotor = data.readUInt8(1); +``` + +### Mouse Input + +```typescript +interface MouseInput { + Buttons: number; // Button flags + Dx: number; // Relative X movement (-32768 to 32767) + Dy: number; // Relative Y movement (-32768 to 32767) + Wheel: number; // Vertical scroll (-128 to 127) + Pan: number; // Horizontal scroll (-128 to 127) +} +``` + +**Wire format:** Fixed 8 bytes, packed structure + +## Configuration and Advanced Usage + +### Custom Port + +```typescript +const client = new ViiperClient("localhost", 3242); +``` + +Default port is 3242 if not specified. + +### 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}`); +} +``` + +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 +``` + +## Troubleshooting + +TODO + +## See Also + +- [Generator Documentation](generator.md): How generated SDKs work +- [Go SDK Documentation](go.md): Reference implementation patterns +- [C# SDK Documentation](csharp.md): Alternative managed language SDK +- [C SDK Documentation](c.md): Alternative SDK for native integration +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details + +--- + +For questions or contributions, see the main VIIPER repository. 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..3618bb59 --- /dev/null +++ b/examples/typescript/virtual_keyboard.ts @@ -0,0 +1,186 @@ +import { ViiperClient, ViiperDevice, Keyboard } from "viiperclient"; + +const { KeyboardInput, Key, Mod, CharToKeyGet, ShiftCharsHas } = Keyboard; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Minimal example: create a keyboard device, type "Hello!" + Enter every 5 seconds, monitor LEDs. +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) { + let createErr: any; + busID = 0; + for (let tryBus = 1; tryBus <= 100; tryBus++) { + try { + const r = await client.buscreate(String(tryBus)); + busID = r.busId; + createdBus = true; + break; + } catch (err) { + createErr = err; + } + } + if (busID === 0) { + console.error(`BusCreate failed: ${createErr}`); + process.exit(1); + } + console.log(`Created bus ${busID}`); + } 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 deviceBusId: string; + try { + const { device, response: addResp } = await client.addDeviceAndConnect(busID, "keyboard"); + dev = device; + deviceBusId = addResp.id; + console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(String(busID)).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceBusId); + console.log(`Removed device ${deviceBusId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(String(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..d2c56a08 --- /dev/null +++ b/examples/typescript/virtual_mouse.ts @@ -0,0 +1,172 @@ +import { ViiperClient, ViiperDevice, Mouse } from "viiperclient"; + +const { MouseInput, Btn_ } = Mouse; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Minimal example: ensure a bus, create a mouse device, stream inputs, clean up on exit. +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) { + let createErr: any; + busID = 0; + for (let tryBus = 1; tryBus <= 100; tryBus++) { + try { + const r = await client.buscreate(String(tryBus)); + busID = r.busId; + createdBus = true; + break; + } catch (err) { + createErr = err; + } + } + if (busID === 0) { + console.error(`BusCreate failed: ${createErr}`); + process.exit(1); + } + console.log(`Created bus ${busID}`); + } 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 deviceBusId: string; + try { + const { device, response: addResp } = await client.addDeviceAndConnect(busID, "mouse"); + dev = device; + deviceBusId = addResp.id; + console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(String(busID)).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceBusId); + console.log(`Removed device ${deviceBusId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(String(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 + 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..ee95d400 --- /dev/null +++ b/examples/typescript/virtual_x360_pad.ts @@ -0,0 +1,176 @@ +import { ViiperClient, ViiperDevice, Xbox360 } from "viiperclient"; + +const { Xbox360Input, Button } = Xbox360; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Minimal example: ensure a bus, create an xbox360 device, stream inputs, read rumble, clean up on exit. +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) { + let createErr: any; + busID = 0; + for (let tryBus = 1; tryBus <= 100; tryBus++) { + try { + const r = await client.buscreate(String(tryBus)); + busID = r.busId; + createdBus = true; + break; + } catch (err) { + createErr = err; + } + } + if (busID === 0) { + console.error(`BusCreate failed: ${createErr}`); + process.exit(1); + } + console.log(`Created bus ${busID}`); + } 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 deviceBusId: string; + try { + const { device, response: addResp } = await client.addDeviceAndConnect(busID, "xbox360"); + dev = device; + deviceBusId = addResp.id; + console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + } catch (err) { + console.error(`AddDeviceAndConnect error: ${err}`); + if (createdBus) { + await client.busremove(String(busID)).catch(() => {}); + } + process.exit(1); + } + + // Cleanup function + const cleanup = async () => { + try { + dev.close(); + await client.busdeviceremove(busID, deviceBusId); + console.log(`Removed device ${deviceBusId}`); + } catch (err) { + console.error(`DeviceRemove error: ${err}`); + } + if (createdBus) { + try { + await client.busremove(String(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/mkdocs.yml b/mkdocs.yml index 179fe4a7..1b4e3675 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Generator Documentation: clients/generator.md - C SDK: clients/c.md - C# SDK: clients/csharp.md + - TypeScript SDK: clients/typescript.md - Devices: - Xbox 360 Controller: devices/xbox360.md - Keyboard: devices/keyboard.md diff --git a/viiper/internal/codegen/generator/generator.go b/viiper/internal/codegen/generator/generator.go index cb068198..9461614a 100644 --- a/viiper/internal/codegen/generator/generator.go +++ b/viiper/internal/codegen/generator/generator.go @@ -7,6 +7,7 @@ import ( "path/filepath" cgen "viiper/internal/codegen/generator/c" "viiper/internal/codegen/generator/csharp" + "viiper/internal/codegen/generator/typescript" "viiper/internal/codegen/meta" "viiper/internal/codegen/scanner" ) @@ -21,8 +22,9 @@ type Generator struct { type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Metadata) error var generators = map[string]LanguageGenerator{ - "c": cgen.Generate, - "csharp": csharp.Generate, + "c": cgen.Generate, + "csharp": csharp.Generate, + "typescript": typescript.Generate, } func New(outputDir string, logger *slog.Logger) *Generator { diff --git a/viiper/internal/codegen/generator/typescript/binary_utils.go b/viiper/internal/codegen/generator/typescript/binary_utils.go new file mode 100644 index 00000000..1128b75a --- /dev/null +++ b/viiper/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() + 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/viiper/internal/codegen/generator/typescript/client.go b/viiper/internal/codegen/generator/typescript/client.go new file mode 100644 index 00000000..e64d21b5 --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/client.go @@ -0,0 +1,153 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" + "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'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * VIIPER management API client for bus and device control + */ +export class ViiperClient { + private host: string; + private port: number; + + constructor(host: string, port: number = 3242) { + this.host = host; + this.port = port; + } +{{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 .Arguments}}const payload = {{range $i, $arg := .Arguments}}{{if $i}} + ' ' + {{end}}String({{toCamelCase $arg.Name}}){{end}};{{else}}const payload: string | null = null;{{end}} + {{if .ResponseDTO}}return await this.sendRequest(path, payload);{{else}}await this.sendRequest(path, payload); return true;{{end}} + } +{{end}}{{end}} + private sendRequest(path: string, payload?: string | null): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + let line = path.toLowerCase(); + if (payload && payload.length > 0) line += ' ' + payload; + line += '\n'; + socket.write(encoder.encode(line)); + }); + + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += decoder.decode(chunk); + if (buffer.includes('\n')) { + const json = buffer.replace(/\n.*/, ''); + try { + const obj = JSON.parse(json) as T; + resolve(obj); + } catch (e) { + reject(e); + } finally { + socket.end(); + } + } + }); + + socket.on('error', reject); + socket.on('end', () => {/* noop */}); + }); + } + + async connectDevice(busId: number, devId: string): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + const line = ` + "`" + `bus/${busId}/${devId}\n` + "`" + `; + socket.write(encoder.encode(line)); + resolve(new ViiperDevice(socket)); + }); + socket.on('error', reject); + }); + } + + /** + * AddDeviceAndConnect creates a device on the specified bus and immediately connects to its stream. + * This is a convenience wrapper that combines busdeviceadd + connectDevice in one call. + */ + async addDeviceAndConnect(busId: number, deviceType: string): Promise<{ device: ViiperDevice; response: Types.DeviceAddResponse }> { + const resp = await this.busdeviceadd(busId, deviceType); + + // Parse device ID from response (format: "busId-devId") + const parts = resp.id.split('-'); + if (parts.length < 2) { + throw new Error(` + "`" + `Invalid device ID format: ${resp.id}` + "`" + `); + } + const devId = parts.slice(1).join('-'); + + 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, + "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() + 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))) + } + for _, arg := range route.Arguments { + tsType := goTypeToTS(arg.Type) + if arg.Optional { + params = append(params, fmt.Sprintf("%s?: %s", common.ToCamelCase(arg.Name), tsType)) + } else { + params = append(params, fmt.Sprintf("%s: %s", common.ToCamelCase(arg.Name), tsType)) + } + } + if len(params) == 0 { + return "" + } + return strings.Join(params, ", ") +} diff --git a/viiper/internal/codegen/generator/typescript/constants.go b/viiper/internal/codegen/generator/typescript/constants.go new file mode 100644 index 00000000..d1c8b3aa --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/constants.go @@ -0,0 +1,248 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" + "viiper/internal/codegen/scanner" +) + +type tsEnumGroup struct { + Name string + Type string + Constants []tsConstInfo +} + +type tsConstInfo struct { + Name 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 + Maps []tsMapData + }{Device: pascalDevice, Enums: enums, Maps: maps} + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() + 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 { + prefix := common.ExtractPrefix(c.Name) + if prefix == "" { + continue + } + g := groups[prefix] + if g == nil { + g = &tsEnumGroup{Name: prefix, Type: "number"} + groups[prefix] = g + } + name := strings.TrimPrefix(c.Name, prefix) + if len(name) > 0 && name[0] >= '0' && name[0] <= '9' { + name = "Num" + name + } + g.Constants = append(g.Constants, tsConstInfo{Name: name, 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 formatConstValueTS(v interface{}) string { + switch t := v.(type) { + case int64: + return fmt.Sprintf("0x%X", t) + case uint64: + return fmt.Sprintf("0x%X", t) + case int: + 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 := make([]string, 0, len(m.Entries)) + for k := range m.Entries { + keys = append(keys, k) + } + sort.Strings(keys) + 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": + // Prefer enum symbolic key if provided (e.g., Key.A) + if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { + prefix := common.ExtractPrefix(key) + if prefix != "" { + member := strings.TrimPrefix(key, prefix) + if len(member) > 0 { + if member[0] >= '0' && member[0] <= '9' { + member = "Num" + member + } + return fmt.Sprintf("%s.%s", prefix, member) + } + } + } + // Otherwise, emit numeric key code for literal chars/escapes + 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, " ") { + prefix := common.ExtractPrefix(str) + if prefix != "" { + member := strings.TrimPrefix(str, prefix) + if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { + member = "Num" + member + } + return fmt.Sprintf("%s.%s", prefix, 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 .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/viiper/internal/codegen/generator/typescript/device_types.go b/viiper/internal/codegen/generator/typescript/device_types.go new file mode 100644 index 00000000..061dab04 --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/device_types.go @@ -0,0 +1,171 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" + "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 + GoType string + Writer string + Reader string + IsArray bool + CountName string +} + +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() + data := struct { + Device string + ClassName string + Fields []tsWireField + HasArray bool + ArrayInfo *tsWireField + }{Device: device, ClassName: className} + for _, field := range tag.Fields { + wf := tsWireField{Name: common.ToPascalCase(field.Name), GoType: field.Type, Writer: writerFor(field.Type), Reader: readerFor(field.Type)} + if strings.Contains(field.Spec, "*") { + parts := strings.Split(field.Spec, "*") + if len(parts) == 2 { + data.HasArray = true + wf.IsArray = true + wf.CountName = common.ToPascalCase(parts[1]) + copy := wf + data.ArrayInfo = © + } + } + 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 .GoType "u64") (eq .GoType "i64")}}bigint{{else}}number{{end}}{{if .IsArray}}[]{{end}}; +{{end}} + constructor(init: Partial<{{.Device}}{{.ClassName}}> = {}) { + Object.assign(this, init); + } + write(writer: BinaryWriter): void { +{{if .HasArray}} // Write fixed-size fields +{{range .Fields}}{{if not .IsArray}} writer.{{.Writer}}(this.{{.Name}} as any); +{{end}}{{end}} // Write variable-length array + for (let i = 0; i < (this.{{.ArrayInfo.CountName}} as number); i++) { + writer.{{.ArrayInfo.Writer}}((this.{{.ArrayInfo.Name}} as any[])[i]); + } +{{else}}{{range .Fields}} writer.{{.Writer}}(this.{{.Name}} as any); +{{end}}{{end}} } + static read(reader: BinaryReader): {{.Device}}{{.ClassName}} { +{{if .HasArray}} // Read fixed-size fields +{{range .Fields}}{{if not .IsArray}} const {{.Name | toCamelTS}} = reader.{{.Reader}}(); +{{end}}{{end}} const {{.ArrayInfo.Name | toCamelTS}}: any[] = new Array(Number({{.ArrayInfo.CountName | toCamelTS}})); + for (let i = 0; i < Number({{.ArrayInfo.CountName | toCamelTS}}); i++) { + {{.ArrayInfo.Name | toCamelTS}}[i] = reader.{{.ArrayInfo.Reader}}(); + } + return new {{.Device}}{{.ClassName}}({ +{{range .Fields}}{{if not .IsArray}} {{.Name}}: {{.Name | toCamelTS}}, +{{end}}{{end}} {{.ArrayInfo.Name}}: {{.ArrayInfo.Name | toCamelTS}}, + }); +{{else}} return new {{.Device}}{{.ClassName}}({ +{{range .Fields}} {{.Name}}: reader.{{.Reader}}(), +{{end}} }); +{{end}} } +} +` diff --git a/viiper/internal/codegen/generator/typescript/device_wrapper.go b/viiper/internal/codegen/generator/typescript/device_wrapper.go new file mode 100644 index 00000000..f5521d0a --- /dev/null +++ b/viiper/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, BinaryReader } 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/viiper/internal/codegen/generator/typescript/gen.go b/viiper/internal/codegen/generator/typescript/gen.go new file mode 100644 index 00000000..4d806266 --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/gen.go @@ -0,0 +1,79 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" +) + +// Generate produces the TypeScript SDK layout under outputDir. +// It creates a Node package with the following structure: +// - package.json, tsconfig.json +// - src/index.ts +// - src/ViiperClient.ts (management API) +// - src/ViiperDevice.ts (device stream wrapper) +// - src/types/ManagementDtos.ts (DTOs) +// - src/devices//{Input.ts,Output.ts,Constants.ts} +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 := 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 := generateDeviceIndex(logger, deviceDir, deviceName); err != nil { + return err + } + } + + if err := common.GenerateLicense(logger, projectDir); err != nil { + return err + } + + logger.Info("Generated TypeScript SDK", "dir", projectDir) + return nil +} diff --git a/viiper/internal/codegen/generator/typescript/helpers.go b/viiper/internal/codegen/generator/typescript/helpers.go new file mode 100644 index 00000000..00c8f54c --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/helpers.go @@ -0,0 +1,26 @@ +package typescript + +import ( + "strings" + "viiper/internal/codegen/common" +) + +// goTypeToTS maps Go types to TypeScript types +func goTypeToTS(goType string) string { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + switch goType { + case "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": + return "number" + case "bool": + return "boolean" + case "string": + return "string" + default: + return common.ToPascalCase(goType) + } +} + +func writeFileHeaderTS() string { + return "// Auto-generated VIIPER TypeScript SDK\n// DO NOT EDIT - This file is generated from the VIIPER server codebase\n\n" +} diff --git a/viiper/internal/codegen/generator/typescript/index.go b/viiper/internal/codegen/generator/typescript/index.go new file mode 100644 index 00000000..0744dfac --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/index.go @@ -0,0 +1,82 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" +) + +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'; +` + +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() + 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 := "" + for _, char := range deviceName { + if len(pascalName) == 0 { + pascalName += string(char - 32) + } else { + pascalName += string(char) + } + } + + hasOutput := false + outputPath := filepath.Join(deviceDir, pascalName+"Output.ts") + if _, err := os.Stat(outputPath); err == nil { + hasOutput = 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() + + tmpl := template.Must(template.New("deviceIndex").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(deviceIndexTemplate)) + + data := struct { + PascalName string + HasOutput bool + }{ + PascalName: pascalName, + HasOutput: hasOutput, + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute device index template: %w", err) + } + return nil +} diff --git a/viiper/internal/codegen/generator/typescript/project.go b/viiper/internal/codegen/generator/typescript/project.go new file mode 100644 index 00000000..71e8293f --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/project.go @@ -0,0 +1,83 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +const packageTemplate = `{ + "name": "viiperclient", + "version": "{{.Version}}", + "description": "VIIPER Client SDK for TypeScript/Node.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Alia5/VIIPER" + }, + "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" + }, + "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(packageTemplate) + if err != nil { + return "{}" + } + var buf strings.Builder + _ = tmpl.Execute(&buf, struct{ Version string }{Version: version}) + return buf.String() +} diff --git a/viiper/internal/codegen/generator/typescript/types.go b/viiper/internal/codegen/generator/typescript/types.go new file mode 100644 index 00000000..b70fb3d4 --- /dev/null +++ b/viiper/internal/codegen/generator/typescript/types.go @@ -0,0 +1,67 @@ +package typescript + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + "viiper/internal/codegen/common" + "viiper/internal/codegen/meta" +) + +const dtoTemplateTS = `{{writeFileHeaderTS}} +// Management API DTO interfaces + +{{range .DTOs}} +// {{.Name}} DTO for management API +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() + 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 == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + return goTypeToTS(elem) + "[]" + } + if typeKind == "struct" { + return common.ToPascalCase(typeStr) + } + return goTypeToTS(typeStr) +} From e7f84a39bd7b75772beda4cdab088ec6f815953a Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 23:45:39 +0100 Subject: [PATCH 010/298] Cleanup --- viiper/internal/codegen/common/enums.go | 38 +++++++++++++++++++ viiper/internal/codegen/common/fileheader.go | 10 +++++ viiper/internal/codegen/common/gotypes.go | 19 ++++++++++ .../internal/codegen/generator/c/helpers.go | 4 +- .../codegen/generator/csharp/constants.go | 35 +++++------------ .../codegen/generator/csharp/helpers.go | 15 ++------ .../codegen/generator/typescript/constants.go | 35 +++++------------ .../generator/typescript/device_wrapper.go | 2 +- .../codegen/generator/typescript/helpers.go | 12 ++---- .../codegen/generator/typescript/index.go | 10 +---- 10 files changed, 101 insertions(+), 79 deletions(-) create mode 100644 viiper/internal/codegen/common/enums.go create mode 100644 viiper/internal/codegen/common/fileheader.go create mode 100644 viiper/internal/codegen/common/gotypes.go diff --git a/viiper/internal/codegen/common/enums.go b/viiper/internal/codegen/common/enums.go new file mode 100644 index 00000000..277809f3 --- /dev/null +++ b/viiper/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/viiper/internal/codegen/common/fileheader.go b/viiper/internal/codegen/common/fileheader.go new file mode 100644 index 00000000..d6067b5e --- /dev/null +++ b/viiper/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 SDK\n%s DO NOT EDIT - This file is generated from the VIIPER server codebase\n\n", commentPrefix, langLabel, commentPrefix) +} diff --git a/viiper/internal/codegen/common/gotypes.go b/viiper/internal/codegen/common/gotypes.go new file mode 100644 index 00000000..04b6ebec --- /dev/null +++ b/viiper/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/generator/c/helpers.go b/viiper/internal/codegen/generator/c/helpers.go index 546b08a7..1bcdca7d 100644 --- a/viiper/internal/codegen/generator/c/helpers.go +++ b/viiper/internal/codegen/generator/c/helpers.go @@ -188,7 +188,9 @@ func mapFuncImpl(device string, mapInfo scanner.MapInfo) string { builder.WriteString(" if (!out_value) return 0;\n") builder.WriteString(" switch (key) {\n") - for keyStr, value := range mapInfo.Entries { + keys := common.SortedStringKeys(mapInfo.Entries) + for _, keyStr := range keys { + value := mapInfo.Entries[keyStr] cKey := formatCMapKey(keyStr, mapInfo.KeyType, device) cValue := formatCMapValue(value, mapInfo.ValueType, device) builder.WriteString(fmt.Sprintf(" case %s: *out_value = %s; return 1;\n", cKey, cValue)) diff --git a/viiper/internal/codegen/generator/csharp/constants.go b/viiper/internal/codegen/generator/csharp/constants.go index 39780500..72fab9ec 100644 --- a/viiper/internal/codegen/generator/csharp/constants.go +++ b/viiper/internal/codegen/generator/csharp/constants.go @@ -32,7 +32,7 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, outputPath := filepath.Join(deviceDir, pascalDevice+"Constants.cs") enumGroups := groupConstantsByPrefix(deviceConsts.Constants) - maps := convertMapsToCShar(deviceConsts.Maps) + maps := convertMapsToCSharp(deviceConsts.Maps) data := struct { Device string @@ -105,10 +105,7 @@ func groupConstantsByPrefix(constants []scanner.ConstantInfo) []enumGroup { } } - name := strings.TrimPrefix(c.Name, prefix) - if len(name) > 0 && name[0] >= '0' && name[0] <= '9' { - name = "Num" + name - } + _, name := common.TrimPrefixAndSanitize(c.Name) groups[prefix].Constants = append(groups[prefix].Constants, constantInfo{ Name: name, Value: formatConstValue(c.Value), @@ -243,7 +240,7 @@ func inferValueTypeFromEntries(entries map[string]interface{}) string { return commonPrefix } -func convertMapsToCShar(maps []scanner.MapInfo) []mapData { +func convertMapsToCSharp(maps []scanner.MapInfo) []mapData { result := make([]mapData, 0, len(maps)) for _, m := range maps { @@ -262,11 +259,7 @@ func convertMapsToCShar(maps []scanner.MapInfo) []mapData { Entries: make([]mapEntry, 0, len(m.Entries)), } - keys := make([]string, 0, len(m.Entries)) - for k := range m.Entries { - keys = append(keys, k) - } - sort.Strings(keys) + keys := common.SortedStringKeys(m.Entries) for _, k := range keys { v := m.Entries[k] @@ -315,13 +308,9 @@ func formatMapKey(key string, goType string) string { return fmt.Sprintf("(byte)0x%02X", key[0]) } if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { - prefix := common.ExtractPrefix(key) - if prefix != "" { - member := strings.TrimPrefix(key, prefix) - if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { - member = "Num" + member - } - return fmt.Sprintf("(byte)%s.%s", prefix, member) + if pfx := common.ExtractPrefix(key); pfx != "" { + _, member := common.TrimPrefixAndSanitize(key) + return fmt.Sprintf("(byte)%s.%s", pfx, member) } } return key @@ -336,13 +325,9 @@ func formatMapValue(value interface{}, goType string) string { switch goType { case "byte", "uint8": if str, ok := value.(string); ok && !strings.Contains(str, " ") { - prefix := common.ExtractPrefix(str) - if prefix != "" { - member := strings.TrimPrefix(str, prefix) - if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { - member = "Num" + member - } - return fmt.Sprintf("%s.%s", prefix, member) + if pfx := common.ExtractPrefix(str); pfx != "" { + _, member := common.TrimPrefixAndSanitize(str) + return fmt.Sprintf("%s.%s", pfx, member) } return str } diff --git a/viiper/internal/codegen/generator/csharp/helpers.go b/viiper/internal/codegen/generator/csharp/helpers.go index 2836af83..9709eb04 100644 --- a/viiper/internal/codegen/generator/csharp/helpers.go +++ b/viiper/internal/codegen/generator/csharp/helpers.go @@ -1,7 +1,6 @@ package csharp import ( - "strings" "viiper/internal/codegen/common" ) @@ -14,10 +13,9 @@ func toCamelCase(s string) string { } func goTypeToCSharp(goType string) string { - goType = strings.TrimPrefix(goType, "*") - goType = strings.TrimPrefix(goType, "[]") + base, _, _ := common.NormalizeGoType(goType) - switch goType { + switch base { case "uint8": return "byte" case "uint16": @@ -45,13 +43,8 @@ func goTypeToCSharp(goType string) string { case "byte": return "byte" default: - return toPascalCase(goType) + return toPascalCase(base) } } -func writeFileHeader() string { - return `// Auto-generated VIIPER C# SDK -// DO NOT EDIT - This file is generated from the VIIPER server codebase - -` -} +func writeFileHeader() string { return common.FileHeader("//", "C#") } diff --git a/viiper/internal/codegen/generator/typescript/constants.go b/viiper/internal/codegen/generator/typescript/constants.go index d1c8b3aa..efb23077 100644 --- a/viiper/internal/codegen/generator/typescript/constants.go +++ b/viiper/internal/codegen/generator/typescript/constants.go @@ -80,11 +80,8 @@ func groupConstants(constants []scanner.ConstantInfo) []tsEnumGroup { g = &tsEnumGroup{Name: prefix, Type: "number"} groups[prefix] = g } - name := strings.TrimPrefix(c.Name, prefix) - if len(name) > 0 && name[0] >= '0' && name[0] <= '9' { - name = "Num" + name - } - g.Constants = append(g.Constants, tsConstInfo{Name: name, Value: formatConstValueTS(c.Value)}) + _, member := common.TrimPrefixAndSanitize(c.Name) + g.Constants = append(g.Constants, tsConstInfo{Name: member, Value: formatConstValueTS(c.Value)}) } var result []tsEnumGroup for _, g := range groups { @@ -117,11 +114,7 @@ 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 := make([]string, 0, len(m.Entries)) - for k := range m.Entries { - keys = append(keys, k) - } - sort.Strings(keys) + 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)}) @@ -152,14 +145,10 @@ func formatMapKeyTS(key string, goType string) string { case "byte", "uint8": // Prefer enum symbolic key if provided (e.g., Key.A) if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { - prefix := common.ExtractPrefix(key) - if prefix != "" { - member := strings.TrimPrefix(key, prefix) - if len(member) > 0 { - if member[0] >= '0' && member[0] <= '9' { - member = "Num" + member - } - return fmt.Sprintf("%s.%s", prefix, member) + if pfx := common.ExtractPrefix(key); pfx != "" { + _, member := common.TrimPrefixAndSanitize(key) + if member != "" { + return fmt.Sprintf("%s.%s", pfx, member) } } } @@ -193,13 +182,9 @@ func formatMapValueTS(value interface{}, goType string) string { switch goType { case "byte", "uint8": if str, ok := value.(string); ok && !strings.Contains(str, " ") { - prefix := common.ExtractPrefix(str) - if prefix != "" { - member := strings.TrimPrefix(str, prefix) - if len(member) > 0 && member[0] >= '0' && member[0] <= '9' { - member = "Num" + member - } - return fmt.Sprintf("%s.%s", prefix, member) + if pfx := common.ExtractPrefix(str); pfx != "" { + _, member := common.TrimPrefixAndSanitize(str) + return fmt.Sprintf("%s.%s", pfx, member) } return str } diff --git a/viiper/internal/codegen/generator/typescript/device_wrapper.go b/viiper/internal/codegen/generator/typescript/device_wrapper.go index f5521d0a..b907a025 100644 --- a/viiper/internal/codegen/generator/typescript/device_wrapper.go +++ b/viiper/internal/codegen/generator/typescript/device_wrapper.go @@ -10,7 +10,7 @@ import ( func generateDeviceWrapper(logger *slog.Logger, srcDir string) error { logger.Debug("Generating ViiperDevice.ts stream wrapper") content := writeFileHeaderTS() + `import { Socket } from 'net'; -import { BinaryWriter, BinaryReader } from './utils/binary'; +import { BinaryWriter } from './utils/binary'; import { EventEmitter } from 'events'; export interface IBinarySerializable { diff --git a/viiper/internal/codegen/generator/typescript/helpers.go b/viiper/internal/codegen/generator/typescript/helpers.go index 00c8f54c..ad5ec862 100644 --- a/viiper/internal/codegen/generator/typescript/helpers.go +++ b/viiper/internal/codegen/generator/typescript/helpers.go @@ -1,15 +1,13 @@ package typescript import ( - "strings" "viiper/internal/codegen/common" ) // goTypeToTS maps Go types to TypeScript types func goTypeToTS(goType string) string { - goType = strings.TrimPrefix(goType, "*") - goType = strings.TrimPrefix(goType, "[]") - switch goType { + base, _, _ := common.NormalizeGoType(goType) + switch base { case "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": return "number" case "bool": @@ -17,10 +15,8 @@ func goTypeToTS(goType string) string { case "string": return "string" default: - return common.ToPascalCase(goType) + return common.ToPascalCase(base) } } -func writeFileHeaderTS() string { - return "// Auto-generated VIIPER TypeScript SDK\n// DO NOT EDIT - This file is generated from the VIIPER server codebase\n\n" -} +func writeFileHeaderTS() string { return common.FileHeader("//", "TypeScript") } diff --git a/viiper/internal/codegen/generator/typescript/index.go b/viiper/internal/codegen/generator/typescript/index.go index 0744dfac..5d4351ef 100644 --- a/viiper/internal/codegen/generator/typescript/index.go +++ b/viiper/internal/codegen/generator/typescript/index.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "text/template" + "viiper/internal/codegen/common" ) const indexTemplate = `{{writeFileHeaderTS}} @@ -42,14 +43,7 @@ func generateIndex(logger *slog.Logger, srcDir string) error { func generateDeviceIndex(logger *slog.Logger, deviceDir, deviceName string) error { logger.Debug("Generating device index.ts", "device", deviceName) - pascalName := "" - for _, char := range deviceName { - if len(pascalName) == 0 { - pascalName += string(char - 32) - } else { - pascalName += string(char) - } - } + pascalName := common.ToPascalCase(deviceName) hasOutput := false outputPath := filepath.Join(deviceDir, pascalName+"Output.ts") From a6df2d44e6900fb297cf2aef58c89a7f1d6745fb Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 23:56:37 +0100 Subject: [PATCH 011/298] Clarify codegen needs source --- docs/cli/codegen.md | 2 ++ viiper/internal/codegen/generator/generator.go | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index 96e9c957..dbe95af1 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -23,6 +23,8 @@ Then generates client SDKs with: - Per-device encode/decode functions - Typed constants and enums +**Note:** The codegen command requires access to VIIPER source code. It must be executed from the `viiper/` module directory within the repository. + ## Flags ### `--output` diff --git a/viiper/internal/codegen/generator/generator.go b/viiper/internal/codegen/generator/generator.go index 9461614a..f39d77c3 100644 --- a/viiper/internal/codegen/generator/generator.go +++ b/viiper/internal/codegen/generator/generator.go @@ -77,6 +77,13 @@ func (g *Generator) GenerateLang(lang string) error { // ScanAll runs all scanners to collect metadata func (g *Generator) ScanAll() (*meta.Metadata, error) { + requiredPaths := []string{"internal/cmd", "pkg/apitypes", "pkg/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{ From dda7af9e7ffbe1c9b8a0cedaa10ae3a1665f8cc9 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 17 Nov 2025 23:58:55 +0100 Subject: [PATCH 012/298] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c8553d7..0f8923d5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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) +- ✅ Multiple client SDKs (C, C#, Typescript/Javascript) for easy integration; see [Client SDKs](docs/api/overview.md) MIT Licensed ## 🔌 Requirements From b9c05abc3a575353725dc1da947fb9284a2f2364 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 18 Nov 2025 00:51:58 +0100 Subject: [PATCH 013/298] Update pipeline --- .github/scripts/generate-changelog.sh | 26 +++- .github/workflows/clients_ci.yml | 138 ++++++++++++++++++ .github/workflows/release.yml | 95 +++++++++++- .github/workflows/snapshots.yml | 9 +- viiper/internal/codegen/common/readme.go | 38 +++++ viiper/internal/codegen/generator/c/gen.go | 4 + .../internal/codegen/generator/csharp/gen.go | 4 + .../codegen/generator/csharp/project.go | 5 + .../codegen/generator/typescript/gen.go | 4 + .../codegen/generator/typescript/project.go | 5 +- 10 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/clients_ci.yml create mode 100644 viiper/internal/codegen/common/readme.go diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 7ed34d1d..2a7561bb 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -44,8 +44,30 @@ MISC="" 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}') + # Extract changelog type from subject or body (case-insensitive, robust) + changelog_type="" + # Check subject first + if echo "$commit_msg" | grep -iqE 'changelog[(: ]'; then + if echo "$commit_msg" | grep -iqE 'changelog\((feature|feat)\)'; then + changelog_type="feature" + elif echo "$commit_msg" | grep -iqE 'changelog\((fix)\)'; then + changelog_type="fix" + elif echo "$commit_msg" | grep -iqE 'changelog\((misc)\)'; then + changelog_type="misc" + fi + fi + # If not found in subject, check body + if [ -z "$changelog_type" ]; then + if echo "$commit_body" | grep -iqE 'changelog[(: ]'; then + if echo "$commit_body" | grep -iqE 'changelog\((feature|feat)\)'; then + changelog_type="feature" + elif echo "$commit_body" | grep -iqE 'changelog\((fix)\)'; then + changelog_type="fix" + elif echo "$commit_body" | grep -iqE 'changelog\((misc)\)'; then + changelog_type="misc" + fi + fi + fi if [ -n "$changelog_type" ]; then body_content=$(echo "$commit_body" | awk 'BEGIN{IGNORECASE=1} !/changelog[(: ]/ && NF') entry="- $commit_msg" diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml new file mode 100644 index 00000000..10f0264c --- /dev/null +++ b/.github/workflows/clients_ci.yml @@ -0,0 +1,138 @@ +name: SDK CI + +on: + pull_request: + branches: ["**"] + 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 SDK artifacts" + version: + required: false + type: string + default: "" + description: "Override version injected via ldflags (e.g. tag v1.2.3)" + +permissions: + contents: read + +jobs: + sdk: + name: Codegen and smoke builds + runs-on: ubuntu-latest + steps: + - name: Checkout + 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: Run code generation + working-directory: viiper + shell: bash + run: | + set -euo pipefail + if [ -n "${{ inputs.version }}" ]; then + echo "Running codegen with injected version: ${{ inputs.version }}" + go run -ldflags "-X 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: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: | + clients/typescript/package-lock.json + examples/typescript/package-lock.json + + - name: Build TypeScript SDK + 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: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Pack C# SDK (smoke) + 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: Set up CMake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: '3.26.x' + + - name: Build C SDK (smoke) + run: | + cmake -S clients/c -B clients/c/build + cmake --build clients/c/build --config Release --parallel + + - name: Rename TypeScript SDK tarball + if: ${{ inputs.upload_artifacts }} + working-directory: clients/typescript + run: | + for file in viiperclient-*.tgz; do + if [ -f "$file" ]; then + mv "$file" "viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz" + fi + done + + - name: Upload TypeScript SDK tarball + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: typescript-sdk${{ inputs.artifact_suffix }} + path: clients/typescript/viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz + if-no-files-found: error + + - name: Upload C# SDK nupkg + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: csharp-sdk-nupkg${{ inputs.artifact_suffix }} + path: artifacts/nuget/*.nupkg + if-no-files-found: error + + - name: Archive C SDK source + if: ${{ inputs.upload_artifacts }} + run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c . + + - name: Upload C SDK source + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: c-sdk-source${{ inputs.artifact_suffix }} + path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bdc726c..397e2a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write jobs: build: @@ -23,9 +24,17 @@ jobs: mode: release tag_name: ${{ github.ref_name }} + client-sdks: + name: SDK smoke builds and pack + uses: ./.github/workflows/clients_ci.yml + with: + artifact_suffix: "-Release" + upload_artifacts: true + version: ${{ github.ref_name }} + create-release: name: Create Release - needs: [build, generate-changelog] + needs: [build, generate-changelog, client-sdks] runs-on: ubuntu-latest steps: - name: Checkout code @@ -50,10 +59,15 @@ jobs: name_no_suffix="${base%-Release}" for file in "$dir"/*; do if [ -f "$file" ]; then - ext="${file##*.}" + 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 [[ "$file" == *.* ]]; then + if [[ "$filename" == *.* ]]; then cp "$file" "release_files/${fname}.${ext}" else cp "$file" "release_files/${fname}" @@ -64,6 +78,79 @@ jobs: done ls -la release_files/ + - name: Set up Node.js (for npm publish) + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org/' + + - name: Set up .NET SDK (for NuGet publish) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # Acquire short-lived NuGet API key via OIDC Trusted Publishing + - name: NuGet login (OIDC → temp API key) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + # Publish SDKs to npm and NuGet using OIDC trusted publishing + - name: Publish TypeScript SDK to npm + working-directory: release_files + shell: bash + run: | + set -euo pipefail + TS_TARBALL="viiper-typescript-sdk.tgz" + if [ ! -f "$TS_TARBALL" ]; then + echo "ERROR: Missing TypeScript SDK 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 SDK tarball" >&2 + exit 1 + fi + if [ -z "$PKG_NAME" ]; then + echo "ERROR: Failed to extract package name from TypeScript SDK 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# SDK to NuGet + working-directory: release_files + shell: bash + run: | + set -euo pipefail + NUPKG="viiper-csharp-sdk-nupkg.nupkg" + if [ ! -f "$NUPKG" ]; then + echo "ERROR: Missing C# SDK 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: Extract build info id: build_info shell: bash @@ -94,7 +181,7 @@ jobs: ### Changes ${{ needs.generate-changelog.outputs.changelog }} - files: release_files/**/* + files: release_files/* prerelease: false draft: true env: diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 6b2bc3d5..7ae3fd8d 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -21,9 +21,16 @@ jobs: with: mode: snapshot + client-sdks: + name: SDK smoke builds and pack + uses: ./.github/workflows/clients_ci.yml + with: + artifact_suffix: "-Snapshot" + upload_artifacts: true + create-pre-release: name: Create Pre-Release - needs: [build, generate-changelog] + needs: [build, generate-changelog, client-sdks] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') steps: diff --git a/viiper/internal/codegen/common/readme.go b/viiper/internal/codegen/common/readme.go new file mode 100644 index 00000000..84767547 --- /dev/null +++ b/viiper/internal/codegen/common/readme.go @@ -0,0 +1,38 @@ +package common + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const readmeTemplate = `# VIIPER Client SDK + +This is an automatically generated client SDK 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/viiper/internal/codegen/generator/c/gen.go b/viiper/internal/codegen/generator/c/gen.go index aeee8c5f..f2088d23 100644 --- a/viiper/internal/codegen/generator/c/gen.go +++ b/viiper/internal/codegen/generator/c/gen.go @@ -62,6 +62,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } + if err := common.GenerateReadme(logger, outputDir); err != nil { + return err + } + logger.Info("Generated C SDK", "dir", outputDir) return nil } diff --git a/viiper/internal/codegen/generator/csharp/gen.go b/viiper/internal/codegen/generator/csharp/gen.go index 84b50e65..6f80bbb2 100644 --- a/viiper/internal/codegen/generator/csharp/gen.go +++ b/viiper/internal/codegen/generator/csharp/gen.go @@ -68,6 +68,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } + if err := common.GenerateReadme(logger, outputDir); err != nil { + return err + } + logger.Info("Generated C# SDK", "dir", outputDir) return nil } diff --git a/viiper/internal/codegen/generator/csharp/project.go b/viiper/internal/codegen/generator/csharp/project.go index 8443f734..6c61bcad 100644 --- a/viiper/internal/codegen/generator/csharp/project.go +++ b/viiper/internal/codegen/generator/csharp/project.go @@ -27,8 +27,13 @@ const projectTemplate = ` https://github.com/Alia5/VIIPER git viiper;usbip;virtual-device;input-emulation;hid + README.md + + + + ` diff --git a/viiper/internal/codegen/generator/typescript/gen.go b/viiper/internal/codegen/generator/typescript/gen.go index 4d806266..99b92a69 100644 --- a/viiper/internal/codegen/generator/typescript/gen.go +++ b/viiper/internal/codegen/generator/typescript/gen.go @@ -74,6 +74,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } + if err := common.GenerateReadme(logger, projectDir); err != nil { + return err + } + logger.Info("Generated TypeScript SDK", "dir", projectDir) return nil } diff --git a/viiper/internal/codegen/generator/typescript/project.go b/viiper/internal/codegen/generator/typescript/project.go index 71e8293f..3fd23621 100644 --- a/viiper/internal/codegen/generator/typescript/project.go +++ b/viiper/internal/codegen/generator/typescript/project.go @@ -16,7 +16,7 @@ const packageTemplate = `{ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/Alia5/VIIPER" + "url": "git+https://github.com/Alia5/VIIPER.git" }, "main": "dist/index.js", "types": "dist/index.d.ts", @@ -35,6 +35,9 @@ const packageTemplate = `{ "build": "tsc -p .", "clean": "rimraf dist" }, + "publishConfig": { + "access": "public" + }, "devDependencies": { "@types/node": "24.10.1", "rimraf": "6.1.0", From d2bcabb6c8cb6d20f168667c819793b157afdf7e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 18 Nov 2025 02:14:49 +0100 Subject: [PATCH 014/298] Update docs --- README.md | 5 ++++ docs/clients/csharp.md | 45 ++++++++++++++++++++++++--------- docs/clients/typescript.md | 52 +++++++++++++++++++++++++++++--------- 3 files changed, 78 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0f8923d5..55148a59 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ [![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) +[![npm version](https://img.shields.io/npm/v/viiperclient?logo=npm)](https://www.npmjs.com/package/viiperclient) +[![npm downloads](https://img.shields.io/npm/dm/viiperclient?logo=npm&label=downloads)](https://www.npmjs.com/package/viiperclient) +[![NuGet version](https://img.shields.io/nuget/v/Viiper.Client?logo=nuget)](https://www.nuget.org/packages/Viiper.Client/) +[![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) +[![C SDK](https://img.shields.io/badge/C_SDK-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 24dd4f67..9763d695 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -18,36 +18,57 @@ The C# SDK features: ## Installation -### Building from Source +### 1. Using the Published NuGet Package (Recommended) -The C# SDK is generated from the VIIPER server codebase: +Install the stable package: ```bash -cd viiper -go run ./cmd/viiper codegen --lang=csharp +dotnet add package Viiper.Client ``` -Build the SDK: +Package page: [Viiper.Client on NuGet](https://www.nuget.org/packages/Viiper.Client/) + +> 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 -cd ../clients/csharp -dotnet build -c Release Viiper.Client +# 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 + + + ``` -### Adding to Your Project +### 2. Project Reference (For Local Development Against Source) -**Project Reference (recommended for local development):** +Use this when modifying the generator or contributing new device types: ```xml - + ``` -**NuGet Package (when available):** +### 3. Generating from Source (Advanced / Contributors) + +Only required when enhancing VIIPER itself: ```bash -dotnet add package Viiper.Client +cd viiper +go run ./cmd/viiper codegen --lang=csharp +cd ../clients/csharp +dotnet build -c Release Viiper.Client ``` ## Quick Start diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index c3fc258b..97f17133 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -18,26 +18,40 @@ The TypeScript SDK features: ## Installation -### Building from Source +### 1. Using the Published NPM Package (Recommended) -The TypeScript SDK is generated from the VIIPER server codebase: +Install the SDK from the public npm registry: ```bash -cd viiper -go run ./cmd/viiper codegen --lang=typescript +npm install viiperclient ``` -Build the SDK: +Or with pnpm / yarn: ```bash -cd ../clients/typescript -npm install -npm run build +pnpm add viiperclient +# or +yarn add viiperclient +``` + +The latest stable version is tagged as `latest`. + +> 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 ``` -### Adding to Your Project +Package page: [npm: viiperclient](https://www.npmjs.com/package/viiperclient) + +### 2. Local Project Reference (For Development Against Source) -**Project Reference (recommended for local development):** +If you are actively modifying VIIPER or the code generator, link directly: ```json { @@ -47,10 +61,24 @@ npm run build } ``` -**NPM Package (when available):** +Then build locally after regeneration: ```bash -npm install viiperclient +cd clients/typescript +npm install +npm run build +``` + +### 3. Generating from Source (Advanced / Contributors) + +This is only required if you are contributing to VIIPER or adding device types. Normal users should use the npm package. + +```bash +cd viiper +go run ./cmd/viiper codegen --lang=typescript +cd ../clients/typescript +npm install +npm run build ``` ## Quick Start From 9e25046fdda97bf03c61de7377eb28b489a5e121 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 18 Nov 2025 15:33:26 +0100 Subject: [PATCH 015/298] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 55148a59..48afcad6 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,7 @@ 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 . ``` + +## Credits / Inspiration + +[REDACTED-Bus aka ViGEmBus](https://github.com/nefarius/ViGEmBus) - (Retired) Windows kernel-mode driver emulating well-known USB game controllers. From 9ecb04fb8a111c2c69b488fb9ec65ab00b5ba7f1 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 20 Nov 2025 14:56:52 +0100 Subject: [PATCH 016/298] Auto attach devices on localhost Changelog(feat) --- docs/api/overview.md | 6 +++- docs/cli/configuration.md | 4 ++- docs/cli/server.md | 15 ++++++++ viiper/internal/server/api/autoattach.go | 36 +++++++++++++++++++ viiper/internal/server/api/config.go | 3 +- .../server/api/handler/bus_device_add.go | 12 +++++++ viiper/internal/server/api/router.go | 2 ++ viiper/internal/server/api/server.go | 6 +++- viiper/internal/server/usb/server.go | 14 ++++++++ 9 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 viiper/internal/server/api/autoattach.go diff --git a/docs/api/overview.md b/docs/api/overview.md index ee78146c..5d866168 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -52,6 +52,8 @@ The server registers the following commands and streams: - 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. + - 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 (missing tool, non-zero exit) are logged but do not affect the API response. - `bus/{id}/remove ` - Remove a device by its device number on that bus (the part after the dash in the busid string). @@ -184,4 +186,6 @@ For a higher-level experience, see the Go client in `pkg/apiclient/`. 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: -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). +1) Create a bus ➜ 2) Add a device ➜ 3) Connect the device stream ➜ 4) Attach using USBIP by `busid` (see the Server command page for 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/configuration.md b/docs/cli/configuration.md index 59a358a9..a582484e 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -25,6 +25,7 @@ All command-line flags have corresponding environment variables for easier deplo | `VIIPER_USB_ADDR` | `--usb.addr` | `:3241` | USBIP server listen address | | `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_CONNECTION_TIMEOUT` | `--connection-timeout` | `30s` | Connection operation timeout | ### Proxy Configuration @@ -71,7 +72,8 @@ Server: { "api": { "addr": ":3242", - "device-handler-connect-timeout": "5s" + "device-handler-connect-timeout": "5s", + "auto-attach-local-client": true }, "usb": { "addr": ":3241" diff --git a/docs/cli/server.md b/docs/cli/server.md index 0c2116c7..29edcc83 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -59,6 +59,21 @@ Time before auto-cleanup occurs when a device handler has no active connection. viiper server --api.device-handler-timeout=10s ``` +### `--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.auto-attach-local-client=false +``` + ### `--connection-timeout` Connection operation timeout for both USBIP and API servers. diff --git a/viiper/internal/server/api/autoattach.go b/viiper/internal/server/api/autoattach.go new file mode 100644 index 00000000..9b462699 --- /dev/null +++ b/viiper/internal/server/api/autoattach.go @@ -0,0 +1,36 @@ +package api + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strconv" + "viiper/pkg/usbip" +) + +func AttachLocalhostClient(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, + "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 +} diff --git a/viiper/internal/server/api/config.go b/viiper/internal/server/api/config.go index 380bc318..f20e676e 100644 --- a/viiper/internal/server/api/config.go +++ b/viiper/internal/server/api/config.go @@ -5,6 +5,7 @@ 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"` + 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"` + ConnectionTimeout time.Duration `kong:"-"` } diff --git a/viiper/internal/server/api/handler/bus_device_add.go b/viiper/internal/server/api/handler/bus_device_add.go index ebab3a7a..88dea882 100644 --- a/viiper/internal/server/api/handler/bus_device_add.go +++ b/viiper/internal/server/api/handler/bus_device_add.go @@ -68,6 +68,18 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } }() + if apiSrv.Config().AutoAttachLocalClient { + err := api.AttachLocalhostClient( + req.Ctx, + exportMeta, + s.GetListenPort(), + logger, + ) + if err != nil { + logger.Error("failed to auto-attach localhost client", "error", err) + } + } + payload, err := json.Marshal(apitypes.DeviceAddResponse{ ID: fmt.Sprintf("%d-%d", busID, exportMeta.DevId), }) diff --git a/viiper/internal/server/api/router.go b/viiper/internal/server/api/router.go index da20bd29..65371767 100644 --- a/viiper/internal/server/api/router.go +++ b/viiper/internal/server/api/router.go @@ -1,6 +1,7 @@ package api import ( + "context" "log/slog" "net" "strings" @@ -9,6 +10,7 @@ import ( // Request contains route parameters and additional args from the command. type Request struct { + Ctx context.Context Params map[string]string Args []string } diff --git a/viiper/internal/server/api/server.go b/viiper/internal/server/api/server.go index f3b9ca42..1eb31bdf 100644 --- a/viiper/internal/server/api/server.go +++ b/viiper/internal/server/api/server.go @@ -98,6 +98,10 @@ func (a *Server) writeOK(w io.Writer, rest string) { func (a *Server) handleConn(conn net.Conn) { defer conn.Close() + + connCtx, connCancel := context.WithCancel(context.Background()) + defer connCancel() + connLogger := a.logger.With("remote", conn.RemoteAddr().String()) r := bufio.NewReader(conn) w := conn @@ -124,7 +128,7 @@ func (a *Server) handleConn(conn net.Conn) { args := fields[1:] if h, params := a.router.Match(path); h != nil { - req := &Request{Params: params, Args: args} + req := &Request{Ctx: connCtx, Params: params, Args: args} res := &Response{} if err := h(req, res, connLogger); err != nil { connLogger.Error("api handler error", "path", path, "error", err) diff --git a/viiper/internal/server/usb/server.go b/viiper/internal/server/usb/server.go index 2d242dbe..3c3fedbe 100644 --- a/viiper/internal/server/usb/server.go +++ b/viiper/internal/server/usb/server.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net" + "strconv" "strings" "sync" "syscall" @@ -205,6 +206,19 @@ func (s *Server) Close() error { return nil } +// GetListenPort extracts and returns the port number from the server's listen address. +func (s *Server) GetListenPort() uint16 { + _, portStr, err := net.SplitHostPort(s.config.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 { From 20155f7f3396a03c39de33c79e713e2c75a3ad00 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 20 Nov 2025 14:57:28 +0100 Subject: [PATCH 017/298] Fix examples launch.json --- examples/.vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", ], From 7caf3dbacb91fd0e69f155897c9102d1915c95dc Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 01:36:03 +0100 Subject: [PATCH 018/298] **Breaking:** Refactor API / transport (more flexible) - **Breaking:** Return Proper problem+json errors from API (http like) - **Breaking:** Better device_add response (json) - User defined VID/PID (optional) - (misc) Update examples Changelog(feat): --- docs/api/overview.md | 63 ++- docs/clients/c.md | 77 +++- docs/clients/csharp.md | 31 +- docs/clients/typescript.md | 33 +- docs/devices/keyboard.md | 13 +- docs/devices/mouse.md | 13 +- docs/devices/xbox360.md | 15 +- examples/c/virtual_keyboard/main.c | 30 +- examples/c/virtual_x360_pad/main.c | 37 +- examples/csharp/virtual_keyboard/Program.cs | 21 +- examples/csharp/virtual_mouse/Program.cs | 20 +- examples/csharp/virtual_x360_pad/Program.cs | 19 +- examples/go/virtual_keyboard/main.go | 7 +- examples/go/virtual_mouse/main.go | 7 +- examples/go/virtual_x360_pad/main.go | 7 +- examples/typescript/virtual_keyboard.ts | 21 +- examples/typescript/virtual_mouse.ts | 21 +- examples/typescript/virtual_x360_pad.ts | 21 +- scripts/viiper-api.ps1 | 24 +- scripts/viiper-api.sh | 3 +- .../codegen/generator/c/header_common.go | 23 +- .../internal/codegen/generator/c/helpers.go | 316 ++++++++++++- .../codegen/generator/c/source_common.go | 160 +++++-- .../codegen/generator/csharp/client.go | 108 +++-- .../internal/codegen/generator/generator.go | 9 + .../codegen/generator/typescript/client.go | 208 +++++---- viiper/internal/codegen/meta/meta.go | 1 + viiper/internal/codegen/scanner/dtos_test.go | 1 - viiper/internal/codegen/scanner/handlers.go | 279 ------------ viiper/internal/codegen/scanner/payload.go | 420 ++++++++++++++++++ viiper/internal/codegen/scanner/routes.go | 55 +-- .../internal/codegen/scanner/routes_test.go | 160 ++++--- viiper/internal/server/api/device_registry.go | 10 +- .../server/api/device_registry_test.go | 42 +- .../server/api/device_stream_handler_test.go | 24 +- viiper/internal/server/api/errors.go | 29 ++ .../internal/server/api/handler/bus_create.go | 18 +- .../server/api/handler/bus_create_test.go | 12 +- .../server/api/handler/bus_device_add.go | 44 +- .../server/api/handler/bus_device_add_test.go | 79 ++-- .../server/api/handler/bus_device_remove.go | 22 +- .../api/handler/bus_device_remove_test.go | 14 +- .../server/api/handler/bus_devices_list.go | 12 +- .../api/handler/bus_devices_list_test.go | 16 +- .../internal/server/api/handler/bus_remove.go | 12 +- .../server/api/handler/bus_remove_test.go | 14 +- viiper/internal/server/api/router.go | 6 +- viiper/internal/server/api/server.go | 229 ++++++---- viiper/internal/server/api/server_test.go | 21 +- viiper/internal/server/usb/server.go | 50 +-- viiper/internal/testing/api_test_helpers.go | 73 +-- viiper/internal/testing/mocks.go | 36 ++ viiper/pkg/apiclient/client.go | 58 ++- viiper/pkg/apiclient/client_test.go | 102 ++--- viiper/pkg/apiclient/stream.go | 15 +- viiper/pkg/apiclient/stream_test.go | 236 +++++++--- viiper/pkg/apiclient/transport.go | 31 +- viiper/pkg/apiclient/transport_test.go | 173 +++++++- viiper/pkg/apitypes/structs.go | 102 ++++- viiper/pkg/device/keyboard/device.go | 26 +- viiper/pkg/device/keyboard/handler.go | 3 +- viiper/pkg/device/mouse/device.go | 26 +- viiper/pkg/device/mouse/handler.go | 3 +- viiper/pkg/device/options.go | 6 + viiper/pkg/device/xbox360/device.go | 26 +- viiper/pkg/device/xbox360/handler.go | 3 +- viiper/pkg/usb/device.go | 1 + viiper/pkg/usb/usbdesc.go | 64 ++- viiper/pkg/virtualbus/virtualbus.go | 117 ++--- 69 files changed, 2573 insertions(+), 1405 deletions(-) delete mode 100644 viiper/internal/codegen/scanner/handlers.go create mode 100644 viiper/internal/codegen/scanner/payload.go create mode 100644 viiper/internal/server/api/errors.go create mode 100644 viiper/internal/testing/mocks.go create mode 100644 viiper/pkg/device/options.go diff --git a/docs/api/overview.md b/docs/api/overview.md index 5d866168..85deedbe 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -18,10 +18,11 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de - 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" }` +- Request format: a single ASCII/UTF‑8 line terminated by `\n\n` (double newline) +- Routing: path followed by optional payload separated by whitespace (e.g., `bus/list\n\n` or `bus/create 5\n\n`) +- Payload: optional string that can be a JSON object, numeric value, or plain string depending on the endpoint +- Success response: a single line containing a JSON payload (or an empty line for commands that have no payload), followed by `\n`, then 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, followed by `\n`, then connection close Tip: You can experiment with `nc`/`ncat` or PowerShell’s `tcpclient` to send lines and read JSON back. @@ -37,34 +38,41 @@ The server registers the following commands and streams: - Response: `{ "buses": [1, 2, ...] }` - `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. + - Create a new bus. If `busId` (numeric) is provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. + - Payload: Optional numeric bus ID (e.g., `5`) - Response: `{ "busId": }` - `bus/remove ` - Remove a bus and all devices on it. + - Payload: Numeric bus ID (e.g., `1`) - Response: `{ "busId": }` - `bus/{id}/list` - List devices on a bus. - Response: `{ "devices": [{ "busId": 1, "devId": "1", "vid": "0x045e", "pid": "0x028e", "type": "xbox360" }, ...] }` -- `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. +- `bus/{id}/add ` + - Add a device to a bus. + - Payload: JSON object with device creation parameters: `{"type": "", "idVendor": , "idProduct": }` + - Example: `{"type": "xbox360"}` or `{"type": "keyboard", "idVendor": 1234, "idProduct": 5678}` + - Response: JSON device object with fields: `{"busId": , "devId": "", "vid": "0x045e", "pid": "0x028e", "type": "xbox360"}` - 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. - 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 (missing tool, non-zero exit) are logged but do not affect the API response. - `bus/{id}/remove ` - - Remove a device by its device number on that bus (the part after the dash in the busid string). + - Remove a device by its device number on that bus. + - Payload: Numeric device ID (e.g., `1` for device 1-1 on the bus) - Response: `{ "busId": , "devId": "" }` ### Streaming endpoint - 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. +- Handshake: Send the path followed by `\n\n` (e.g., `bus/1/1\n\n`) +- Type: long-lived TCP connection +- 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. #### Xbox 360 controller stream (device type: `xbox360`) @@ -127,21 +135,25 @@ Note on protocol compatibility: ```bash # List buses -printf "bus/list\n" | nc localhost 3242 +printf "bus/list\n\n" | nc localhost 3242 # Create a bus -printf "bus/create\n" | nc localhost 3242 +printf "bus/create\n\n" | nc localhost 3242 # → {"busId":1} +# Create a bus with specific ID +printf "bus/create 5\n\n" | nc localhost 3242 +# → {"busId":5} + # Add a virtual Xbox 360 controller to bus 1 -printf "bus/1/add xbox360\n" | nc localhost 3242 -# → {"id":"1-1"} +printf 'bus/1/add {"type":"xbox360"}\n\n' | nc localhost 3242 +# → {"busId":1,"devId":"1","vid":"0x045e","pid":"0x028e","type":"xbox360"} # List devices on bus 1 -printf "bus/1/list\n" | nc localhost 3242 +printf "bus/1/list\n\n" | nc localhost 3242 ``` -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. +Then, open a second TCP connection for streaming to `bus/1/1` (the API port, not the USBIP port). First send the handshake `bus/1/1\n\n`, then you'll write 14‑byte input packets and read 2‑byte rumble packets. Any language with raw TCP support works. ### WIndows (PowerShell) @@ -154,7 +166,7 @@ VIIPER includes convenience scripts for quick testing and automation: # Use the Invoke-ViiperApi function (or 'viiper' alias) viiper "bus/list" viiper "bus/create" -viiper "bus/1/add xbox360" -Port 3242 -Hostname localhost +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. @@ -165,18 +177,21 @@ The script provides `Invoke-ViiperApi` (alias: `viiper`) for sending commands an 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 double newline terminator + fmt.Fprint(conn, "bus/create\n\n") + + // Read entire response until connection closes + resp, _ := io.ReadAll(conn) + fmt.Println(string(resp)) // {"busId":1}\n } ``` diff --git a/docs/clients/c.md b/docs/clients/c.md index 20d227c5..cda673a0 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -78,23 +78,27 @@ int main(void) { } // Create or find a bus - viiper_bus_list_t buses = {0}; + viiper_bus_list_response_t buses = {0}; err = viiper_bus_list(client, &buses); - uint32_t bus_id = (buses.count > 0) ? buses.buses[0] : 0; + uint32_t bus_id = (buses.BusesCount > 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; + uint32_t desired_id = 1; + err = viiper_bus_create(client, &desired_id, &resp); // NULL for auto-assign + bus_id = resp.BusID; } - // Add device - viiper_device_add_response_t dev_resp = {0}; - err = viiper_device_add(client, bus_id, "keyboard", &dev_resp); - - // Open device stream + // Add device and connect (convenience function) + const char* device_type = "keyboard"; + viiper_device_create_request_t req = { + .Type = &device_type, + .IdVendor = NULL, + .IdProduct = NULL + }; + viiper_device_info_t dev_info = {0}; viiper_device_t* device = NULL; - err = viiper_device_create(client, bus_id, dev_resp.id, &device); + err = viiper_add_device_and_connect(client, bus_id, &req, &dev_info, &device); // Send keyboard input viiper_keyboard_input_t input = { @@ -109,8 +113,13 @@ int main(void) { // Cleanup viiper_device_close(device); - viiper_device_remove(client, bus_id, dev_resp.id); - viiper_client_destroy(client); + + char bus_id_str[32]; + snprintf(bus_id_str, sizeof(bus_id_str), "%u", bus_id); + viiper_device_remove_response_t remove_resp = {0}; + viiper_bus_device_remove(client, bus_id_str, dev_info.DevId, &remove_resp); + + viiper_client_free(client); return 0; } ``` @@ -119,11 +128,49 @@ int main(void) { ### Creating a Device Stream +Manual approach (add device, then connect): + +```c +// Add device first +const char* device_type = "keyboard"; +viiper_device_create_request_t req = { + .Type = &device_type, + .IdVendor = NULL, // Optional: set to specify custom VID + .IdProduct = NULL // Optional: set to specify custom PID +}; + +char bus_id_str[32]; +snprintf(bus_id_str, sizeof(bus_id_str), "%u", bus_id); + +viiper_device_info_t dev_info = {0}; +viiper_error_t err = viiper_bus_device_add(client, bus_id_str, &req, &dev_info); +if (err != VIIPER_OK) { + fprintf(stderr, "Failed to add device: %s\n", viiper_get_error(client)); +} + +// Then connect to its stream +viiper_device_t* device = NULL; +err = viiper_open_stream(client, bus_id, dev_info.DevId, &device); +if (err != VIIPER_OK) { + fprintf(stderr, "Failed to open device stream: %s\n", viiper_get_error(client)); +} +``` + +Convenience approach (add and connect in one call): + ```c +const char* device_type = "xbox360"; +viiper_device_create_request_t req = { + .Type = &device_type, + .IdVendor = NULL, + .IdProduct = NULL +}; + +viiper_device_info_t dev_info = {0}; 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)); +viiper_error_t err = viiper_add_device_and_connect(client, bus_id, &req, &dev_info, &device); +if (err != VIIPER_OK) { + fprintf(stderr, "Failed to add and connect device: %s\n", viiper_get_error(client)); } ``` diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 9763d695..a831f914 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -85,7 +85,8 @@ var buses = await client.BusListAsync(); uint busId; if (buses.Buses.Length == 0) { - var resp = await client.BusCreateAsync("my-bus"); + var resp = await client.BusCreateAsync(null); // null = auto-assign ID + // Or specify ID: await client.BusCreateAsync(5); busId = resp.BusID; } else @@ -94,11 +95,11 @@ else } // Add device and connect -var addResp = await client.BusDeviceAddAsync(busId, "keyboard"); -var deviceId = addResp.ID.Split('-')[1]; // Extract device ID from "busId-devId" -var device = await client.ConnectDeviceAsync(busId, deviceId); +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 {addResp.ID}"); +Console.WriteLine($"Connected to device {deviceResp.BusID}-{deviceResp.DevId}"); // Send keyboard input var input = new KeyboardInput @@ -110,7 +111,7 @@ var input = new KeyboardInput await device.SendAsync(input); // Cleanup -await client.BusDeviceRemoveAsync(busId, deviceId); +await client.BusDeviceRemoveAsync(busId, deviceResp.DevId); ``` ## Device Stream API @@ -120,9 +121,21 @@ await client.BusDeviceRemoveAsync(busId, deviceId); The simplest way to add a device and connect: ```csharp -var addResp = await client.BusDeviceAddAsync(busId, "xbox360"); -var deviceId = addResp.ID.Split('-')[1]; -var device = await client.ConnectDeviceAsync(busId, deviceId); +var deviceReq = new DeviceCreateRequest { Type = "xbox360" }; +var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); +var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); +``` + +With custom VID/PID: + +```csharp +var deviceReq = new DeviceCreateRequest { + Type = "keyboard", + IdVendor = 0x1234, + IdProduct = 0x5678 +}; +var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); +var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); ``` Or connect to an existing device: diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index 97f17133..d009c227 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -95,17 +95,18 @@ const client = new ViiperClient("localhost", 3242); const busesResp = await client.buslist(); let busID: number; if (busesResp.buses.length === 0) { - const resp = await client.buscreate("my-bus"); + 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 { device, response } = await client.addDeviceAndConnect(busID, "keyboard"); -const deviceId = response.id.split('-')[1]; // Extract device ID from "busId-devId" +const deviceReq = { type: "keyboard" }; +const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); -console.log(`Connected to device ${response.id}`); +console.log(`Connected to device ${response.busId}-${response.devId}`); // Send keyboard input const input = new KeyboardInput({ @@ -116,7 +117,7 @@ const input = new KeyboardInput({ await device.send(input); // Cleanup -await client.busdeviceremove(busID, deviceId); +await client.busdeviceremove(busID, response.devId); ``` ## Device Stream API @@ -126,8 +127,26 @@ await client.busdeviceremove(busID, deviceId); The simplest way to add a device and connect: ```typescript -const { device, response } = await client.addDeviceAndConnect(busID, "xbox360"); -const deviceId = response.id.split('-')[1]; +const deviceReq = { type: "xbox360" }; +const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); +``` + +With custom VID/PID: + +```typescript +const deviceReq = { + type: "keyboard", + idVendor: 0x1234, + idProduct: 0x5678 +}; +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: diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index 1f215863..278df587 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -58,11 +58,18 @@ Convenience helpers and key constants are available in the Go package: ## Adding the device -```text -bus/create -bus/1/add keyboard +Using the raw API (see [API Reference](../api/overview.md) for details): + +```bash +# Create a bus +printf "bus/create\n\n" | nc localhost 3242 + +# Add keyboard device with JSON payload +printf 'bus/1/add {"type":"keyboard"}\n\n' | nc localhost 3242 ``` +Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. + ## Examples A runnable example that types “Hello!” followed by Enter every few seconds is provided in `examples/virtual_keyboard/`. diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index 1def933c..c2af6976 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -36,11 +36,18 @@ Buttons persist until changed; motion/wheel deltas are applied once and reset. ## Adding the device -```text -bus/create -bus/1/add mouse +Using the raw API (see [API Reference](../api/overview.md) for details): + +```bash +# Create a bus +printf "bus/create\n\n" | nc localhost 3242 + +# Add mouse device with JSON payload +printf 'bus/1/add {"type":"mouse"}\n\n' | nc localhost 3242 ``` +Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. + ## Examples A runnable example that periodically moves the mouse a short distance, clicks, and scrolls is provided in `examples/virtual_mouse/`. diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 1827a06d..2eae6837 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -15,14 +15,19 @@ See: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) ## Adding the device -Use the API to create a bus and add an Xbox 360 controller: +Use the API to create a bus and add an Xbox 360 controller. Using the raw API (see [API Reference](../api/overview.md) for details): -```text -bus/create -bus/1/add xbox360 +```bash +# Create a bus +printf "bus/create\n\n" | nc localhost 3242 + +# Add xbox360 device with JSON payload +printf 'bus/1/add {"type":"xbox360"}\n\n' | nc localhost 3242 ``` -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. +The API returns a Device object with `busId`, `devId`, and other details. Attach it from a USB/IP client, then open a stream to drive input and receive rumble. + +Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. ## Streaming protocol diff --git a/examples/c/virtual_keyboard/main.c b/examples/c/virtual_keyboard/main.c index 02265dd8..eebca35b 100644 --- a/examples/c/virtual_keyboard/main.c +++ b/examples/c/virtual_keyboard/main.c @@ -118,28 +118,22 @@ int main(int argc, char** argv) 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; + /* Create and connect device in one step */ + viiper_device_info_t addResp; memset(&addResp, 0, sizeof addResp); viiper_device_t* dev = NULL; - rc = viiper_device_create(client, busId, devId, &dev); - viiper_free_device_add_response(&addResp); + const char* deviceType = "keyboard"; + viiper_device_create_request_t createReq = { + .Type = &deviceType, + .IdVendor = NULL, + .IdProduct = NULL + }; + viiper_error_t rc = viiper_add_device_and_connect(client, busId, &createReq, &addResp, &dev); if (rc != VIIPER_OK) { - fprintf(stderr, "device_create failed: %s\n", viiper_get_error(client)); + fprintf(stderr, "add_device_and_connect failed: %s\n", viiper_get_error(client)); viiper_client_free(client); return 1; } + const char* devId = addResp.DevId ? addResp.DevId : "(none)"; + printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); /* Register LED callback */ viiper_device_on_output(dev, on_leds, NULL); diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c index 980d685b..27970338 100644 --- a/examples/c/virtual_x360_pad/main.c +++ b/examples/c/virtual_x360_pad/main.c @@ -107,37 +107,18 @@ int main(int argc, char** argv) 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_info_t addResp; memset(&addResp, 0, sizeof addResp); viiper_device_t* dev = NULL; - rc = viiper_device_create(client, busId, devId, &dev); - viiper_free_device_add_response(&addResp); + const char* deviceType = "xbox360"; + viiper_device_create_request_t createReq = { .Type = &deviceType, .IdVendor = NULL, .IdProduct = NULL }; + viiper_error_t rc = viiper_add_device_and_connect(client, busId, &createReq, &addResp, &dev); if (rc != VIIPER_OK){ - fprintf(stderr, "device_create failed: %s\n", viiper_get_error(client)); + fprintf(stderr, "add_device_and_connect failed: %s\n", viiper_get_error(client)); viiper_client_free(client); return 1; } + const char* devId = addResp.DevId ? addResp.DevId : "(none)"; + printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); /* Register async backchannel callback */ viiper_device_on_output(dev, on_rumble, NULL); @@ -182,11 +163,9 @@ int main(int argc, char** argv) 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){ + if (viiper_bus_remove(client, busId, &br) != VIIPER_OK){ fprintf(stderr, "BusRemove failed (continuing): %s\n", viiper_get_error(client)); } viiper_client_free(client); diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs index f648929a..e07001c7 100644 --- a/examples/csharp/virtual_keyboard/Program.cs +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -29,7 +29,7 @@ { try { - var resp = await client.BusCreateAsync(i.ToString()); + var resp = await client.BusCreateAsync(i); busId = resp.BusID; createdBus = true; break; @@ -53,25 +53,20 @@ } } -string fullDevId; -string devId; +Device deviceInfo; ViiperDevice device; try { // 2) Add device and connect - var add = await client.BusDeviceAddAsync(busId, "keyboard"); - fullDevId = add.ID; // e.g., "1-1" - // Parse "busId-devId" format to extract just the device part - var parts = fullDevId.Split('-'); - devId = parts.Length > 1 ? parts[1] : fullDevId; - device = await client.ConnectDeviceAsync(busId, devId); - Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); + 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.ToString()); } catch { } + try { await client.BusRemoveAsync(busId); } catch { } } Console.WriteLine($"AddDevice/connect error: {ex}"); return; @@ -83,10 +78,10 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } + try { await client.BusDeviceRemoveAsync(deviceInfo.BusID, deviceInfo.DevId); Console.WriteLine($"Removed device {deviceInfo.DevId}"); } catch { } if (createdBus) { - try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } + try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } } } diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs index 2d16c870..b8afa25b 100644 --- a/examples/csharp/virtual_mouse/Program.cs +++ b/examples/csharp/virtual_mouse/Program.cs @@ -1,5 +1,6 @@ using Viiper.Client; using Viiper.Client.Devices.Mouse; +using Viiper.Client.Types; if (args.Length < 1) { @@ -22,7 +23,7 @@ busId = 0; for (uint i = 1; i <= 100; i++) { - try { var r = await client.BusCreateAsync(i.ToString()); busId = r.BusID; createdBus = true; break; } + try { var r = await client.BusCreateAsync(i); busId = r.BusID; createdBus = true; break; } catch (Exception ex) { lastErr = ex; } } if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } @@ -32,19 +33,16 @@ } // Add device and connect -string fullDevId; string devId; ViiperDevice device; +Device resp; ViiperDevice device; try { - var add = await client.BusDeviceAddAsync(busId, "mouse"); - fullDevId = add.ID; // e.g., "1-1" - var parts = fullDevId.Split('-'); - devId = parts.Length > 1 ? parts[1] : fullDevId; - device = await client.ConnectDeviceAsync(busId, devId); - Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); + 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.ToString()); } catch { } } + if (createdBus) { try { await client.BusRemoveAsync(busId); } catch { } } Console.WriteLine($"AddDevice/connect error: {ex}"); return; } @@ -54,8 +52,8 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } - if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } } + 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 { } } } // Send movement/click/scroll every 3s diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs index 6ead87e5..e1b2fe2c 100644 --- a/examples/csharp/virtual_x360_pad/Program.cs +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -23,7 +23,7 @@ busId = 0; for (uint i = 1; i <= 100; i++) { - try { var r = await client.BusCreateAsync(i.ToString()); busId = r.BusID; createdBus = true; break; } + try { var r = await client.BusCreateAsync(i); busId = r.BusID; createdBus = true; break; } catch (Exception ex) { lastErr = ex; } } if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } @@ -33,19 +33,16 @@ } // Add device and connect -string fullDevId; string devId; ViiperDevice device; +Device resp; ViiperDevice device; try { - var add = await client.BusDeviceAddAsync(busId, "xbox360"); - fullDevId = add.ID; // e.g., "1-1" - var parts = fullDevId.Split('-'); - devId = parts.Length > 1 ? parts[1] : fullDevId; - device = await client.ConnectDeviceAsync(busId, devId); - Console.WriteLine($"Created and connected to device {fullDevId} on bus {busId}"); + 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.ToString()); } catch { } } + if (createdBus) { try { await client.BusRemoveAsync(busId); } catch { } } Console.WriteLine($"AddDevice/connect error: {ex}"); return; } @@ -55,8 +52,8 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(busId, devId); Console.WriteLine($"Removed device {fullDevId}"); } catch { } - if (createdBus) { try { await client.BusRemoveAsync(busId.ToString()); Console.WriteLine($"Removed bus {busId}"); } catch { } } + 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 (2 bytes) and log diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 06cbe752..88b4c063 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -61,7 +61,7 @@ func main() { } // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "keyboard") + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "keyboard", nil) if err != nil { fmt.Printf("AddDeviceAndConnect error: %v\n", err) if createdBus { @@ -71,15 +71,14 @@ func main() { } defer stream.Close() - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) + 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 %s\n", deviceBusId) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevId) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 2fd27854..7115379b 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -58,7 +58,7 @@ func main() { } // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "mouse") + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "mouse", nil) if err != nil { fmt.Printf("AddDeviceAndConnect error: %v\n", err) if createdBus { @@ -68,15 +68,14 @@ func main() { } defer stream.Close() - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) + 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 %s\n", deviceBusId) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevId) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 6a4cd6ce..26867eb3 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -61,7 +61,7 @@ func main() { } // Add device and connect to stream in one call - stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "xbox360") + stream, addResp, err := api.AddDeviceAndConnect(ctx, busID, "xbox360", nil) if err != nil { fmt.Printf("AddDeviceAndConnect error: %v\n", err) if createdBus { @@ -71,15 +71,14 @@ func main() { } defer stream.Close() - deviceBusId := addResp.ID - fmt.Printf("Created and connected to device %s on bus %d\n", deviceBusId, busID) + 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 %s\n", deviceBusId) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevId) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/typescript/virtual_keyboard.ts b/examples/typescript/virtual_keyboard.ts index 3618bb59..8de71360 100644 --- a/examples/typescript/virtual_keyboard.ts +++ b/examples/typescript/virtual_keyboard.ts @@ -1,4 +1,4 @@ -import { ViiperClient, ViiperDevice, Keyboard } from "viiperclient"; +import { ViiperClient, ViiperDevice, Keyboard, Types } from "viiperclient"; const { KeyboardInput, Key, Mod, CharToKeyGet, ShiftCharsHas } = Keyboard; @@ -27,7 +27,7 @@ async function main() { busID = 0; for (let tryBus = 1; tryBus <= 100; tryBus++) { try { - const r = await client.buscreate(String(tryBus)); + const r = await client.buscreate(tryBus); busID = r.busId; createdBus = true; break; @@ -47,16 +47,17 @@ async function main() { // Add device and connect to stream in one call let dev: ViiperDevice; - let deviceBusId: string; + let deviceDevId: string; try { - const { device, response: addResp } = await client.addDeviceAndConnect(busID, "keyboard"); + const req: Types.DeviceCreateRequest = { type: "keyboard" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); dev = device; - deviceBusId = addResp.id; - console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + 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(String(busID)).catch(() => {}); + await client.busremove(busID).catch(() => {}); } process.exit(1); } @@ -65,14 +66,14 @@ async function main() { const cleanup = async () => { try { dev.close(); - await client.busdeviceremove(busID, deviceBusId); - console.log(`Removed device ${deviceBusId}`); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); } catch (err) { console.error(`DeviceRemove error: ${err}`); } if (createdBus) { try { - await client.busremove(String(busID)); + await client.busremove(busID); console.log(`Removed bus ${busID}`); } catch (err) { console.error(`BusRemove error: ${err}`); diff --git a/examples/typescript/virtual_mouse.ts b/examples/typescript/virtual_mouse.ts index d2c56a08..6be0fde5 100644 --- a/examples/typescript/virtual_mouse.ts +++ b/examples/typescript/virtual_mouse.ts @@ -1,4 +1,4 @@ -import { ViiperClient, ViiperDevice, Mouse } from "viiperclient"; +import { ViiperClient, ViiperDevice, Mouse, Types } from "viiperclient"; const { MouseInput, Btn_ } = Mouse; @@ -27,7 +27,7 @@ async function main() { busID = 0; for (let tryBus = 1; tryBus <= 100; tryBus++) { try { - const r = await client.buscreate(String(tryBus)); + const r = await client.buscreate(tryBus); busID = r.busId; createdBus = true; break; @@ -47,16 +47,17 @@ async function main() { // Add device and connect to stream in one call let dev: ViiperDevice; - let deviceBusId: string; + let deviceDevId: string; try { - const { device, response: addResp } = await client.addDeviceAndConnect(busID, "mouse"); + const req: Types.DeviceCreateRequest = { type: "mouse" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); dev = device; - deviceBusId = addResp.id; - console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + 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(String(busID)).catch(() => {}); + await client.busremove(busID).catch(() => {}); } process.exit(1); } @@ -65,14 +66,14 @@ async function main() { const cleanup = async () => { try { dev.close(); - await client.busdeviceremove(busID, deviceBusId); - console.log(`Removed device ${deviceBusId}`); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); } catch (err) { console.error(`DeviceRemove error: ${err}`); } if (createdBus) { try { - await client.busremove(String(busID)); + await client.busremove(busID); console.log(`Removed bus ${busID}`); } catch (err) { console.error(`BusRemove error: ${err}`); diff --git a/examples/typescript/virtual_x360_pad.ts b/examples/typescript/virtual_x360_pad.ts index ee95d400..4d271bbc 100644 --- a/examples/typescript/virtual_x360_pad.ts +++ b/examples/typescript/virtual_x360_pad.ts @@ -1,4 +1,4 @@ -import { ViiperClient, ViiperDevice, Xbox360 } from "viiperclient"; +import { ViiperClient, ViiperDevice, Xbox360, Types } from "viiperclient"; const { Xbox360Input, Button } = Xbox360; @@ -27,7 +27,7 @@ async function main() { busID = 0; for (let tryBus = 1; tryBus <= 100; tryBus++) { try { - const r = await client.buscreate(String(tryBus)); + const r = await client.buscreate(tryBus); busID = r.busId; createdBus = true; break; @@ -47,16 +47,17 @@ async function main() { // Add device and connect to stream in one call let dev: ViiperDevice; - let deviceBusId: string; + let deviceDevId: string; try { - const { device, response: addResp } = await client.addDeviceAndConnect(busID, "xbox360"); + const req: Types.DeviceCreateRequest = { type: "xbox360" }; + const { device, response: addResp } = await client.addDeviceAndConnect(busID, req); dev = device; - deviceBusId = addResp.id; - console.log(`Created and connected to device ${deviceBusId} on bus ${busID}`); + 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(String(busID)).catch(() => {}); + await client.busremove(busID).catch(() => {}); } process.exit(1); } @@ -65,14 +66,14 @@ async function main() { const cleanup = async () => { try { dev.close(); - await client.busdeviceremove(busID, deviceBusId); - console.log(`Removed device ${deviceBusId}`); + await client.busdeviceremove(busID, deviceDevId); + console.log(`Removed device ${deviceDevId}`); } catch (err) { console.error(`DeviceRemove error: ${err}`); } if (createdBus) { try { - await client.busremove(String(busID)); + await client.busremove(busID); console.log(`Removed bus ${busID}`); } catch (err) { console.error(`BusRemove error: ${err}`); diff --git a/scripts/viiper-api.ps1 b/scripts/viiper-api.ps1 index 9427d1b1..4634202a 100644 --- a/scripts/viiper-api.ps1 +++ b/scripts/viiper-api.ps1 @@ -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 double newline delimiter + $writer.Write($Command) + $writer.Write("`n`n") $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..6e45796a 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 double newline delimiter +if ! OUTPUT=$(printf '%s\n\n' "$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/viiper/internal/codegen/generator/c/header_common.go b/viiper/internal/codegen/generator/c/header_common.go index 1b440624..cd5e87a9 100644 --- a/viiper/internal/codegen/generator/c/header_common.go +++ b/viiper/internal/codegen/generator/c/header_common.go @@ -108,9 +108,9 @@ VIIPER_API const char* viiper_get_error(viiper_client_t* client); /* {{.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}} + const char* {{.}}{{end}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, + {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, + {{responseCType .ResponseDTO}}* out{{end}} ); {{end}} @@ -146,6 +146,23 @@ VIIPER_API void viiper_device_on_output( /* Close device stream and free resources */ VIIPER_API void viiper_device_close(viiper_device_t* device); +/* OpenStream: connect to an existing device's stream channel (device must already exist) */ +VIIPER_API viiper_error_t viiper_open_stream( + viiper_client_t* client, + uint32_t bus_id, + const char* dev_id, + viiper_device_t** out_device +); + +/* Convenience: AddDeviceAndConnect (create device then connect its stream) */ +VIIPER_API viiper_error_t viiper_add_device_and_connect( + viiper_client_t* client, + uint32_t bus_id, + const viiper_device_create_request_t* request, + viiper_device_info_t* out_info, + viiper_device_t** out_device +); + #ifdef __cplusplus } #endif diff --git a/viiper/internal/codegen/generator/c/helpers.go b/viiper/internal/codegen/generator/c/helpers.go index 1bcdca7d..22eb2fe7 100644 --- a/viiper/internal/codegen/generator/c/helpers.go +++ b/viiper/internal/codegen/generator/c/helpers.go @@ -11,20 +11,139 @@ import ( func tplFuncs(md *meta.Metadata) template.FuncMap { return template.FuncMap{ - "ctype": cType, - "snakecase": common.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, - "fieldDecl": fieldDecl, - "pathParams": orderedPathParams, - "join": strings.Join, - "mapFuncDecl": mapFuncDecl, - "mapFuncImpl": mapFuncImpl, + "ctype": cType, + "snakecase": common.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, + "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, + "pathParams": orderedPathParams, + "join": strings.Join, + "mapFuncDecl": mapFuncDecl, + "mapFuncImpl": mapFuncImpl, + "payloadCType": func(pi scanner.PayloadInfo) string { return payloadCType(md, pi) }, + "responseCType": func(name string) string { return responseCType(md, name) }, + "marshalPayload": func(pi scanner.PayloadInfo) string { return marshalPayload(md, pi) }, + "genFreeFunc": func(dto scanner.DTOSchema) string { return generateFreeFunction(md, dto) }, + "genParser": func(dto scanner.DTOSchema) string { return generateParser(md, dto) }, } } +func payloadCType(md *meta.Metadata, pi scanner.PayloadInfo) string { + switch pi.Kind { + case scanner.PayloadJSON: + if pi.RawType != "" { + return fmt.Sprintf("const viiper_%s_t*", dtoToCTypeName(md, pi.RawType)) + } + return "const char*" // fallback to raw JSON string + case scanner.PayloadNumeric: + if pi.Required { + return "uint32_t" + } + return "uint32_t*" + case scanner.PayloadString: + return "const char*" + default: + return "" + } +} + +func responseCType(md *meta.Metadata, dtoName string) string { + if dtoName == "" { + return "" + } + return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, dtoName)) +} + +// dtoToCTypeName converts a DTO name to its C typedef name using metadata mappings +func dtoToCTypeName(md *meta.Metadata, dtoName string) string { + // Check if there's an explicit mapping for this DTO + if md.CTypeNames != nil { + if mapped, ok := md.CTypeNames[dtoName]; ok { + return mapped + } + } + // Default: just convert to snake_case + return common.ToSnakeCase(dtoName) +} + +// marshalPayload generates C code to marshal a struct to JSON string +func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { + if pi.Kind != scanner.PayloadJSON || pi.RawType == "" { + return "" + } + + // Find the DTO in metadata + var dto *scanner.DTOSchema + for i := range md.DTOs { + if md.DTOs[i].Name == pi.RawType { + dto = &md.DTOs[i] + break + } + } + if dto == nil { + return "" + } + + varName := "request" + lines := []string{ + fmt.Sprintf("if (%s) {", varName), + } + + // Start JSON object + firstField := true + for _, f := range dto.Fields { + isPointer := strings.HasPrefix(f.Type, "*") + baseType := strings.TrimPrefix(f.Type, "*") + + var condition string + if !f.Optional { + condition = "" + } else if isPointer { + condition = fmt.Sprintf("if (%s->%s) ", varName, f.Name) + } else { + // Non-pointer optional fields shouldn't exist for JSON payloads + condition = "" + } + + var fieldCode string + switch baseType { + case "string": + if firstField { + fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":\\\"%%s\\\"\", *%s->%s);", + condition, f.JSONName, varName, f.Name) + } else { + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat_s(payload, sizeof(payload), tmp, sizeof(payload) - strlen(payload) - 1); }", + condition, f.JSONName, varName, f.Name) + } + case "uint16", "uint32": + if firstField { + fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":%%u\", (unsigned)*%s->%s);", + condition, f.JSONName, varName, f.Name) + } else { + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat_s(payload, sizeof(payload), tmp, sizeof(payload) - strlen(payload) - 1); }", + condition, f.JSONName, varName, f.Name) + } + default: + // Unsupported type, skip + continue + } + + if firstField { + firstField = false + } + + lines = append(lines, " "+fieldCode) + } + + // Close JSON object + lines = append(lines, " strncat_s(payload, sizeof(payload), \"}\", sizeof(payload) - strlen(payload) - 1);") + lines = append(lines, " }") + + return strings.Join(lines, "\n") +} + func cType(goType, kind string) string { switch { case strings.HasPrefix(goType, "[]"): @@ -58,9 +177,6 @@ func cType(goType, kind string) string { case "float64": return "double" default: - if goType == "Device" { - return "viiper_device_info_t" - } if kind == "struct" { return fmt.Sprintf("viiper_%s_t*", common.ToSnakeCase(goType)) } @@ -154,13 +270,61 @@ func orderedPathParams(path string) []string { return params } -func fieldDecl(f scanner.FieldInfo) string { +func fieldDecl(md *meta.Metadata, f scanner.FieldInfo) string { if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { elem := strings.TrimPrefix(f.Type, "[]") - cElem := cType(elem, "") + cElem := fieldTypeToCType(md, elem) return fmt.Sprintf("%s* %s; size_t %s_count;%s", cElem, f.Name, common.ToSnakeCase(f.Name), optComment(f)) } - return fmt.Sprintf("%s %s;%s", cType(f.Type, f.TypeKind), f.Name, optComment(f)) + + if strings.HasPrefix(f.Type, "*") { + elem := strings.TrimPrefix(f.Type, "*") + cElem := fieldTypeToCType(md, elem) + return fmt.Sprintf("%s* %s;%s", cElem, f.Name, optComment(f)) + } + + return fmt.Sprintf("%s %s;%s", fieldTypeToCType(md, f.Type), f.Name, optComment(f)) +} + +// fieldTypeToCType converts a field type to C type, using metadata for struct type name mapping +func fieldTypeToCType(md *meta.Metadata, goType string) string { + // Check if it's a primitive type first + 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 "int", "int64": + return "int64_t" + case "bool": + return "int" + case "float32": + return "float" + case "float64": + return "double" + } + + // For struct types, check if it's a DTO and use the type name mapping + for _, dto := range md.DTOs { + if dto.Name == goType { + return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, goType)) + } + } + + // Default: assume it's a struct and use snake_case + return fmt.Sprintf("viiper_%s_t", common.ToSnakeCase(goType)) } func optComment(f scanner.FieldInfo) string { @@ -315,3 +479,121 @@ func formatCMapValue(value interface{}, goType string, device string) string { return fmt.Sprintf("%v", value) } } + +// generateFreeFunction creates a free function for a DTO +func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { + snakeName := dtoToCTypeName(md, dto.Name) + lines := []string{ + fmt.Sprintf("VIIPER_API void viiper_free_%s(viiper_%s_t* v){", snakeName, snakeName), + } + + hasPointers := false + for _, f := range dto.Fields { + if strings.HasPrefix(f.Type, "*") || strings.HasPrefix(f.Type, "[]") { + hasPointers = true + break + } + } + + if !hasPointers { + lines = append(lines, " (void)v; }") + return strings.Join(lines, "") + } + + lines = append(lines, " if (!v) return;") + + for _, f := range dto.Fields { + baseType := strings.TrimPrefix(f.Type, "*") + baseType = strings.TrimPrefix(baseType, "[]") + + if strings.HasPrefix(f.Type, "[]") { + // Array type - need to free elements and array itself + if baseType != "string" && baseType != "uint8" && baseType != "uint16" && baseType != "uint32" && baseType != "uint64" { + // Complex type array - need to find the DTO and free each field + lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ ", f.Name, common.ToSnakeCase(f.JSONName))) + // Look up the DTO to find string fields that need freeing + for _, dtoSchema := range md.DTOs { + if dtoSchema.Name == baseType { + for _, field := range dtoSchema.Fields { + fieldType := strings.TrimPrefix(field.Type, "*") + if fieldType == "string" { + lines = append(lines, fmt.Sprintf("if (v->%s[i].%s) free((void*)v->%s[i].%s); ", f.Name, field.Name, f.Name, field.Name)) + } + } + break + } + } + lines = append(lines, " } free(v->"+f.Name+");}") + } else if baseType == "string" { + lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ if (v->%s[i]) free((void*)v->%s[i]); } free(v->%s);}", f.Name, common.ToSnakeCase(f.JSONName), f.Name, f.Name, f.Name)) + } else { + // Primitive array - just free the array + lines = append(lines, fmt.Sprintf(" if (v->%s) free(v->%s);", f.Name, f.Name)) + } + } else if strings.HasPrefix(f.Type, "*") && baseType == "string" { + // Pointer to string + lines = append(lines, fmt.Sprintf(" if (v->%s) free((void*)v->%s);", f.Name, f.Name)) + } + } + + lines = append(lines, " }") + return strings.Join(lines, "") +} + +// generateParser creates a parser function for a DTO +func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { + snakeName := dtoToCTypeName(md, dto.Name) + + // For parsing object responses, use _obj suffix + parserName := fmt.Sprintf("viiper_parse_%s", snakeName) + if strings.HasSuffix(snakeName, "_info") { + parserName = fmt.Sprintf("viiper_parse_%s_obj", snakeName) + } + + lines := []string{ + fmt.Sprintf("static int %s(const char* json, viiper_%s_t* out){", parserName, snakeName), + } + + var parserCalls []string + for _, f := range dto.Fields { + baseType := strings.TrimPrefix(f.Type, "*") + baseType = strings.TrimPrefix(baseType, "[]") + + required := !f.Optional + + if strings.HasPrefix(f.Type, "[]") { + if baseType == "uint32" { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_array_uint32(json, \"%s\", &out->%s, &out->%s_count)", f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) + } else { + // Complex type - use appropriate parser based on type name mapping + parserFuncName := fmt.Sprintf("json_parse_array_%s", dtoToCTypeName(md, baseType)) + parserCalls = append(parserCalls, fmt.Sprintf("%s(json, \"%s\", &out->%s, &out->%s_count)", parserFuncName, f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) + } + } else if baseType == "string" { + if required { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_string_alloc(json, \"%s\", (char**)&out->%s)", f.JSONName, f.Name)) + } else { + // Optional string - don't fail if missing + lines = append(lines, fmt.Sprintf(" json_parse_string_alloc(json, \"%s\", (char**)&out->%s);", f.JSONName, f.Name)) + } + } else if baseType == "uint32" { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_uint32(json, \"%s\", &out->%s)", f.JSONName, f.Name)) + } + } + + if len(parserCalls) == 0 { + lines = append(lines, " return 0; }") + } else if len(parserCalls) == 1 { + lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", parserCalls[0])) + } else { + for i, call := range parserCalls { + if i < len(parserCalls)-1 { + lines = append(lines, fmt.Sprintf(" if (%s!=0) return -1;", call)) + } else { + lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", call)) + } + } + } + + return strings.Join(lines, "") +} diff --git a/viiper/internal/codegen/generator/c/source_common.go b/viiper/internal/codegen/generator/c/source_common.go index 644db39c..8214a02c 100644 --- a/viiper/internal/codegen/generator/c/source_common.go +++ b/viiper/internal/codegen/generator/c/source_common.go @@ -186,7 +186,7 @@ VIIPER_API const char* viiper_get_error(viiper_client_t* client) { } /* ======================================================================== - * Internal networking helpers (line protocol) + * Internal networking helpers * ======================================================================== */ static int viiper_connect(const char* host, uint16_t port) { @@ -229,12 +229,12 @@ static int viiper_send_line(int fd, const char* 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); + wr = send(fd, "\n\n", 2, 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); + wr = send(fd, "\n\n", 2, 0); if (wr < 0) return -1; #endif return 0; @@ -312,21 +312,15 @@ static int viiper_do(viiper_client_t* client, const char* path, const char* payl * ======================================================================== */ /* 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); } +{{range .DTOs}}{{if ne .Name "Device"}} +{{genFreeFunc .}} +{{end}}{{end}} /* 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; } +static int viiper_parse_device_info_obj(const char* json, viiper_device_info_t* out){ if (json_parse_uint32(json, "busId", &out->BusID)!=0) return -1; if (json_parse_string_alloc(json, "devId", (char**)&out->DevId)!=0) return -1; json_parse_string_alloc(json, "vid", (char**)&out->Vid); json_parse_string_alloc(json, "pid", (char**)&out->Pid); json_parse_string_alloc(json, "type", (char**)&out->Type); return 0; } +{{range .DTOs}}{{if ne .Name "Device"}} +{{genParser .}} +{{end}}{{end}} /* ======================================================================== * Management API - Implementations @@ -334,9 +328,9 @@ static int viiper_parse_device_remove_response(const char* json, viiper_device_r {{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}} + const char* {{.}}{{end}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, + {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, + {{responseCType .ResponseDTO}}* out{{end}} ) { if (!client) return VIIPER_ERROR_INVALID_PARAM; /* Build path by substituting params in order */ @@ -356,20 +350,20 @@ VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( } } {{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); + /* Build payload based on PayloadKind */ + char payload[512]; payload[0]='\0'; + {{if eq .Payload.Kind "json"}} + {{marshalPayload .Payload}} + {{else if eq .Payload.Kind "numeric"}}{{if .Payload.Required}} + snprintf(payload, sizeof payload, "%u", (unsigned)payload_value); + {{else}} + if (payload_value) { + snprintf(payload, sizeof payload, "%u", (unsigned)*payload_value); + } + {{end}} + {{else if eq .Payload.Kind "string"}} + if (payload_str && payload_str[0]) { + snprintf(payload, sizeof payload, "%s", payload_str); } {{end}} char* line = NULL; @@ -377,8 +371,8 @@ VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( 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) { + /* rudimentary error detection: {"status":4xx/5xx} (RFC 7807) */ + if (line && strncmp(line, "{\"status\":", 11) == 0) { snprintf(client->error_msg, sizeof client->error_msg, "%s", line); free(line); return VIIPER_ERROR_PROTOCOL; @@ -386,13 +380,7 @@ VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( {{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 eq .ResponseDTO "Device"}}prc = viiper_parse_device_info_obj(line, out);{{else}}prc = viiper_parse_{{snakecase .ResponseDTO}}(line, out);{{end}} if (prc != 0) { snprintf(client->error_msg, sizeof client->error_msg, "parse error"); free(line); return VIIPER_ERROR_PROTOCOL; } } free(line); @@ -452,15 +440,17 @@ VIIPER_API viiper_error_t viiper_device_create( 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 */ + /* Send stream path with double newline terminator for framing */ char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s\n", (unsigned)bus_id, dev_id); - size_t n = strlen(pathbuf); + snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); + if (viiper_send_line(fd, pathbuf) != 0) { #if defined(_WIN32) || defined(_WIN64) - if (send(fd, pathbuf, (int)n, 0) < 0) { closesocket(fd); return VIIPER_ERROR_IO; } + closesocket(fd); #else - if (send(fd, pathbuf, n, 0) < 0) { close(fd); return VIIPER_ERROR_IO; } + close(fd); #endif + return VIIPER_ERROR_IO; + } viiper_device_t* dev = (viiper_device_t*)calloc(1, sizeof(viiper_device_t)); if (!dev) { #if defined(_WIN32) || defined(_WIN64) @@ -549,6 +539,82 @@ VIIPER_API void viiper_device_close(viiper_device_t* device) { if (device->dev_id) free(device->dev_id); free(device); } + +/* OpenStream: connect to an existing device's stream channel (device must already exist on bus) */ +VIIPER_API viiper_error_t viiper_open_stream( + 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; + char pathbuf[256]; + snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); + if (viiper_send_line(fd, pathbuf) != 0) { +#if defined(_WIN32) || defined(_WIN64) + closesocket(fd); +#else + close(fd); +#endif + return VIIPER_ERROR_IO; + } + 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; +#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; +} + +/* Convenience wrapper: AddDeviceAndConnect (create device on bus then open stream) */ +VIIPER_API viiper_error_t viiper_add_device_and_connect( + viiper_client_t* client, + uint32_t bus_id, + const viiper_device_create_request_t* request, + viiper_device_info_t* out_info, + viiper_device_t** out_device +) { + if (!client || !out_info || !out_device) return VIIPER_ERROR_INVALID_PARAM; + char busIdStr[32]; snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)bus_id); + viiper_error_t rc = viiper_bus_device_add(client, busIdStr, request, out_info); + if (rc != VIIPER_OK) return rc; + const char* devId = out_info->DevId ? out_info->DevId : NULL; + if (!devId) return VIIPER_ERROR_PROTOCOL; /* missing devId */ + return viiper_open_stream(client, bus_id, devId, out_device); +} ` func generateCommonSource(logger *slog.Logger, srcDir string, md *meta.Metadata) error { diff --git a/viiper/internal/codegen/generator/csharp/client.go b/viiper/internal/codegen/generator/csharp/client.go index 47b82049..a328886e 100644 --- a/viiper/internal/codegen/generator/csharp/client.go +++ b/viiper/internal/codegen/generator/csharp/client.go @@ -40,13 +40,13 @@ public class ViiperClient : IDisposable {{range .Routes}}{{if eq .Method "Register"}} /// /// {{.Handler}}: {{.Path}} - /// {{range .Arguments}} - /// {{.Type}}{{end}}{{if .ResponseDTO}} + /// {{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}}; - {{if .Arguments}}string? payload = {{range $i, $arg := .Arguments}}{{if $i}} + " " + {{end}}{{toCamelCase $arg.Name}}{{if ne $arg.Type "string"}}?.ToString(){{end}}{{end}};{{else}}string? payload = null;{{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}} } @@ -58,13 +58,13 @@ public class ViiperClient : IDisposable using var stream = client.GetStream(); - // Build command line: "path arg1 arg2 ...\n" (matches Go transport protocol) + // Build command line: "path[ optional-payload]\n\n" (management protocol uses double newline terminator) string commandLine = path.ToLowerInvariant(); if (!string.IsNullOrEmpty(payload)) { commandLine += " " + payload; } - commandLine += "\n"; + commandLine += "\n\n"; var requestBytes = Encoding.UTF8.GetBytes(commandLine); await stream.WriteAsync(requestBytes, cancellationToken); @@ -80,9 +80,14 @@ public class ViiperClient : IDisposable break; } - var responseJson = responseBuilder.ToString().TrimEnd('\n'); - var response = JsonSerializer.Deserialize(responseJson) - ?? throw new InvalidOperationException("Failed to deserialize response"); + 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; } @@ -94,18 +99,18 @@ public class ViiperClient : IDisposable /// 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); - - var stream = client.GetStream(); - var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\n"; - var handshake = Encoding.UTF8.GetBytes(streamPath); - await stream.WriteAsync(handshake, cancellationToken); - - return new ViiperDevice(client, stream); - } + public async Task ConnectDeviceAsync(uint busId, string devId, CancellationToken cancellationToken = default) + { + var client = new TcpClient(); + await client.ConnectAsync(_host, _port, cancellationToken); + var stream = client.GetStream(); + // Streaming handshake uses double newline delimiter (same framing as management). + // Server api/server.go reads until two consecutive '\n'; a single '\n' leaves it waiting. + var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\n\n"; + var handshake = Encoding.UTF8.GetBytes(streamPath); + await stream.WriteAsync(handshake, cancellationToken); + return new ViiperDevice(client, stream); + } public void Dispose() { @@ -126,6 +131,7 @@ func generateClient(logger *slog.Logger, projectDir string, md *meta.Metadata) e "toCamelCase": toCamelCase, "writeFileHeader": writeFileHeader, "generateMethodParams": generateMethodParams, + "payloadParamNameCS": payloadParamNameCS, "lb": func() string { return "{" }, "rb": func() string { return "}" }, } @@ -157,22 +163,66 @@ func generateClient(logger *slog.Logger, projectDir string, md *meta.Metadata) e func generateMethodParams(route scanner.RouteInfo) string { var params []string - for key := range route.PathParams { params = append(params, fmt.Sprintf("uint %s", toCamelCase(key))) } - - for _, arg := range route.Arguments { - csharpType := goTypeToCSharp(arg.Type) - if arg.Optional { - csharpType += "?" + // Add payload parameter if needed + 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" + // crude width mapping + 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", csharpType, toCamelCase(arg.Name))) + 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/viiper/internal/codegen/generator/generator.go b/viiper/internal/codegen/generator/generator.go index f39d77c3..4f053ace 100644 --- a/viiper/internal/codegen/generator/generator.go +++ b/viiper/internal/codegen/generator/generator.go @@ -88,6 +88,7 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { md := &meta.Metadata{ DevicePackages: make(map[string]*scanner.DeviceConstants), + CTypeNames: make(map[string]string), } g.logger.Debug("Scanning API routes") @@ -106,6 +107,14 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { md.DTOs = dtos g.logger.Info("Found DTOs", "count", len(dtos)) + // Build C type name mappings for types that would conflict + for _, dto := range dtos { + // Device conflicts with viiper_device_t (streaming handle), so rename to device_info + if dto.Name == "Device" { + md.CTypeNames["Device"] = "device_info" + } + } + g.logger.Debug("Discovering device packages") deviceBaseDir := "pkg/device" entries, err := os.ReadDir(deviceBaseDir) diff --git a/viiper/internal/codegen/generator/typescript/client.go b/viiper/internal/codegen/generator/typescript/client.go index e64d21b5..656aba20 100644 --- a/viiper/internal/codegen/generator/typescript/client.go +++ b/viiper/internal/codegen/generator/typescript/client.go @@ -22,87 +22,93 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); /** - * VIIPER management API client for bus and device control + * VIIPER management & streaming API client. + * Request framing: [ ]\n\n ; Response framing: single JSON line ending in \n then connection close. */ export class ViiperClient { - private host: string; - private port: number; + private host: string; + private port: number; - constructor(host: string, port: number = 3242) { - this.host = host; - this.port = port; - } + constructor(host: string, port: number = 3242) { + this.host = host; + this.port = port; + } {{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 .Arguments}}const payload = {{range $i, $arg := .Arguments}}{{if $i}} + ' ' + {{end}}String({{toCamelCase $arg.Name}}){{end}};{{else}}const payload: string | null = null;{{end}} - {{if .ResponseDTO}}return await this.sendRequest(path, payload);{{else}}await this.sendRequest(path, payload); return true;{{end}} - } + /** + * {{.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 sendRequest(path: string, payload?: string | null): Promise { - return new Promise((resolve, reject) => { - const socket = new Socket(); - socket.connect(this.port, this.host, () => { - let line = path.toLowerCase(); - if (payload && payload.length > 0) line += ' ' + payload; - line += '\n'; - socket.write(encoder.encode(line)); - }); + private sendRequest(path: string, payload?: string | null): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + let line = path; // preserve case + if (payload && payload.length > 0) line += ' ' + payload; + line += '\n\n'; + socket.write(encoder.encode(line)); + }); - let buffer = ''; - socket.on('data', (chunk: Buffer) => { - buffer += decoder.decode(chunk); - if (buffer.includes('\n')) { - const json = buffer.replace(/\n.*/, ''); - try { - const obj = JSON.parse(json) as T; - resolve(obj); - } catch (e) { - reject(e); - } finally { - socket.end(); - } - } - }); + let buffer = ''; + socket.on('data', (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) { + socket.end(); + reject(e); + return; + } + // Typed error detection (RFC 7807 style) + if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { + socket.end(); + reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); + return; + } + socket.end(); + resolve(parsed as T); + } + }); - socket.on('error', reject); - socket.on('end', () => {/* noop */}); - }); - } + socket.on('error', reject); + socket.on('end', () => {/* noop */}); + }); + } - async connectDevice(busId: number, devId: string): Promise { - return new Promise((resolve, reject) => { - const socket = new Socket(); - socket.connect(this.port, this.host, () => { - const line = ` + "`" + `bus/${busId}/${devId}\n` + "`" + `; - socket.write(encoder.encode(line)); - resolve(new ViiperDevice(socket)); - }); - socket.on('error', reject); - }); - } + async connectDevice(busId: number, devId: string): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + const line = ` + "`" + `bus/${busId}/${devId}\n\n` + "`" + `; + socket.write(encoder.encode(line)); + resolve(new ViiperDevice(socket)); + }); + socket.on('error', reject); + }); + } - /** - * AddDeviceAndConnect creates a device on the specified bus and immediately connects to its stream. - * This is a convenience wrapper that combines busdeviceadd + connectDevice in one call. - */ - async addDeviceAndConnect(busId: number, deviceType: string): Promise<{ device: ViiperDevice; response: Types.DeviceAddResponse }> { - const resp = await this.busdeviceadd(busId, deviceType); - - // Parse device ID from response (format: "busId-devId") - const parts = resp.id.split('-'); - if (parts.length < 2) { - throw new Error(` + "`" + `Invalid device ID format: ${resp.id}` + "`" + `); - } - const devId = parts.slice(1).join('-'); - - const device = await this.connectDevice(busId, devId); - return { device, response: resp }; - } + /** + * 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 }; + } } ` @@ -113,6 +119,7 @@ func generateClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error "writeFileHeaderTS": writeFileHeaderTS, "toCamelCase": common.ToCamelCase, "generateMethodParamsTS": generateMethodParamsTS, + "payloadParamNameTS": payloadParamNameTS, "lb": func() string { return "{" }, "rb": func() string { return "}" }, } @@ -138,16 +145,59 @@ func generateMethodParamsTS(route scanner.RouteInfo) string { for key := range route.PathParams { params = append(params, fmt.Sprintf("%s: number", common.ToCamelCase(key))) } - for _, arg := range route.Arguments { - tsType := goTypeToTS(arg.Type) - if arg.Optional { - params = append(params, fmt.Sprintf("%s?: %s", common.ToCamelCase(arg.Name), tsType)) + // Add payload parameter based on classification + 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: %s", common.ToCamelCase(arg.Name), tsType)) + params = append(params, fmt.Sprintf("%s?: string", name)) } } - if len(params) == 0 { + return strings.Join(params, ", ") +} + +// payloadParamNameTS chooses a descriptive parameter name for the payload. +func payloadParamNameTS(route scanner.RouteInfo) string { + if route.Payload.Kind == scanner.PayloadNone { return "" } - return strings.Join(params, ", ") + // Use ParserHint to derive parameter name (e.g., uint32 -> "busId", DeviceCreateRequest -> "request") + hint := route.Payload.ParserHint + if hint == "" { + return "payload" + } + // Heuristic: numeric hints map to "id", JSON DTOs map to "request" + switch route.Payload.Kind { + case scanner.PayloadNumeric: + // uint32 / uint64 / int likely represent IDs + if strings.Contains(strings.ToLower(hint), "id") || strings.HasPrefix(hint, "uint") || strings.HasPrefix(hint, "int") { + return "id" + } + return "value" + case scanner.PayloadJSON: + // Use raw type name (e.g., DeviceCreateRequest -> request) + if route.Payload.RawType != "" { + return common.ToCamelCase(route.Payload.RawType) + } + return "request" + case scanner.PayloadString: + return "value" + } + return "payload" } diff --git a/viiper/internal/codegen/meta/meta.go b/viiper/internal/codegen/meta/meta.go index 2ce6dc96..f5956caf 100644 --- a/viiper/internal/codegen/meta/meta.go +++ b/viiper/internal/codegen/meta/meta.go @@ -9,4 +9,5 @@ type Metadata struct { DTOs []scanner.DTOSchema DevicePackages map[string]*scanner.DeviceConstants // device name -> constants/maps WireTags *scanner.WireTags // parsed viiper:wire comments + CTypeNames map[string]string // DTO name -> C typedef name (e.g., "Device" -> "device_info") } diff --git a/viiper/internal/codegen/scanner/dtos_test.go b/viiper/internal/codegen/scanner/dtos_test.go index 8299363e..85dc1854 100644 --- a/viiper/internal/codegen/scanner/dtos_test.go +++ b/viiper/internal/codegen/scanner/dtos_test.go @@ -26,7 +26,6 @@ func TestScanDTOs(t *testing.T) { "BusRemoveResponse": true, "Device": true, "DevicesListResponse": true, - "DeviceAddResponse": true, "DeviceRemoveResponse": true, } 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/payload.go b/viiper/internal/codegen/scanner/payload.go new file mode 100644 index 00000000..d325b468 --- /dev/null +++ b/viiper/internal/codegen/scanner/payload.go @@ -0,0 +1,420 @@ +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 && ident.Name == "api" && 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., apitypes.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/routes.go b/viiper/internal/codegen/scanner/routes.go index 696bcd70..61dea35b 100644 --- a/viiper/internal/codegen/scanner/routes.go +++ b/viiper/internal/codegen/scanner/routes.go @@ -15,17 +15,30 @@ type RouteInfo struct { 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 + Payload PayloadInfo `json:"payload"` // payload classification } -// ArgumentInfo describes an argument expected by a route handler. -type ArgumentInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Optional bool `json:"optional"` +// 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) { @@ -72,7 +85,6 @@ func ScanRoutes(filePath string) ([]RouteInfo, error) { Method: methodName, Handler: handlerName, PathParams: pathParams, - Arguments: []ArgumentInfo{}, // Will be enriched later if needed }) return true @@ -138,36 +150,25 @@ func ScanRoutesInPackage(pkgPath string) ([]RouteInfo, error) { // 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) } - + 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 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 } + 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/viiper/internal/codegen/scanner/routes_test.go b/viiper/internal/codegen/scanner/routes_test.go index d15a3603..6b481434 100644 --- a/viiper/internal/codegen/scanner/routes_test.go +++ b/viiper/internal/codegen/scanner/routes_test.go @@ -5,94 +5,84 @@ import ( "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) - } +type testCase struct { + name string + run func(t *testing.T) } -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) +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) + }, + }, } - // 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)) - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { tc.run(t) }) } } diff --git a/viiper/internal/server/api/device_registry.go b/viiper/internal/server/api/device_registry.go index 56642bcd..6c957ab6 100644 --- a/viiper/internal/server/api/device_registry.go +++ b/viiper/internal/server/api/device_registry.go @@ -2,21 +2,16 @@ package api import ( "sync" + "viiper/pkg/device" "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 + CreateDevice(o *device.CreateOptions) 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 } @@ -53,7 +48,6 @@ func GetStreamHandler(name string) StreamHandlerFunc { } 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' { diff --git a/viiper/internal/server/api/device_registry_test.go b/viiper/internal/server/api/device_registry_test.go index 42bde971..b466eb2d 100644 --- a/viiper/internal/server/api/device_registry_test.go +++ b/viiper/internal/server/api/device_registry_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "viiper/internal/server/api" + th "viiper/internal/testing" + "viiper/pkg/device" "viiper/pkg/usb" ) @@ -18,21 +20,14 @@ type mockDevice struct { func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { return nil } - -type mockRegistration struct { - deviceName string - handlerFunc api.StreamHandlerFunc +func (m *mockDevice) GetDescriptor() *usb.Descriptor { + return &usb.Descriptor{} } -func (m *mockRegistration) CreateDevice() usb.Device { - return &mockDevice{name: m.deviceName} -} +func TestDeviceRegistry(t *testing.T) { -func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { - return m.handlerFunc -} + // TODO: test with OPTS -func TestDeviceRegistry(t *testing.T) { tests := []struct { name string registerName string @@ -79,10 +74,12 @@ func TestDeviceRegistry(t *testing.T) { return nil } - reg := &mockRegistration{ - deviceName: tt.expectedDevice, - handlerFunc: mockHandler, - } + reg := th.CreateMockRegistration( + t, + tt.expectedDevice, + func(o *device.CreateOptions) usb.Device { return &mockDevice{name: tt.expectedDevice} }, + mockHandler, + ) api.RegisterDevice(testRegName, reg) @@ -92,7 +89,7 @@ func TestDeviceRegistry(t *testing.T) { if tt.shouldFind { assert.NotNil(t, retrieved, "expected to find registered device") if retrieved != nil { - dev := retrieved.CreateDevice() + dev := retrieved.CreateDevice(nil) mockDev, ok := dev.(*mockDevice) assert.True(t, ok, "expected mockDevice type") if ok { @@ -115,6 +112,9 @@ func TestDeviceRegistry(t *testing.T) { } func TestGetStreamHandler(t *testing.T) { + + // TODO: test with OPTS + tests := []struct { name string registerName string @@ -149,10 +149,12 @@ func TestGetStreamHandler(t *testing.T) { return nil } - reg := &mockRegistration{ - deviceName: tt.registerName, - handlerFunc: mockHandler, - } + reg := th.CreateMockRegistration( + t, + tt.registerName, + func(o *device.CreateOptions) usb.Device { return &mockDevice{name: tt.registerName} }, + mockHandler, + ) testRegName := tt.name + "_" + tt.registerName api.RegisterDevice(testRegName, reg) diff --git a/viiper/internal/server/api/device_stream_handler_test.go b/viiper/internal/server/api/device_stream_handler_test.go index 085bed5a..8a1efe54 100644 --- a/viiper/internal/server/api/device_stream_handler_test.go +++ b/viiper/internal/server/api/device_stream_handler_test.go @@ -14,6 +14,7 @@ import ( "viiper/internal/server/api" srvusb "viiper/internal/server/usb" htesting "viiper/internal/testing" + th "viiper/internal/testing" "viiper/pkg/device" "viiper/pkg/device/xbox360" pusb "viiper/pkg/usb" @@ -28,7 +29,7 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { bus, err := virtualbus.NewWithBusId(90001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New() + dev := xbox360.New(nil) devCtx, err := bus.Add(dev) require.NoError(t, err) @@ -53,13 +54,14 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { 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 { + testReg := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { handlerCalled <- true return nil }, - } + ) + api.RegisterDevice("xbox360", testReg) clientConn, serverConn := net.Pipe() @@ -89,7 +91,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { bus, err := virtualbus.NewWithBusId(70001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New() + dev := xbox360.New(nil) devCtx, err := bus.Add(dev) require.NoError(t, err) meta := device.GetDeviceMeta(devCtx) @@ -107,20 +109,20 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { 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 { + testReg := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + 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) + _, err = fmt.Fprintf(c, "bus/%d/%s\n\n", bus.BusID(), deviceID) require.NoError(t, err) select { diff --git a/viiper/internal/server/api/errors.go b/viiper/internal/server/api/errors.go new file mode 100644 index 00000000..3196a5fd --- /dev/null +++ b/viiper/internal/server/api/errors.go @@ -0,0 +1,29 @@ +package api + +import "viiper/pkg/apitypes" + +// Factory helpers returning *apitypes.ApiError (single canonical error type). +func ErrBadRequest(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} +} +func ErrNotFound(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} +} +func ErrConflict(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} +} +func ErrInternal(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} +} + +// WrapError normalizes any error into *apitypes.ApiError. +func WrapError(err error) *apitypes.ApiError { + if err == nil { + return nil + } + if ae, ok := err.(*apitypes.ApiError); ok { + return ae + } + // Default wrap as internal error + return ErrInternal(err.Error()) +} diff --git a/viiper/internal/server/api/handler/bus_create.go b/viiper/internal/server/api/handler/bus_create.go index 2550e7ab..d953ba28 100644 --- a/viiper/internal/server/api/handler/bus_create.go +++ b/viiper/internal/server/api/handler/bus_create.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "log/slog" "strconv" "viiper/internal/server/api" @@ -14,32 +15,33 @@ import ( // 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 req.Payload != "" { + busId, err := strconv.ParseUint(req.Payload, 10, 32) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } b, err := virtualbus.NewWithBusId(uint32(busId)) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if err := s.AddBus(b); err != nil { - return err + return api.ErrConflict(fmt.Sprintf("bus %d already exists", busId)) } out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { - return err + return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(out) return nil } + b := virtualbus.New() if err := s.AddBus(b); err != nil { - return err + return api.ErrInternal(fmt.Sprintf("failed to add bus: %v", err)) } out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { - return err + return api.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/viiper/internal/server/api/handler/bus_create_test.go index 06efec08..b6ef7e0a 100644 --- a/viiper/internal/server/api/handler/bus_create_test.go +++ b/viiper/internal/server/api/handler/bus_create_test.go @@ -38,7 +38,7 @@ func TestBusCreate(t *testing.T) { } }, payload: "60002", - expectedResponse: `{"error":"bus number 60002 already allocated"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: bus number 60002 already allocated"}`, }, { name: "create after remove allows reuse", @@ -61,13 +61,13 @@ func TestBusCreate(t *testing.T) { name: "invalid bus number", setup: nil, payload: "foo", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"foo\": invalid syntax"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"foo\": invalid syntax"}`, }, { name: "negative bus number", setup: nil, payload: "-1", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"-1\": invalid syntax"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"-1\": invalid syntax"}`, }, } @@ -83,7 +83,11 @@ func TestBusCreate(t *testing.T) { } line, err := c.Do("bus/create", tt.payload, nil) assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) + 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_device_add.go b/viiper/internal/server/api/handler/bus_device_add.go index 88dea882..8f330ee5 100644 --- a/viiper/internal/server/api/handler/bus_device_add.go +++ b/viiper/internal/server/api/handler/bus_device_add.go @@ -18,35 +18,49 @@ 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") + return api.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } b := s.GetBus(uint32(busID)) if b == nil { - return fmt.Errorf("unknown bus") + return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } - if len(req.Args) < 1 { - return fmt.Errorf("missing device type") + if req.Payload == "" { + return api.ErrBadRequest("missing payload") + } + var deviceCreateReq apitypes.DeviceCreateRequest + err = json.Unmarshal([]byte(req.Payload), &deviceCreateReq) + if err != nil { + return api.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + } + if deviceCreateReq.Type == nil { + return api.ErrBadRequest("missing device type") } - name := strings.ToLower(req.Args[0]) + + name := strings.ToLower(*deviceCreateReq.Type) reg := api.GetRegistration(name) if reg == nil { - return fmt.Errorf("unknown device type: %s", name) + return api.ErrBadRequest(fmt.Sprintf("unknown device type: %s", name)) + } + + opts := device.CreateOptions{ + IdVendor: deviceCreateReq.IdVendor, + IdProduct: deviceCreateReq.IdProduct, } - dev := reg.CreateDevice() + dev := reg.CreateDevice(&opts) devCtx, err := b.Add(dev) if err != nil { - return err + return api.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) } exportMeta := device.GetDeviceMeta(devCtx) if exportMeta == nil { - return fmt.Errorf("failed to get device metadata from context") + return api.ErrInternal("failed to get device metadata from context") } connTimer := device.GetConnTimer(devCtx) @@ -80,11 +94,15 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } } - payload, err := json.Marshal(apitypes.DeviceAddResponse{ - ID: fmt.Sprintf("%d-%d", busID, exportMeta.DevId), + payload, err := json.Marshal(apitypes.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, }) if err != nil { - return err + return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(payload) diff --git a/viiper/internal/server/api/handler/bus_device_add_test.go b/viiper/internal/server/api/handler/bus_device_add_test.go index 387948dd..542bb680 100644 --- a/viiper/internal/server/api/handler/bus_device_add_test.go +++ b/viiper/internal/server/api/handler/bus_device_add_test.go @@ -13,22 +13,14 @@ import ( "viiper/internal/server/api" "viiper/internal/server/api/handler" "viiper/internal/server/usb" - handlerTest "viiper/internal/testing" + th "viiper/internal/testing" "viiper/pkg/apiclient" + "viiper/pkg/device" "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 @@ -49,22 +41,52 @@ func TestBusDeviceAdd(t *testing.T) { } }, pathParams: map[string]string{"id": "80001"}, - payload: "xbox360", - expectedResponse: `{"id":"80001-1"}`, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, }, { name: "add device to non-existing bus", setup: nil, pathParams: map[string]string{"id": "99999"}, - payload: "xbox360", - expectedResponse: `{"error":"unknown bus"}`, + 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: "xbox360", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"baz\": invalid syntax"}`, + 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) { + 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) { + 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", @@ -76,7 +98,7 @@ func TestBusDeviceAdd(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device failed: %v", err) } if err := b.RemoveDeviceByID("1"); err != nil { @@ -84,14 +106,14 @@ func TestBusDeviceAdd(t *testing.T) { } }, pathParams: map[string]string{"id": "80005"}, - payload: "xbox360", - expectedResponse: `{"id":"80005-1"}`, + payload: `{"type": "xbox360"}`, + expectedResponse: `{"busId":80005, "devId": "1", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, }, } 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) { + 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)) }) @@ -103,7 +125,7 @@ func TestBusDeviceAdd(t *testing.T) { } line, err := c.Do("bus/{id}/add", tt.payload, tt.pathParams) assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) + assert.JSONEq(t, tt.expectedResponse, line) }) } } @@ -113,7 +135,6 @@ 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)) @@ -133,15 +154,15 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { 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 }, - }) + testReg := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { return nil }, + ) + + api.RegisterDevice("xbox360", testReg) - // Use API client to add device, then wait beyond timeout and verify removal c := apiclient.New(addr) - _, err = c.DeviceAdd(80100, "xbox360") + _, err = c.DeviceAdd(80100, "xbox360", nil) require.NoError(t, err) // Immediately after add, the device should be present (server now registers bus/{id}/list) diff --git a/viiper/internal/server/api/handler/bus_device_remove.go b/viiper/internal/server/api/handler/bus_device_remove.go index f5f1b928..88a713f7 100644 --- a/viiper/internal/server/api/handler/bus_device_remove.go +++ b/viiper/internal/server/api/handler/bus_device_remove.go @@ -15,22 +15,28 @@ 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") + return api.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } - if len(req.Args) < 1 { - return fmt.Errorf("missing device number") + if req.Payload == "" { + return api.ErrBadRequest("missing device number") } - deviceID := req.Args[0] - if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { - return err + deviceID := req.Payload + + b := s.GetBus(uint32(busID)) + if b == nil { + return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + } + if err := b.RemoveDeviceByID(deviceID); err != nil { + return api.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } + j, err := json.Marshal(apitypes.DeviceRemoveResponse{BusID: uint32(busID), DevId: deviceID}) if err != nil { - return err + return api.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/viiper/internal/server/api/handler/bus_device_remove_test.go index 286e71d7..df101dee 100644 --- a/viiper/internal/server/api/handler/bus_device_remove_test.go +++ b/viiper/internal/server/api/handler/bus_device_remove_test.go @@ -32,7 +32,7 @@ func TestBusDeviceRemove(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device failed: %v", err) } }, @@ -45,7 +45,7 @@ func TestBusDeviceRemove(t *testing.T) { setup: nil, pathParams: map[string]string{"id": "90001"}, payload: "1", - expectedResponse: `{"error":"bus 90001 not found"}`, + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 90001 not found"}`, }, { name: "remove non-existing device", @@ -60,14 +60,14 @@ func TestBusDeviceRemove(t *testing.T) { }, pathParams: map[string]string{"id": "90002"}, payload: "1", - expectedResponse: `{"error":"device with id 1 not found on bus 90002"}`, + 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: `{"error":"strconv.ParseUint: parsing \"abc\": invalid syntax"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"abc\": invalid syntax"}`, }, } @@ -84,7 +84,11 @@ func TestBusDeviceRemove(t *testing.T) { } line, err := c.Do("bus/{id}/remove", tt.payload, tt.pathParams) assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) + 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_devices_list.go b/viiper/internal/server/api/handler/bus_devices_list.go index 6cce5d1f..84d99ffe 100644 --- a/viiper/internal/server/api/handler/bus_devices_list.go +++ b/viiper/internal/server/api/handler/bus_devices_list.go @@ -18,15 +18,15 @@ 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") + return api.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } b := s.GetBus(uint32(busID)) if b == nil { - return fmt.Errorf("unknown bus") + return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } metas := b.GetAllDeviceMetas() out := make([]apitypes.Device, 0, len(metas)) @@ -35,14 +35,14 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { 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), + Vid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDVendor), + Pid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDProduct), Type: dtype, }) } payload, err := json.Marshal(apitypes.DevicesListResponse{Devices: out}) if err != nil { - return err + return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(payload) return nil diff --git a/viiper/internal/server/api/handler/bus_devices_list_test.go b/viiper/internal/server/api/handler/bus_devices_list_test.go index c7e3e0b4..a4eb419a 100644 --- a/viiper/internal/server/api/handler/bus_devices_list_test.go +++ b/viiper/internal/server/api/handler/bus_devices_list_test.go @@ -45,7 +45,7 @@ func TestBusDevicesList(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device failed: %v", err) } }, @@ -62,10 +62,10 @@ func TestBusDevicesList(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device 1 failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device 2 failed: %v", err) } }, @@ -76,13 +76,13 @@ func TestBusDevicesList(t *testing.T) { name: "list devices on non-existing bus", setup: nil, pathParams: map[string]string{"id": "99999"}, - expectedResponse: `{"error":"unknown bus"}`, + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 99999 not found"}`, }, { name: "invalid bus number", setup: nil, pathParams: map[string]string{"id": "abc"}, - expectedResponse: `{"error":"strconv.ParseUint: parsing \"abc\": invalid syntax"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"abc\": invalid syntax"}`, }, } @@ -99,7 +99,11 @@ func TestBusDevicesList(t *testing.T) { } line, err := c.Do("bus/{id}/list", nil, tt.pathParams) assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) + 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_remove.go b/viiper/internal/server/api/handler/bus_remove.go index a92b7313..e1946573 100644 --- a/viiper/internal/server/api/handler/bus_remove.go +++ b/viiper/internal/server/api/handler/bus_remove.go @@ -13,19 +13,19 @@ import ( // 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") + if req.Payload == "" { + return api.ErrBadRequest("missing busId") } - busID, err := strconv.ParseUint(req.Args[0], 10, 32) + busID, err := strconv.ParseUint(req.Payload, 10, 32) if err != nil { - return err + return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if err := s.RemoveBus(uint32(busID)); err != nil { - return err + return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } out, err := json.Marshal(apitypes.BusRemoveResponse{BusID: uint32(busID)}) if err != nil { - return err + return api.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/viiper/internal/server/api/handler/bus_remove_test.go index 507b4202..981a25a6 100644 --- a/viiper/internal/server/api/handler/bus_remove_test.go +++ b/viiper/internal/server/api/handler/bus_remove_test.go @@ -53,7 +53,7 @@ func TestBusRemove(t *testing.T) { name: "remove non-existing bus", setup: nil, payload: "99999", - expectedResponse: `{"error":"bus 99999 not found"}`, + expectedResponse: `{"status":404,"title":"Not Found","detail":"bus 99999 not found"}`, }, { name: "remove bus with devices attached", @@ -65,10 +65,10 @@ func TestBusRemove(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device 1 failed: %v", err) } - if _, err := b.Add(xbox360.New()); err != nil { + if _, err := b.Add(xbox360.New(nil)); err != nil { t.Fatalf("add device 2 failed: %v", err) } }, @@ -79,7 +79,7 @@ func TestBusRemove(t *testing.T) { name: "invalid bus number", setup: nil, payload: "bar", - expectedResponse: `{"error":"strconv.ParseUint: parsing \"bar\": invalid syntax"}`, + expectedResponse: `{"status":400,"title":"Bad Request","detail":"invalid busId: strconv.ParseUint: parsing \"bar\": invalid syntax"}`, }, } @@ -97,7 +97,11 @@ func TestBusRemove(t *testing.T) { } line, err := c.Do("bus/remove", tt.payload, nil) assert.NoError(t, err) - assert.Equal(t, tt.expectedResponse, line) + 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) diff --git a/viiper/internal/server/api/router.go b/viiper/internal/server/api/router.go index 65371767..e016fa97 100644 --- a/viiper/internal/server/api/router.go +++ b/viiper/internal/server/api/router.go @@ -10,9 +10,9 @@ import ( // Request contains route parameters and additional args from the command. type Request struct { - Ctx context.Context - 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/viiper/internal/server/api/server.go b/viiper/internal/server/api/server.go index 1eb31bdf..455da8ad 100644 --- a/viiper/internal/server/api/server.go +++ b/viiper/internal/server/api/server.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net" + "regexp" "strconv" "strings" @@ -82,9 +83,9 @@ func (a *Server) serve() { } } -func (a *Server) writeError(w io.Writer, msg string) { - problem := map[string]string{"error": msg} - problemJSON, _ := json.Marshal(problem) +func (a *Server) writeError(w io.Writer, err error) { + apiErr := WrapError(err) + problemJSON, _ := json.Marshal(apiErr) fmt.Fprintf(w, "%s\n", string(problemJSON)) } @@ -105,115 +106,147 @@ func (a *Server) handleConn(conn net.Conn) { connLogger := a.logger.With("remote", conn.RemoteAddr().String()) r := bufio.NewReader(conn) w := conn + + // Read until double newline delimiter + var data strings.Builder + newlineCount := 0 for { - line, err := r.ReadString('\n') + b, err := r.ReadByte() if err != nil { - if err != io.EOF { - connLogger.Error("read api line", "error", err) + if err == io.EOF { + break } + connLogger.Error("read api data", "error", err) return } - line = strings.TrimSpace(line) - if line == "" { - continue + if b == '\n' { + newlineCount++ + if newlineCount >= 2 { + break + } + } else { + newlineCount = 0 } - connLogger.Info("api cmd", "cmd", line) - fields := strings.Fields(line) - if len(fields) == 0 { - connLogger.Error("api empty command") - a.writeError(w, "empty") - continue + data.WriteByte(b) + } + + reqData := data.String() + // Remove trailing newline if present + reqData = strings.TrimSuffix(reqData, "\n") + + if reqData == "" { + connLogger.Error("api empty command") + a.writeError(w, 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") + a.writeError(w, ErrBadRequest("empty path")) + return + } + + path = strings.ToLower(path) + connLogger.Info("api cmd", "path", path) + + if h, params := a.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) + a.writeError(w, err) + return + } + connLogger.Debug("api handler success", "path", path) + a.writeOK(w, res.JSON) + return + } 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, ErrBadRequest("missing busId parameter")) + return + } + devIDStr, ok := params["deviceid"] + if !ok { + a.writeError(w, ErrBadRequest("missing deviceid parameter")) + return } - path := strings.ToLower(fields[0]) - args := fields[1:] - - if h, params := a.router.Match(path); h != nil { - req := &Request{Ctx: connCtx, 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 + busID, err := strconv.ParseUint(busIDStr, 10, 32) + if err != nil { + a.writeError(w, ErrBadRequest(fmt.Sprintf("invalid busId: %v", err))) + return + } + bus := a.usbs.GetBus(uint32(busID)) + if bus == nil { + a.writeError(w, 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 { + a.writeError(w, ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) + return + } - connTimer := device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Stop() - } + 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) - } + // 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") + } + }() } + return } + connLogger.Error("api unknown path", "path", path) + a.writeError(w, ErrNotFound(fmt.Sprintf("unknown path: %s", path))) + return } diff --git a/viiper/internal/server/api/server_test.go b/viiper/internal/server/api/server_test.go index 838f99c7..fe44444d 100644 --- a/viiper/internal/server/api/server_test.go +++ b/viiper/internal/server/api/server_test.go @@ -12,19 +12,13 @@ import ( "viiper/internal/log" "viiper/internal/server/api" srvusb "viiper/internal/server/usb" + th "viiper/internal/testing" + "viiper/pkg/device" "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)) @@ -43,7 +37,7 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { bus, err := virtualbus.NewWithBusId(70002) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(bus)) - dev := xbox360.New() + dev := xbox360.New(nil) _, err = bus.Add(dev) require.NoError(t, err) @@ -56,11 +50,12 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { 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 }, - }) + mr := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, + ) + api.RegisterDevice("xbox360", mr) c, err := net.Dial("tcp", addr) require.NoError(t, err) _, err = fmt.Fprintf(c, "bus/%d/%s\n", bus.BusID(), devID) diff --git a/viiper/internal/server/usb/server.go b/viiper/internal/server/usb/server.go index 3c3fedbe..8bed3dac 100644 --- a/viiper/internal/server/usb/server.go +++ b/viiper/internal/server/usb/server.go @@ -265,7 +265,7 @@ func (s *Server) handleDevList(conn net.Conn) error { dlh := usbip.DevListReplyHeader{NDevices: n} _ = dlh.Write(&buf) for _, m := range metas { - desc := m.Desc + desc := m.Dev.GetDescriptor() meta := m.Meta exp := usbip.ExportedDevice{ @@ -306,7 +306,7 @@ func (s *Server) handleImport(conn net.Conn, first8 []byte) (usb.Device, error) s.logger.Info("Import request", "busid", reqBus) var chosen usb.Device var chosenMeta *usbip.ExportMeta - var chosenDesc *virtualbus.DeviceDescriptor + var chosenDesc *usb.Descriptor for _, m := range s.getAllDeviceMetas() { meta := m.Meta end := bytes.IndexByte(meta.USBBusId[:], 0) @@ -314,7 +314,7 @@ func (s *Server) handleImport(conn net.Conn, first8 []byte) (usb.Device, error) if bid == reqBus { chosen = m.Dev chosenMeta = &meta - chosenDesc = &m.Desc + chosenDesc = m.Dev.GetDescriptor() break } } @@ -362,18 +362,6 @@ func (s *Server) getAllDeviceMetas() []virtualbus.DeviceMeta { 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 @@ -412,19 +400,24 @@ func (lc *logConn) Write(p []byte) (int, error) { 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 + var owningBus *virtualbus.VirtualBus + for _, b := range s.busses { + devices := b.Devices() + for _, d := range devices { + if d == dev { + owningBus = b + break + } + } + if owningBus != nil { break } } - if ownerBus == nil { - return fmt.Errorf("device not found on any bus") + if owningBus == nil { + return fmt.Errorf("device does not belong to any bus") } - ctx := ownerBus.GetDeviceContext(dev) + ctx := owningBus.GetDeviceContext(dev) if ctx == nil { return fmt.Errorf("no device context available from bus") } @@ -547,10 +540,7 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by return []byte{0x01} } - desc := s.getDeviceDescriptor(dev) - if desc == nil { - return nil - } + desc := dev.GetDescriptor() if breq == usbReqGetDescriptor && bm == usbReqTypeStandardFromDevice { dtype := uint8(wValue >> 8) @@ -558,12 +548,12 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by var data []byte switch dtype { case usbDescTypeDevice: - data = desc.Device.Bytes() + data = desc.Bytes() case usbDescTypeConfiguration: data = buildConfigDescriptor(desc) case usbDescTypeString: if s, ok := desc.Strings[dindex]; ok { - data = virtualbus.EncodeStringDescriptor(s) + data = usb.EncodeStringDescriptor(s) } } if len(data) == 0 { @@ -601,7 +591,7 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by } // buildConfigDescriptor builds a configuration descriptor for the device. -func buildConfigDescriptor(desc *virtualbus.DeviceDescriptor) []byte { +func buildConfigDescriptor(desc *usb.Descriptor) []byte { var b bytes.Buffer h := usb.ConfigHeader{ WTotalLength: 0, // to be patched diff --git a/viiper/internal/testing/api_test_helpers.go b/viiper/internal/testing/api_test_helpers.go index 1eb4b619..49933c46 100644 --- a/viiper/internal/testing/api_test_helpers.go +++ b/viiper/internal/testing/api_test_helpers.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "regexp" "strings" "testing" "time" @@ -48,11 +49,9 @@ 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) @@ -60,47 +59,61 @@ func ExecCmd(t *testing.T, addr string, cmd string) string { t.Fatalf("dial failed: %v", err) } defer c.Close() + + // Send command with double newline delimiter + _, _ = fmt.Fprintf(c, "%s\n\n", 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/viiper/internal/testing/mocks.go b/viiper/internal/testing/mocks.go new file mode 100644 index 00000000..72886544 --- /dev/null +++ b/viiper/internal/testing/mocks.go @@ -0,0 +1,36 @@ +package testing + +import ( + "testing" + "viiper/internal/server/api" + "viiper/pkg/device" + "viiper/pkg/usb" +) + +type mockRegistration struct { + deviceName string + handlerFunc api.StreamHandlerFunc + + createFunc func(o *device.CreateOptions) usb.Device +} + +func (m *mockRegistration) CreateDevice(o *device.CreateOptions) usb.Device { + return m.createFunc(o) +} + +func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { + return m.handlerFunc +} + +func CreateMockRegistration( + t *testing.T, + name string, + cf func(o *device.CreateOptions) usb.Device, + h api.StreamHandlerFunc, +) api.DeviceRegistration { + return &mockRegistration{ + deviceName: name, + handlerFunc: h, + createFunc: cf, + } +} diff --git a/viiper/pkg/apiclient/client.go b/viiper/pkg/apiclient/client.go index 15e72ccd..fd5aae4d 100644 --- a/viiper/pkg/apiclient/client.go +++ b/viiper/pkg/apiclient/client.go @@ -8,6 +8,7 @@ import ( "fmt" apitypes "viiper/pkg/apitypes" + "viiper/pkg/device" ) // Client provides a high-level interface to the VIIPER API, handling request @@ -35,11 +36,11 @@ func (c *Client) BusCreate(busID uint32) (*apitypes.BusCreateResponse, error) { 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) + raw, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) if err != nil { return nil, err } - return parse[apitypes.BusCreateResponse](line) + return parse[apitypes.BusCreateResponse](raw) } // BusRemove removes an existing virtual USB bus and all devices attached to it. @@ -50,11 +51,11 @@ func (c *Client) BusRemove(busID uint32) (*apitypes.BusRemoveResponse, error) { 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) + raw, err := c.transport.DoCtx(ctx, path, fmt.Sprintf("%d", busID), nil) if err != nil { return nil, err } - return parse[apitypes.BusRemoveResponse](line) + return parse[apitypes.BusRemoveResponse](raw) } // BusList retrieves a list of all active virtual USB bus numbers. @@ -64,29 +65,42 @@ func (c *Client) BusList() (*apitypes.BusListResponse, error) { func (c *Client) BusListCtx(ctx context.Context) (*apitypes.BusListResponse, error) { const path = "bus/list" - line, err := c.transport.DoCtx(ctx, path, nil, nil) + raw, err := c.transport.DoCtx(ctx, path, nil, nil) if err != nil { return nil, err } - return parse[apitypes.BusListResponse](line) + return parse[apitypes.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) (*apitypes.DeviceAddResponse, error) { - return c.DeviceAddCtx(context.Background(), busID, devType) +func (c *Client) DeviceAdd(busID uint32, devType string, o *device.CreateOptions) (*apitypes.Device, error) { + return c.DeviceAddCtx(context.Background(), busID, devType, o) } -func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string) (*apitypes.DeviceAddResponse, error) { +func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, o *device.CreateOptions) (*apitypes.Device, 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 o == nil { + o = &device.CreateOptions{} + } + req := apitypes.DeviceCreateRequest{ + Type: &devType, + IdVendor: o.IdVendor, + IdProduct: o.IdProduct, + } + 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[apitypes.DeviceAddResponse](line) + return parse[apitypes.Device](raw) } // DeviceRemove removes a device from the specified bus by its device ID. @@ -100,11 +114,11 @@ func (c *Client) DeviceRemove(busID uint32, busid string) (*apitypes.DeviceRemov 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) + raw, err := c.transport.DoCtx(ctx, path, busid, pathParams) if err != nil { return nil, err } - return parse[apitypes.DeviceRemoveResponse](line) + return parse[apitypes.DeviceRemoveResponse](raw) } // DevicesList retrieves a list of all devices attached to the specified bus. @@ -115,26 +129,24 @@ func (c *Client) DevicesList(busID uint32) (*apitypes.DevicesListResponse, error 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) + raw, err := c.transport.DoCtx(ctx, path, nil, pathParams) if err != nil { return nil, err } - return parse[apitypes.DevicesListResponse](line) + return parse[apitypes.DevicesListResponse](raw) } -func parse[T any](line string) (*T, error) { - if line == "" { +func parse[T any](data string) (*T, error) { + if data == "" { 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 problem apitypes.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(line))) + dec := json.NewDecoder(bytes.NewReader([]byte(data))) dec.DisallowUnknownFields() if err := dec.Decode(&out); err != nil { return nil, fmt.Errorf("decode: %w", err) diff --git a/viiper/pkg/apiclient/client_test.go b/viiper/pkg/apiclient/client_test.go index 21f1ed73..fd887ea4 100644 --- a/viiper/pkg/apiclient/client_test.go +++ b/viiper/pkg/apiclient/client_test.go @@ -1,97 +1,82 @@ -package apiclient +package apiclient_test import ( "context" "errors" "testing" + apiclient "viiper/pkg/apiclient" 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 +// 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) *apiclient.Client { + return apiclient.WithTransport(apiclient.NewMockTransport(func(path string, _ any, _ map[string]string) (string, error) { + if err != nil { + return "", 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 { + if out, ok := 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) + setup func(responses map[string]string) (err error) + call func(c *apiclient.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) }, + setup: func(responses map[string]string) error { responses["bus/create"] = `{"busId":42}`; return nil }, + call: func(c *apiclient.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: "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 *apiclient.Client) (any, error) { return c.BusCreate(0) }, + wantErr: "400 Bad Request: invalid busId", }, { 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) + 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 *apiclient.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() }, + setup: func(responses map[string]string) error { return errors.New("dial fail") }, + call: func(c *apiclient.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() }, + setup: func(responses map[string]string) error { return nil }, + call: func(c *apiclient.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) }, + name: "devices list empty", + setup: func(responses map[string]string) error { responses["bus/{id}/list"] = `{"devices":[]}`; return nil }, + call: func(c *apiclient.Client) (any, error) { return c.DevicesList(1) }, assertFunc: func(t *testing.T, got any) { resp := got.(*apitypes.DevicesListResponse) assert.Len(t, resp.Devices, 0) @@ -101,11 +86,14 @@ func TestHighLevelClient(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ms := &mockState{responses: map[string]string{}} + responses := map[string]string{} + errInject := error(nil) if tt.setup != nil { - tt.setup(ms) + if e := tt.setup(responses); e != nil { + errInject = e + } } - c := WithTransport(newMockTransport(ms)) + c := testClient(responses, errInject) got, err := tt.call(c) if tt.wantErr != "" { assert.Error(t, err) @@ -121,8 +109,7 @@ func TestHighLevelClient(t *testing.T) { } 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 + c := apiclient.WithTransport(apiclient.NewTransport("127.0.0.1:9")) // address irrelevant due to early cancel ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := c.BusListCtx(ctx) @@ -130,10 +117,9 @@ func TestContextCancellation(t *testing.T) { } 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)) + responses := map[string]string{} + responses["bus/list"] = `{"buses":[1,2,3],"extra":true}` // extra field should cause decode error + c := testClient(responses, nil) _, err := c.BusList() assert.Error(t, err) } diff --git a/viiper/pkg/apiclient/stream.go b/viiper/pkg/apiclient/stream.go index 4fd1fd19..b54aff75 100644 --- a/viiper/pkg/apiclient/stream.go +++ b/viiper/pkg/apiclient/stream.go @@ -11,6 +11,7 @@ import ( "time" apitypes "viiper/pkg/apitypes" + "viiper/pkg/device" ) // DeviceStream represents a bidirectional connection to a device stream. @@ -38,7 +39,7 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D return nil, fmt.Errorf("dial: %w", err) } - streamPath := fmt.Sprintf("bus/%d/%s\n", busID, devID) + streamPath := fmt.Sprintf("bus/%d/%s\n\n", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { conn.Close() return nil, fmt.Errorf("write stream path: %w", err) @@ -54,19 +55,13 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D // 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) +func (c *Client) AddDeviceAndConnect(ctx context.Context, busID uint32, deviceType string, o *device.CreateOptions) (*DeviceStream, *apitypes.Device, error) { + resp, err := c.DeviceAddCtx(ctx, busID, deviceType, o) 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) + stream, err := c.OpenStream(ctx, busID, resp.DevId) if err != nil { return nil, resp, err } diff --git a/viiper/pkg/apiclient/stream_test.go b/viiper/pkg/apiclient/stream_test.go index 7308a4f0..83977e92 100644 --- a/viiper/pkg/apiclient/stream_test.go +++ b/viiper/pkg/apiclient/stream_test.go @@ -1,82 +1,188 @@ -package apiclient +package apiclient_test import ( "context" + "errors" + "log/slog" + "net" "testing" + "time" + + "viiper/internal/log" + api "viiper/internal/server/api" + handler "viiper/internal/server/api/handler" + "viiper/internal/server/usb" + htesting "viiper/internal/testing" + apiclient "viiper/pkg/apiclient" + apitypes "viiper/pkg/apitypes" + "viiper/pkg/device" + "viiper/pkg/device/xbox360" + pusb "viiper/pkg/usb" + "viiper/pkg/virtualbus" "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)) - +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_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 TestAddDeviceAndConnect(t *testing.T) { + tests := []struct { + name string + setup func(responses map[string]string) error + wantDevice *apitypes.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: &apitypes.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", + }, + { + name: "strict JSON decode error (extra field)", + setup: func(responses map[string]string) error { + responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test","extra":true}` + return nil + }, + wantErrSubstr: "decode:", + }, + } + + 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_StartReading_RequiresConnection(t *testing.T) { - // StartReading requires a real connection to function properly - t.Skip("StartReading requires real connection for testing") +func TestDeviceStream_Operations(t *testing.T) { + type operation func(t *testing.T, stream *apiclient.DeviceStream) + + tests := []struct { + name string + busID uint32 + customRegistration bool + op operation + }{ + { + name: "read deadline timeout", + busID: 201, + op: func(t *testing.T, stream *apiclient.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 *apiclient.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 { 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() + + b, err := virtualbus.NewWithBusId(tt.busID) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(b)) + + c := apiclient.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) + }) + } } diff --git a/viiper/pkg/apiclient/transport.go b/viiper/pkg/apiclient/transport.go index ff34ecf6..6d49d5df 100644 --- a/viiper/pkg/apiclient/transport.go +++ b/viiper/pkg/apiclient/transport.go @@ -1,10 +1,10 @@ package apiclient import ( - "bufio" "context" "encoding/json" "fmt" + "io" "net" "net/url" "strings" @@ -26,8 +26,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 ] \n\n` (double newline terminator). The payload may itself +// contain newlines (e.g. pretty JSON) because only a *double* newline 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) @@ -90,24 +94,21 @@ func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if c.cfg.WriteTimeout > 0 { _ = conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) } - if _, err := conn.Write(append(lineBytes, '\n')); err != nil { + // Send request with double newline delimiter + if _, err := conn.Write(append(lineBytes, '\n', '\n')); err != nil { return "", fmt.Errorf("write: %w", err) } - r := bufio.NewReader(conn) if c.cfg.ReadTimeout > 0 { _ = conn.SetReadDeadline(time.Now().Add(c.cfg.ReadTimeout)) } - resp, err := r.ReadString('\n') - if err != nil { - if len(resp) == 0 { // no data received - return "", fmt.Errorf("read: %w", err) - } - } - if len(resp) == 0 { - return "", nil + respBytes, err := io.ReadAll(conn) + if err != nil && len(respBytes) == 0 { + return "", fmt.Errorf("read: %w", err) } - if resp[len(resp)-1] == '\n' { - resp = resp[:len(resp)-1] + resp := string(respBytes) + // Trim exactly one trailing newline if present. + if strings.HasSuffix(resp, "\n") { + resp = strings.TrimSuffix(resp, "\n") } return resp, nil } diff --git a/viiper/pkg/apiclient/transport_test.go b/viiper/pkg/apiclient/transport_test.go index 04fbbb2b..0cca4f69 100644 --- a/viiper/pkg/apiclient/transport_test.go +++ b/viiper/pkg/apiclient/transport_test.go @@ -1,34 +1,167 @@ -package apiclient +package apiclient_test import ( "encoding/json" + "net" + "strings" "testing" + "time" + + "viiper/pkg/apiclient" "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) +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() + var buf []byte + newlineCount := 0 + var tmp [1]byte + for { + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, rerr := conn.Read(tmp[:]) + if rerr != nil { + break + } + b := tmp[0] + buf = append(buf, b) + if b == '\n' { + newlineCount++ + if newlineCount == 2 { + break + } + } else { + newlineCount = 0 + } + } + *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"` } - 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) + 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\n\n", + }, + { + name: "empty string payload", + payload: "", + expectedLine: "echo\n\n", + }, + { + name: "bytes payload", + payload: []byte("rawbytes"), + expectedLine: "echo rawbytes\n\n", + }, + { + name: "string payload", + payload: "hello world", + expectedLine: "echo hello world\n\n", + }, + { + name: "string payload with newline", + payload: "multi\nline", + expectedLine: "echo multi\nline\n\n", + }, + { + 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}\n\n", + }, + } + + for _, tc := range cases { + addr, got, closeFn := startTestServer(t, "ok\n") + client := apiclient.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) + "\n\n" + assert.Equal(t, expectedPrefix, *got, tc.name) + line := strings.TrimSuffix(strings.TrimPrefix(*got, "echo "), "\n\n") + 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() + + 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) + newlineCount := 0 + for { + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := conn.Read(tmp) + if err != nil { + break + } + b := tmp[0] + buf = append(buf, b) + if b == '\n' { + newlineCount++ + if newlineCount == 2 { // end of request + break + } + } else { + newlineCount = 0 + } + } + _, _ = conn.Write([]byte(resp)) + conn.Close() + }() + + client := apiclient.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) } diff --git a/viiper/pkg/apitypes/structs.go b/viiper/pkg/apitypes/structs.go index ef285541..65ded5bd 100644 --- a/viiper/pkg/apitypes/structs.go +++ b/viiper/pkg/apitypes/structs.go @@ -1,9 +1,33 @@ package apitypes -// Shared API response structs used by both handlers and clients. +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) +// ApiError represents an RFC 7807 (problem+json) error response. type ApiError struct { - Error string `json:"error"` + // 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 == nil { + return "" + } + 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 BusListResponse struct { @@ -30,11 +54,77 @@ 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"` } + +type DeviceCreateRequest struct { + Type *string `json:"type"` + IdVendor *uint16 `json:"idVendor,omitempty"` + IdProduct *uint16 `json:"idProduct,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"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + d.Type = raw.Type + + if raw.IdVendor != nil { + val, err := parseUint16OrHex(raw.IdVendor) + if err != nil { + return fmt.Errorf("idVendor: %w", err) + } + d.IdVendor = &val + } + + if raw.IdProduct != nil { + val, err := parseUint16OrHex(raw.IdProduct) + if err != nil { + return fmt.Errorf("idProduct: %w", err) + } + d.IdProduct = &val + } + + return nil +} + +// parseUint16OrHex accepts either a JSON number or a hex string like "0x12ac" +func parseUint16OrHex(v any) (uint16, error) { + switch val := v.(type) { + case float64: + if val < 0 || val > 65535 { + return 0, fmt.Errorf("value %v out of uint16 range", val) + } + return uint16(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 + } + } + parsed, err := strconv.ParseUint(s, base, 16) + if err != nil { + return 0, fmt.Errorf("invalid hex/numeric string %q: %w", val, err) + } + return uint16(parsed), nil + default: + return 0, fmt.Errorf("expected number or hex string, got %T", v) + } +} diff --git a/viiper/pkg/device/keyboard/device.go b/viiper/pkg/device/keyboard/device.go index e61908c3..b992692f 100644 --- a/viiper/pkg/device/keyboard/device.go +++ b/viiper/pkg/device/keyboard/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" + "viiper/pkg/device" "viiper/pkg/usb" "viiper/pkg/usbip" - "viiper/pkg/virtualbus" ) // Keyboard implements the Device interface for a full HID keyboard with LED support. @@ -17,11 +17,23 @@ type Keyboard struct { stateMu sync.Mutex ledState uint8 ledCallback func(LEDState) + descriptor usb.Descriptor } // New returns a new Keyboard device. -func New() *Keyboard { - return &Keyboard{} +func New(o *device.CreateOptions) *Keyboard { + d := &Keyboard{ + descriptor: defaultDescriptor, + } + if o != nil { + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + } + return d } // SetLEDCallback sets a callback that will be invoked when LED state changes. @@ -136,7 +148,7 @@ var hidReportDescriptor = []byte{ } // Descriptor defines the static USB descriptor for the keyboard. -var Descriptor = virtualbus.DeviceDescriptor{ +var defaultDescriptor = usb.Descriptor{ Device: usb.DeviceDescriptor{ BcdUSB: 0x0200, BDeviceClass: 0x00, @@ -152,7 +164,7 @@ var Descriptor = virtualbus.DeviceDescriptor{ BNumConfigurations: 0x01, Speed: 2, // Full speed }, - Interfaces: []virtualbus.InterfaceConfig{ + Interfaces: []usb.InterfaceConfig{ { Descriptor: usb.InterfaceDescriptor{ BInterfaceNumber: 0x00, @@ -197,6 +209,6 @@ var Descriptor = virtualbus.DeviceDescriptor{ }, } -func (k *Keyboard) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor +func (k *Keyboard) GetDescriptor() *usb.Descriptor { + return &k.descriptor } diff --git a/viiper/pkg/device/keyboard/handler.go b/viiper/pkg/device/keyboard/handler.go index 76537098..cb563aae 100644 --- a/viiper/pkg/device/keyboard/handler.go +++ b/viiper/pkg/device/keyboard/handler.go @@ -7,6 +7,7 @@ import ( "net" "viiper/internal/server/api" + "viiper/pkg/device" "viiper/pkg/usb" ) @@ -16,7 +17,7 @@ func init() { type handler struct{} -func (h *handler) CreateDevice() usb.Device { return New() } +func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } func (h *handler) StreamHandler() api.StreamHandlerFunc { return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { diff --git a/viiper/pkg/device/mouse/device.go b/viiper/pkg/device/mouse/device.go index 75576d47..e4ddbe7e 100644 --- a/viiper/pkg/device/mouse/device.go +++ b/viiper/pkg/device/mouse/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" + "viiper/pkg/device" "viiper/pkg/usb" "viiper/pkg/usbip" - "viiper/pkg/virtualbus" ) // Mouse implements the minimal Device interface for a 5-button HID mouse @@ -16,11 +16,23 @@ type Mouse struct { tick uint64 inputState *InputState stateMu sync.Mutex + descriptor usb.Descriptor } // New returns a new Mouse device. -func New() *Mouse { - return &Mouse{} +func New(o *device.CreateOptions) *Mouse { + d := &Mouse{ + descriptor: defaultDescriptor, + } + if o != nil { + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + } + return d } // UpdateInputState updates the device's current input state (thread-safe). @@ -98,7 +110,7 @@ var hidReportDescriptor = []byte{ } // Descriptor defines the static USB descriptor for the mouse. -var Descriptor = virtualbus.DeviceDescriptor{ +var defaultDescriptor = usb.Descriptor{ Device: usb.DeviceDescriptor{ BcdUSB: 0x0200, BDeviceClass: 0x00, @@ -114,7 +126,7 @@ var Descriptor = virtualbus.DeviceDescriptor{ BNumConfigurations: 0x01, Speed: 2, // Full speed }, - Interfaces: []virtualbus.InterfaceConfig{ + Interfaces: []usb.InterfaceConfig{ { Descriptor: usb.InterfaceDescriptor{ BInterfaceNumber: 0x00, @@ -153,6 +165,6 @@ var Descriptor = virtualbus.DeviceDescriptor{ }, } -func (m *Mouse) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor +func (m *Mouse) GetDescriptor() *usb.Descriptor { + return &m.descriptor } diff --git a/viiper/pkg/device/mouse/handler.go b/viiper/pkg/device/mouse/handler.go index 6f42a4e0..189aa5e6 100644 --- a/viiper/pkg/device/mouse/handler.go +++ b/viiper/pkg/device/mouse/handler.go @@ -6,6 +6,7 @@ import ( "log/slog" "net" "viiper/internal/server/api" + "viiper/pkg/device" "viiper/pkg/usb" ) @@ -15,7 +16,7 @@ func init() { type handler struct{} -func (r *handler) CreateDevice() usb.Device { return New() } +func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } func (r *handler) StreamHandler() api.StreamHandlerFunc { return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { diff --git a/viiper/pkg/device/options.go b/viiper/pkg/device/options.go new file mode 100644 index 00000000..f9b87025 --- /dev/null +++ b/viiper/pkg/device/options.go @@ -0,0 +1,6 @@ +package device + +type CreateOptions struct { + IdVendor *uint16 + IdProduct *uint16 +} diff --git a/viiper/pkg/device/xbox360/device.go b/viiper/pkg/device/xbox360/device.go index 686b0a4c..42f1989e 100644 --- a/viiper/pkg/device/xbox360/device.go +++ b/viiper/pkg/device/xbox360/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" + "viiper/pkg/device" "viiper/pkg/usb" "viiper/pkg/usbip" - "viiper/pkg/virtualbus" ) // Xbox360 implements only the minimal Device interface. @@ -16,11 +16,23 @@ type Xbox360 struct { inputState *InputState stateMu sync.Mutex rumbleFunc func(XRumbleState) // called when rumble commands arrive + descriptor usb.Descriptor } // New returns a new Xbox360 device. -func New() *Xbox360 { - return &Xbox360{} +func New(o *device.CreateOptions) *Xbox360 { + d := &Xbox360{ + descriptor: defaultDescriptor, + } + if o != nil { + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + if o.IdProduct != nil { + d.descriptor.Device.IDProduct = *o.IdProduct + } + } + return d } // SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. @@ -68,7 +80,7 @@ func (x *Xbox360) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { } // Static descriptor/config for Xbox360, for registration with the bus. -var Descriptor = virtualbus.DeviceDescriptor{ +var defaultDescriptor = usb.Descriptor{ Device: usb.DeviceDescriptor{ BcdUSB: 0x0200, BDeviceClass: 0xff, @@ -84,7 +96,7 @@ var Descriptor = virtualbus.DeviceDescriptor{ BNumConfigurations: 0x01, Speed: 2, // Full speed }, - Interfaces: []virtualbus.InterfaceConfig{ + Interfaces: []usb.InterfaceConfig{ // Interface 0: ff/5d/01 with 2 interrupt endpoints { Descriptor: usb.InterfaceDescriptor{ @@ -159,6 +171,6 @@ var Descriptor = virtualbus.DeviceDescriptor{ }, } -func (x *Xbox360) GetDeviceDescriptor() virtualbus.DeviceDescriptor { - return Descriptor +func (x *Xbox360) GetDescriptor() *usb.Descriptor { + return &x.descriptor } diff --git a/viiper/pkg/device/xbox360/handler.go b/viiper/pkg/device/xbox360/handler.go index ba1138a9..3d9abdd5 100644 --- a/viiper/pkg/device/xbox360/handler.go +++ b/viiper/pkg/device/xbox360/handler.go @@ -6,6 +6,7 @@ import ( "log/slog" "net" "viiper/internal/server/api" + "viiper/pkg/device" "viiper/pkg/usb" ) @@ -15,7 +16,7 @@ func init() { type handler struct{} -func (r *handler) CreateDevice() usb.Device { return New() } +func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } func (r *handler) StreamHandler() api.StreamHandlerFunc { return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { diff --git a/viiper/pkg/usb/device.go b/viiper/pkg/usb/device.go index 7c1754b5..74b7e937 100644 --- a/viiper/pkg/usb/device.go +++ b/viiper/pkg/usb/device.go @@ -7,4 +7,5 @@ type Device interface { // 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 + GetDescriptor() *Descriptor } diff --git a/viiper/pkg/usb/usbdesc.go b/viiper/pkg/usb/usbdesc.go index cabcc032..a9a4149e 100644 --- a/viiper/pkg/usb/usbdesc.go +++ b/viiper/pkg/usb/usbdesc.go @@ -25,6 +25,40 @@ const ( HIDDescLen = 9 ) +// Descriptor holds all static descriptor/config data for a device. +type Descriptor struct { + Device DeviceDescriptor + Interfaces []InterfaceConfig + Strings map[uint8]string +} + +// InterfaceConfig holds all descriptors for a single interface for bus management. +type InterfaceConfig struct { + Descriptor InterfaceDescriptor + Endpoints []EndpointDescriptor + HIDDescriptor []byte // optional HID class descriptor (0x21) + HIDReport []byte // optional HID report descriptor (0x22) + VendorData []byte // optional vendor-specific bytes +} + +// 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 { @@ -33,8 +67,8 @@ type DeviceDescriptor struct { BDeviceSubClass uint8 BDeviceProtocol uint8 BMaxPacketSize0 uint8 - IDVendor uint16 // LE - IDProduct uint16 // LE + IDVendor uint16 // LE; may get overridden + IDProduct uint16 // LE; may get overridden BcdDevice uint16 // LE IManufacturer uint8 IProduct uint8 @@ -44,22 +78,22 @@ type DeviceDescriptor struct { } // Bytes returns the binary representation of the DeviceDescriptor with BLength auto-filled. -func (d DeviceDescriptor) Bytes() []byte { +func (d Descriptor) 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) + _ = 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() } diff --git a/viiper/pkg/virtualbus/virtualbus.go b/viiper/pkg/virtualbus/virtualbus.go index 7bb8c588..d616072b 100644 --- a/viiper/pkg/virtualbus/virtualbus.go +++ b/viiper/pkg/virtualbus/virtualbus.go @@ -32,52 +32,9 @@ type VirtualBus struct { // 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() @@ -124,44 +81,40 @@ func NewWithBusId(busId uint32) (*VirtualBus, error) { // 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() + 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") - } + 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 - } + } + 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) + 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) + 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) + 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") + 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. @@ -170,7 +123,7 @@ func (vb *VirtualBus) GetAllDeviceMetas() []DeviceMeta { 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}) + out = append(out, DeviceMeta{Dev: d.dev, Meta: d.meta}) } return out } @@ -182,19 +135,6 @@ func (vb *VirtualBus) BusID() uint32 { 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() @@ -283,7 +223,6 @@ func (vb *VirtualBus) GetDeviceContext(dev usb.Device) context.Context { type busDevice struct { dev usb.Device - desc DeviceDescriptor meta usbip.ExportMeta ctx context.Context cancel context.CancelFunc From 7fd646abe405c7c25ae5b4a67cc04fdb69dc7c02 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 02:01:01 +0100 Subject: [PATCH 019/298] Docs: include past versions on changelogs index --- .github/workflows/docs-deploy.yml | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index eab52fc9..a5a893bd 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -39,23 +39,23 @@ jobs: 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 + # 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 - 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 + + # 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) @@ -70,23 +70,23 @@ jobs: 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 + # 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 - 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 + + # 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 From 774723ef88a8e68781d3d4962396813ef9d3f0fc Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 02:10:55 +0100 Subject: [PATCH 020/298] Fix snapshot build versions --- .github/workflows/snapshots.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 7ae3fd8d..49bf7d85 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -8,7 +8,31 @@ permissions: contents: write jobs: + calculate-version: + name: Calculate Dev Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + 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" + build: + needs: calculate-version uses: ./.github/workflows/build_base.yml with: artifact_suffix: "-Snapshot" @@ -23,10 +47,12 @@ jobs: client-sdks: name: SDK smoke builds and pack + needs: calculate-version uses: ./.github/workflows/clients_ci.yml with: artifact_suffix: "-Snapshot" upload_artifacts: true + version: ${{ needs.calculate-version.outputs.version }} create-pre-release: name: Create Pre-Release From e94f43fd6cdd410b7626db0f9c1b68b5e3ddc752 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 02:17:33 +0100 Subject: [PATCH 021/298] Update docs --- README.md | 1 + docs/cli/server.md | 5 +++++ docs/index.md | 1 + 3 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 48afcad6..49623428 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - 🔜 ??? 🚧 Extensible architecture allows for more device types (other gamepads, specialized HID) - ✅ USBIP server mode: expose virtual devices to remote clients +- ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ 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) diff --git a/docs/cli/server.md b/docs/cli/server.md index 29edcc83..1aaa9c19 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -17,6 +17,11 @@ The server exposes two interfaces: 1. **USBIP Server** - Standard USBIP protocol for device attachment 2. **API Server** - Management API for device/bus control +!!! 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 ### `--usb.addr` diff --git a/docs/index.md b/docs/index.md index 85cc5115..9fa131b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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 +- ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ Proxy mode: forward real USB devices and inspect/record traffic - ✅ Cross-platform: works on Linux and Windows - ✅ Flexible logging (including raw USB packet logs) From 217b2aebcdda37279c089b49768bab351724fadb Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 11:52:12 +0100 Subject: [PATCH 022/298] **Breaking** Switch to \0 terminated transport Changelog(misc) --- docs/api/overview.md | 24 ++++++------- docs/cli/codegen.md | 12 ++----- docs/devices/keyboard.md | 4 +-- docs/devices/mouse.md | 4 +-- docs/devices/xbox360.md | 4 +-- scripts/viiper-api.ps1 | 4 +-- .../codegen/generator/c/source_common.go | 8 ++--- .../codegen/generator/csharp/client.go | 9 +++-- .../codegen/generator/typescript/client.go | 6 ++-- .../server/api/device_stream_handler_test.go | 2 +- viiper/internal/server/api/server.go | 32 +++++------------ viiper/pkg/apiclient/stream.go | 2 +- viiper/pkg/apiclient/transport.go | 8 ++--- viiper/pkg/apiclient/transport_test.go | 36 +++++++------------ 14 files changed, 60 insertions(+), 95 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 85deedbe..97ee47e4 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -18,9 +18,9 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de - Transport: TCP - Default listen address: `:3242` (configurable via `--api.addr`) -- Request format: a single ASCII/UTF‑8 line terminated by `\n\n` (double newline) -- Routing: path followed by optional payload separated by whitespace (e.g., `bus/list\n\n` or `bus/create 5\n\n`) -- Payload: optional string that can be a JSON object, numeric value, or plain string depending on the endpoint +- Request format: a single ASCII/UTF‑8 line terminated by `\0` (null byte) +- 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), followed by `\n`, then 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, followed by `\n`, then connection close @@ -68,7 +68,7 @@ The server registers the following commands and streams: ### Streaming endpoint - Path: `bus/{busId}/{deviceid}` -- Handshake: Send the path followed by `\n\n` (e.g., `bus/1/1\n\n`) +- Handshake: Send the path followed by `\0` (null byte) (e.g., `bus/1/1\0`) - Type: long-lived TCP connection - 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`). @@ -135,25 +135,25 @@ Note on protocol compatibility: ```bash # List buses -printf "bus/list\n\n" | nc localhost 3242 +printf "bus/list\0" | nc localhost 3242 # Create a bus -printf "bus/create\n\n" | nc localhost 3242 +printf "bus/create\0" | nc localhost 3242 # → {"busId":1} # Create a bus with specific ID -printf "bus/create 5\n\n" | nc localhost 3242 +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"}\n\n' | nc localhost 3242 +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\n\n" | nc localhost 3242 +printf "bus/1/list\0" | nc localhost 3242 ``` -Then, open a second TCP connection for streaming to `bus/1/1` (the API port, not the USBIP port). First send the handshake `bus/1/1\n\n`, then you'll write 14‑byte input packets and read 2‑byte rumble packets. Any language with raw TCP support works. +Then, open a second TCP connection for streaming to `bus/1/1` (the API port, not the USBIP port). First send the handshake `bus/1/1\0`, then you'll write 14‑byte input packets and read 2‑byte rumble packets. Any language with raw TCP support works. ### WIndows (PowerShell) @@ -186,8 +186,8 @@ func main() { conn, _ := net.Dial("tcp", "localhost:3242") defer conn.Close() - // Send request with double newline terminator - fmt.Fprint(conn, "bus/create\n\n") + // Send request with null terminator + fmt.Fprint(conn, "bus/create\x00") // Read entire response until connection closes resp, _ := io.ReadAll(conn) diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index dbe95af1..1efcb26c 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -23,7 +23,8 @@ Then generates client SDKs with: - Per-device encode/decode functions - Typed constants and enums -**Note:** The codegen command requires access to VIIPER source code. It must be executed from the `viiper/` module directory within the repository. +!!! note "Sourcecode access is required" + The codegen command requires access to VIIPER source code. It must be executed from the `viiper/` module directory within the repository. ## Flags @@ -82,15 +83,6 @@ 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 -``` - ## When to Regenerate Run codegen when any of these change: diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index 278df587..5bcbafda 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -62,10 +62,10 @@ Using the raw API (see [API Reference](../api/overview.md) for details): ```bash # Create a bus -printf "bus/create\n\n" | nc localhost 3242 +printf "bus/create\0" | nc localhost 3242 # Add keyboard device with JSON payload -printf 'bus/1/add {"type":"keyboard"}\n\n' | nc localhost 3242 +printf 'bus/1/add {"type":"keyboard"}\0' | nc localhost 3242 ``` Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index c2af6976..854648ab 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -40,10 +40,10 @@ Using the raw API (see [API Reference](../api/overview.md) for details): ```bash # Create a bus -printf "bus/create\n\n" | nc localhost 3242 +printf "bus/create\0" | nc localhost 3242 # Add mouse device with JSON payload -printf 'bus/1/add {"type":"mouse"}\n\n' | nc localhost 3242 +printf 'bus/1/add {"type":"mouse"}\0' | nc localhost 3242 ``` Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 2eae6837..577b35da 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -19,10 +19,10 @@ Use the API to create a bus and add an Xbox 360 controller. Using the raw API (s ```bash # Create a bus -printf "bus/create\n\n" | nc localhost 3242 +printf "bus/create\0" | nc localhost 3242 # Add xbox360 device with JSON payload -printf 'bus/1/add {"type":"xbox360"}\n\n' | nc localhost 3242 +printf 'bus/1/add {"type":"xbox360"}\0' | nc localhost 3242 ``` The API returns a Device object with `busId`, `devId`, and other details. Attach it from a USB/IP client, then open a stream to drive input and receive rumble. diff --git a/scripts/viiper-api.ps1 b/scripts/viiper-api.ps1 index 4634202a..99ed05bf 100644 --- a/scripts/viiper-api.ps1 +++ b/scripts/viiper-api.ps1 @@ -42,9 +42,9 @@ function Invoke-ViiperApi { $writer = New-Object System.IO.StreamWriter($stream) $reader = New-Object System.IO.StreamReader($stream) - # Send command with double newline delimiter + # Send command with null terminator $writer.Write($Command) - $writer.Write("`n`n") + $writer.Write("`0") $writer.Flush() # Read single line response diff --git a/viiper/internal/codegen/generator/c/source_common.go b/viiper/internal/codegen/generator/c/source_common.go index 8214a02c..34e6b0a9 100644 --- a/viiper/internal/codegen/generator/c/source_common.go +++ b/viiper/internal/codegen/generator/c/source_common.go @@ -229,12 +229,12 @@ static int viiper_send_line(int fd, const char* line) { #if defined(_WIN32) || defined(_WIN64) int wr = send(fd, line, (int)n, 0); if (wr < 0) return -1; - wr = send(fd, "\n\n", 2, 0); + wr = send(fd, "\0", 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\n", 2, 0); + wr = send(fd, "\0", 1, 0); if (wr < 0) return -1; #endif return 0; @@ -252,7 +252,7 @@ static int viiper_read_line(int fd, char** out) { ssize_t rd = recv(fd, &ch, 1, 0); #endif if (rd <= 0) { free(buf); return -1; } - if (ch == '\n') break; + if (ch == '\0') break; if (len + 1 >= cap) { cap *= 2; char* nb = (char*)realloc(buf, cap); @@ -440,7 +440,7 @@ VIIPER_API viiper_error_t viiper_device_create( 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 with double newline terminator for framing */ + /* Send stream path with null terminator for framing */ char pathbuf[256]; snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); if (viiper_send_line(fd, pathbuf) != 0) { diff --git a/viiper/internal/codegen/generator/csharp/client.go b/viiper/internal/codegen/generator/csharp/client.go index a328886e..f4033aa2 100644 --- a/viiper/internal/codegen/generator/csharp/client.go +++ b/viiper/internal/codegen/generator/csharp/client.go @@ -58,13 +58,13 @@ public class ViiperClient : IDisposable using var stream = client.GetStream(); - // Build command line: "path[ optional-payload]\n\n" (management protocol uses double newline terminator) + // Build command line: "path[ optional-payload]\0" (management protocol uses null terminator) string commandLine = path.ToLowerInvariant(); if (!string.IsNullOrEmpty(payload)) { commandLine += " " + payload; } - commandLine += "\n\n"; + commandLine += "\0"; var requestBytes = Encoding.UTF8.GetBytes(commandLine); await stream.WriteAsync(requestBytes, cancellationToken); @@ -104,9 +104,8 @@ public class ViiperClient : IDisposable var client = new TcpClient(); await client.ConnectAsync(_host, _port, cancellationToken); var stream = client.GetStream(); - // Streaming handshake uses double newline delimiter (same framing as management). - // Server api/server.go reads until two consecutive '\n'; a single '\n' leaves it waiting. - var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\n\n"; + // 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); diff --git a/viiper/internal/codegen/generator/typescript/client.go b/viiper/internal/codegen/generator/typescript/client.go index 656aba20..72cebabe 100644 --- a/viiper/internal/codegen/generator/typescript/client.go +++ b/viiper/internal/codegen/generator/typescript/client.go @@ -23,7 +23,7 @@ const decoder = new TextDecoder(); /** * VIIPER management & streaming API client. - * Request framing: [ ]\n\n ; Response framing: single JSON line ending in \n then connection close. + * Request framing: [ ]\0 (null terminator) ; Response framing: single JSON line ending in \n then connection close. */ export class ViiperClient { private host: string; @@ -50,7 +50,7 @@ export class ViiperClient { socket.connect(this.port, this.host, () => { let line = path; // preserve case if (payload && payload.length > 0) line += ' ' + payload; - line += '\n\n'; + line += '\0'; socket.write(encoder.encode(line)); }); @@ -88,7 +88,7 @@ export class ViiperClient { return new Promise((resolve, reject) => { const socket = new Socket(); socket.connect(this.port, this.host, () => { - const line = ` + "`" + `bus/${busId}/${devId}\n\n` + "`" + `; + const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; socket.write(encoder.encode(line)); resolve(new ViiperDevice(socket)); }); diff --git a/viiper/internal/server/api/device_stream_handler_test.go b/viiper/internal/server/api/device_stream_handler_test.go index 8a1efe54..a23c4dee 100644 --- a/viiper/internal/server/api/device_stream_handler_test.go +++ b/viiper/internal/server/api/device_stream_handler_test.go @@ -122,7 +122,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { require.NoError(t, err) defer c.Close() - _, err = fmt.Fprintf(c, "bus/%d/%s\n\n", bus.BusID(), deviceID) + _, err = fmt.Fprintf(c, "bus/%d/%s\x00", bus.BusID(), deviceID) require.NoError(t, err) select { diff --git a/viiper/internal/server/api/server.go b/viiper/internal/server/api/server.go index 455da8ad..00881d43 100644 --- a/viiper/internal/server/api/server.go +++ b/viiper/internal/server/api/server.go @@ -107,32 +107,18 @@ func (a *Server) handleConn(conn net.Conn) { r := bufio.NewReader(conn) w := conn - // Read until double newline delimiter - var data strings.Builder - newlineCount := 0 - for { - b, err := r.ReadByte() - if err != nil { - if err == io.EOF { - break - } - connLogger.Error("read api data", "error", err) - return - } - if b == '\n' { - newlineCount++ - if newlineCount >= 2 { - break - } + // 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 { - newlineCount = 0 + connLogger.Error("read api data", "error", err) } - data.WriteByte(b) + return } - - reqData := data.String() - // Remove trailing newline if present - reqData = strings.TrimSuffix(reqData, "\n") + // Remove null terminator + reqData = strings.TrimSuffix(reqData, "\x00") if reqData == "" { connLogger.Error("api empty command") diff --git a/viiper/pkg/apiclient/stream.go b/viiper/pkg/apiclient/stream.go index b54aff75..f0746c74 100644 --- a/viiper/pkg/apiclient/stream.go +++ b/viiper/pkg/apiclient/stream.go @@ -39,7 +39,7 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D return nil, fmt.Errorf("dial: %w", err) } - streamPath := fmt.Sprintf("bus/%d/%s\n\n", busID, devID) + streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { conn.Close() return nil, fmt.Errorf("write stream path: %w", err) diff --git a/viiper/pkg/apiclient/transport.go b/viiper/pkg/apiclient/transport.go index 6d49d5df..2e5e259f 100644 --- a/viiper/pkg/apiclient/transport.go +++ b/viiper/pkg/apiclient/transport.go @@ -27,8 +27,8 @@ func defaultConfig() Config { } // Transport is the low-level VIIPER management protocol implementation used by higher-level API clients. -// Request framing: `[ SP ] \n\n` (double newline terminator). The payload may itself -// contain newlines (e.g. pretty JSON) because only a *double* newline ends the request. +// 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. @@ -94,8 +94,8 @@ func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if c.cfg.WriteTimeout > 0 { _ = conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) } - // Send request with double newline delimiter - if _, err := conn.Write(append(lineBytes, '\n', '\n')); err != nil { + // Send request with null terminator + if _, err := conn.Write(append(lineBytes, '\x00')); err != nil { return "", fmt.Errorf("write: %w", err) } if c.cfg.ReadTimeout > 0 { diff --git a/viiper/pkg/apiclient/transport_test.go b/viiper/pkg/apiclient/transport_test.go index 0cca4f69..25c51178 100644 --- a/viiper/pkg/apiclient/transport_test.go +++ b/viiper/pkg/apiclient/transport_test.go @@ -23,7 +23,6 @@ func startTestServer(t *testing.T, response string) (addr string, gotReqLine *st } defer conn.Close() var buf []byte - newlineCount := 0 var tmp [1]byte for { conn.SetReadDeadline(time.Now().Add(2 * time.Second)) @@ -33,13 +32,8 @@ func startTestServer(t *testing.T, response string) (addr string, gotReqLine *st } b := tmp[0] buf = append(buf, b) - if b == '\n' { - newlineCount++ - if newlineCount == 2 { - break - } - } else { - newlineCount = 0 + if b == '\x00' { + break } } *got = string(buf) @@ -66,27 +60,27 @@ func TestTransportPayloadEncoding(t *testing.T) { { name: "nil payload", payload: nil, - expectedLine: "echo\n\n", + expectedLine: "echo\x00", }, { name: "empty string payload", payload: "", - expectedLine: "echo\n\n", + expectedLine: "echo\x00", }, { name: "bytes payload", payload: []byte("rawbytes"), - expectedLine: "echo rawbytes\n\n", + expectedLine: "echo rawbytes\x00", }, { name: "string payload", payload: "hello world", - expectedLine: "echo hello world\n\n", + expectedLine: "echo hello world\x00", }, { name: "string payload with newline", payload: "multi\nline", - expectedLine: "echo multi\nline\n\n", + expectedLine: "echo multi\nline\x00", }, { name: "struct payload json marshaled", @@ -96,7 +90,7 @@ func TestTransportPayloadEncoding(t *testing.T) { { name: "multi-line JSON string payload", payload: "{\n\"x\":1\n}", - expectedLine: "echo {\n\"x\":1\n}\n\n", + expectedLine: "echo {\n\"x\":1\n}\x00", }, } @@ -111,9 +105,9 @@ func TestTransportPayloadEncoding(t *testing.T) { if tc.validateJSON { b, merr := json.Marshal(tc.payload) assert.NoError(t, merr, tc.name) - expectedPrefix := "echo " + string(b) + "\n\n" + expectedPrefix := "echo " + string(b) + "\x00" assert.Equal(t, expectedPrefix, *got, tc.name) - line := strings.TrimSuffix(strings.TrimPrefix(*got, "echo "), "\n\n") + 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) @@ -138,7 +132,6 @@ func TestTransportMultiLineResponse(t *testing.T) { } buf := make([]byte, 0, 128) tmp := make([]byte, 1) - newlineCount := 0 for { conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, err := conn.Read(tmp) @@ -147,13 +140,8 @@ func TestTransportMultiLineResponse(t *testing.T) { } b := tmp[0] buf = append(buf, b) - if b == '\n' { - newlineCount++ - if newlineCount == 2 { // end of request - break - } - } else { - newlineCount = 0 + if b == '\x00' { // end of request + break } } _, _ = conn.Write([]byte(resp)) From 7c4b621d0460fe1ed2f98f0b525714cd4a59727b Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 11:59:40 +0100 Subject: [PATCH 023/298] **Breaking** Fix missing underscores in C SDK constants Changelog(fix) --- docs/clients/c.md | 12 +++++----- docs/clients/generator.md | 8 +++---- examples/c/virtual_keyboard/main.c | 24 +++++++++---------- examples/c/virtual_x360_pad/main.c | 8 +++---- viiper/internal/codegen/common/naming.go | 24 ++++++++++++++++--- .../codegen/generator/c/header_device.go | 2 +- .../internal/codegen/generator/c/helpers.go | 4 ++-- 7 files changed, 50 insertions(+), 32 deletions(-) diff --git a/docs/clients/c.md b/docs/clients/c.md index cda673a0..132f16f5 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -105,7 +105,7 @@ int main(void) { .modifiers = 0, .count = 1 }; - uint8_t keys[] = {VIIPER_KEYBOARD_KEYA}; + uint8_t keys[] = {VIIPER_KEYBOARD_KEY_A}; input.keys = keys; input.keys_count = 1; @@ -195,9 +195,9 @@ 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)); + !!(leds & VIIPER_KEYBOARD_LED_NUM_LOCK), + !!(leds & VIIPER_KEYBOARD_LED_CAPS_LOCK), + !!(leds & VIIPER_KEYBOARD_LED_SCROLL_LOCK)); } viiper_device_on_output(device, on_led_update, NULL); @@ -254,9 +254,9 @@ add_custom_command(TARGET your_target POST_BUILD **Solution:** Add sufficient delays between key press, release, and next action: ```c -press_and_release(dev, VIIPER_KEYBOARD_KEYL, 0); +press_and_release(dev, VIIPER_KEYBOARD_KEY_L, 0); Sleep(100); -press_and_release(dev, VIIPER_KEYBOARD_KEYL, 0); +press_and_release(dev, VIIPER_KEYBOARD_KEY_L, 0); ``` ### Struct Padding Issues diff --git a/docs/clients/generator.md b/docs/clients/generator.md index 82abefb0..5e1bb537 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -159,10 +159,10 @@ var CharToKey = map[byte]byte{ **Emitted C header (`viiper_keyboard.h`):** ```c -#define VIIPER_KEYBOARD_MODLEFTCTRL 0x1 -#define VIIPER_KEYBOARD_MODLEFTSHIFT 0x2 -#define VIIPER_KEYBOARD_KEYA 0x4 -#define VIIPER_KEYBOARD_KEYB 0x5 +#define VIIPER_KEYBOARD_MOD_LEFT_CTRL 0x1 +#define VIIPER_KEYBOARD_MOD_LEFT_SHIFT 0x2 +#define VIIPER_KEYBOARD_KEY_A 0x4 +#define VIIPER_KEYBOARD_KEY_B 0x5 // Map lookup function int viiper_keyboard_char_to_key_lookup(uint8_t key, uint8_t* out_value); diff --git a/examples/c/virtual_keyboard/main.c b/examples/c/virtual_keyboard/main.c index eebca35b..cc7cb338 100644 --- a/examples/c/virtual_keyboard/main.c +++ b/examples/c/virtual_keyboard/main.c @@ -51,11 +51,11 @@ static void on_leds(const void* output, size_t output_size, void* user) 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); + (b & VIIPER_KEYBOARD_LED_NUM_LOCK) ? 1u : 0u, + (b & VIIPER_KEYBOARD_LED_CAPS_LOCK) ? 1u : 0u, + (b & VIIPER_KEYBOARD_LED_SCROLL_LOCK) ? 1u : 0u, + (b & VIIPER_KEYBOARD_LED_COMPOSE) ? 1u : 0u, + (b & VIIPER_KEYBOARD_LED_KANA) ? 1u : 0u); } } @@ -86,13 +86,13 @@ static void press_and_release(viiper_device_t* dev, uint8_t modifiers, uint8_t k 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); + press_and_release(dev, VIIPER_KEYBOARD_MOD_LEFT_SHIFT, VIIPER_KEYBOARD_KEY_H); /* 'H' */ + press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_E); + press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_L); + press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_L); + press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_O); /* '!' = Shift + '1' */ - press_and_release(dev, VIIPER_KEYBOARD_MODLEFTSHIFT, VIIPER_KEYBOARD_KEY1); + press_and_release(dev, VIIPER_KEYBOARD_MOD_LEFT_SHIFT, VIIPER_KEYBOARD_KEY1); } int main(int argc, char** argv) @@ -142,7 +142,7 @@ int main(int argc, char** argv) for (;;) { type_string_hello(dev); sleep_ms(100); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEYENTER); + press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_ENTER); printf("→ Typed: Hello!\n"); sleep_ms(5000); } diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c index 27970338..6b387847 100644 --- a/examples/c/virtual_x360_pad/main.c +++ b/examples/c/virtual_x360_pad/main.c @@ -131,16 +131,16 @@ int main(int argc, char** argv) memset(&in, 0, sizeof in); switch ((frame / 60) % 4){ case 0: - in.buttons = VIIPER_XBOX360_BUTTONA; + in.buttons = VIIPER_XBOX360_BUTTON_A; break; case 1: - in.buttons = VIIPER_XBOX360_BUTTONB; + in.buttons = VIIPER_XBOX360_BUTTON_B; break; case 2: - in.buttons = VIIPER_XBOX360_BUTTONX; + in.buttons = VIIPER_XBOX360_BUTTON_X; break; default: - in.buttons = VIIPER_XBOX360_BUTTONY; + in.buttons = VIIPER_XBOX360_BUTTON_Y; break; } in.lt = (uint8_t)((frame * 2) % 256); diff --git a/viiper/internal/codegen/common/naming.go b/viiper/internal/codegen/common/naming.go index fb9c8dc4..9ed7dfa7 100644 --- a/viiper/internal/codegen/common/naming.go +++ b/viiper/internal/codegen/common/naming.go @@ -36,10 +36,28 @@ func ToCamelCase(s string) string { } func ToSnakeCase(s string) string { + if s == "" { + return "" + } var b strings.Builder - for i, r := range s { - if i > 0 && r >= 'A' && r <= 'Z' { - b.WriteByte('_') + 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) } diff --git a/viiper/internal/codegen/generator/c/header_device.go b/viiper/internal/codegen/generator/c/header_device.go index 4df8c063..a26c955d 100644 --- a/viiper/internal/codegen/generator/c/header_device.go +++ b/viiper/internal/codegen/generator/c/header_device.go @@ -23,7 +23,7 @@ const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H {{- if gt (len .Pkg.Constants) 0 }} /* {{.Device}} constants */ {{range .Pkg.Constants -}} -#define VIIPER_{{upper $.Device}}_{{upper .Name}} {{printf "0x%X" .Value}} +#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{printf "0x%X" .Value}} {{end}} {{- end}} diff --git a/viiper/internal/codegen/generator/c/helpers.go b/viiper/internal/codegen/generator/c/helpers.go index 22eb2fe7..a1529fa1 100644 --- a/viiper/internal/codegen/generator/c/helpers.go +++ b/viiper/internal/codegen/generator/c/helpers.go @@ -421,7 +421,7 @@ func formatCMapKey(key string, goType string, device string) string { if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { prefix := common.ExtractPrefix(key) if prefix != "" { - constName := strings.ToUpper(key) + constName := strings.ToUpper(common.ToSnakeCase(key)) return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) } } @@ -440,7 +440,7 @@ func formatCMapValue(value interface{}, goType string, device string) string { if len(str) > 0 && (str[0] >= 'A' && str[0] <= 'Z') { prefix := common.ExtractPrefix(str) if prefix != "" { - constName := strings.ToUpper(str) + constName := strings.ToUpper(common.ToSnakeCase(str)) return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) } } From 6851fac5ac28587a25607c0457b2555b1e44b582 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 12:31:14 +0100 Subject: [PATCH 024/298] Update docs --- docs/getting-started/installation.md | 28 +++- docs/getting-started/quickstart.md | 187 ++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 5 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 20977e59..e44c09d3 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -28,12 +28,36 @@ sudo pacman -S usbip ## Installing VIIPER -### Pre-built Binaries +### Pre-built Binaries (Recommended) -Download the latest release from the [GitHub Releases](https://github.com/Alia5/VIIPER/releases) page. +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) + +### Portable Deployment + +VIIPER does not require system-wide installation. +The `viiper` executable is completely self-contained (and statically linked) and can be: + +- Placed in any directory +- Shipped alongside your application +- Run directly without installation +- Bundled with your application's distribution + +This makes VIIPER ideal for embedding in applications or distributing as part of a software package. + +!!! 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 either: + + - Connect to the existing VIIPER instance (if accessible) + - Use a custom port via `--api.addr` flag to run a separate instance + - Check if VIIPER is already running before starting their own instance ### Building from Source +Building from source is only necessary if you need to modify VIIPER or target an unsupported platform. + #### Prerequisites - [Go](https://go.dev/) 1.25 or newer diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c64d536e..7f0a3069 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,7 +1,188 @@ # Quick Start -🚧 **Documentation in progress** 🚧 +This guide walks you through setting up VIIPER and creating your first virtual device. -## Basic Usage +## Prerequisites -Check the [CLI Reference](../cli/overview.md) for detailed command documentation. +Before starting, ensure you have: + +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) + +## Starting the Server + +Start VIIPER with default settings: + +```bash +viiper server +``` + +This starts two services: + +- **USBIP Server** on port `3241` (standard USBIP protocol) +- **API Server** on port `3242` (device management) + +!!! 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`. + +### Custom Ports + +To use different ports: + +```bash +viiper server --usb.addr=:9000 --api.addr=:9001 +``` + +## 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 SDKs (Recommended) + +Client SDKs are available for C, C#, Go, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. + +For complete SDK documentation and code examples, see: + +- [C SDK Documentation](../clients/c.md) +- [C# SDK Documentation](../clients/csharp.md) +- [TypeScript SDK Documentation](../clients/typescript.md) +- [Go Client Documentation](../clients/go.md) + +Full working examples for all device types are available in the `examples/` directory of the repository. + +### Option 2: Using Raw TCP (netcat) + +For quick testing without SDKs: + +```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. + +### Option 3: Using PowerShell Helper Script + +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 your 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, C#, Go, and TypeScript +2. **Read API Documentation**: Learn about all available [API commands](../api/overview.md) +3. **Choose an SDK**: Pick a [client SDK](../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 +``` + +### 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. + +## See Also + +- [CLI Reference](../cli/overview.md) - Complete command documentation +- [API Reference](../api/overview.md) - Management API protocol +- [Client SDKs](../clients/generator.md) - Language-specific client libraries +- [Configuration](../cli/configuration.md) - Environment variables and config files From e16fe851c70b975ae94a4c61b4b48af582474e0e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 12:35:05 +0100 Subject: [PATCH 025/298] Add prerequisites Check Changelog(feat) --- docs/getting-started/installation.md | 34 +++++++++++++ docs/getting-started/quickstart.md | 29 +++++++++++ viiper/internal/cmd/server.go | 12 +++++ .../internal/server/api/autoattach_linux.go | 48 +++++++++++++++++++ .../internal/server/api/autoattach_windows.go | 24 ++++++++++ 5 files changed, 147 insertions(+) create mode 100644 viiper/internal/server/api/autoattach_linux.go create mode 100644 viiper/internal/server/api/autoattach_windows.go diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index e44c09d3..94aac550 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -26,6 +26,40 @@ sudo pacman -S usbip [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). +### Linux Kernel Module Setup (for Auto-Attach) + +!!! info "Auto-Attach Feature" + VIIPER can automatically attach created devices to the local machine (localhost) when enabled. This requires the `vhci-hcd` kernel module on Linux. + +The `vhci-hcd` (Virtual Host Controller Interface) module is required for the auto-attach feature. 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 +``` + +If you don't plan to use the auto-attach feature, you can skip this setup and disable it with `--api.auto-attach-local-client=false`. + ## Installing VIIPER ### Pre-built Binaries (Recommended) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 7f0a3069..f055a258 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -160,6 +160,35 @@ viiper server --usb.addr=:9000 --api.addr=:9001 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:** diff --git a/viiper/internal/cmd/server.go b/viiper/internal/cmd/server.go index 9901015a..b87e2a1a 100644 --- a/viiper/internal/cmd/server.go +++ b/viiper/internal/cmd/server.go @@ -56,6 +56,18 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { 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 s.ApiServerConfig.AutoAttachLocalClient { + logger.Info("Auto-attach is enabled, checking prerequisites...") + if !api.CheckAutoAttachPrerequisites(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) return err diff --git a/viiper/internal/server/api/autoattach_linux.go b/viiper/internal/server/api/autoattach_linux.go new file mode 100644 index 00000000..bdcfe40b --- /dev/null +++ b/viiper/internal/server/api/autoattach_linux.go @@ -0,0 +1,48 @@ +//go:build linux + +package api + +import ( + "bytes" + "log/slog" + "os" + "os/exec" +) + +// 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(logger *slog.Logger) bool { + allOk := true + + // Check if usbip tool is available + 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") + } + + // Check if vhci-hcd kernel module is loaded + data, err := os.ReadFile("/proc/modules") + if err != nil { + logger.Debug("Could not read /proc/modules", "error", err) + // Don't fail here, might be in a container or restricted environment + } 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/viiper/internal/server/api/autoattach_windows.go b/viiper/internal/server/api/autoattach_windows.go new file mode 100644 index 00000000..8823d16a --- /dev/null +++ b/viiper/internal/server/api/autoattach_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package api + +import ( + "log/slog" + "os/exec" +) + +// CheckAutoAttachPrerequisites checks if auto-attach prerequisites are met on Windows. +// Returns true if all requirements are satisfied, false otherwise with helpful log messages. +func CheckAutoAttachPrerequisites(logger *slog.Logger) bool { + // Check if usbip.exe is available (from usbip-win2) + 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 +} From 1b953dc19b6e0143a13fa3d99ea71ee932cd60fa Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 14:13:13 +0100 Subject: [PATCH 026/298] Fix scripts --- scripts/viiper-api.ps1 | 8 ++++---- scripts/viiper-api.sh | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/viiper-api.ps1 b/scripts/viiper-api.ps1 index 99ed05bf..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)] diff --git a/scripts/viiper-api.sh b/scripts/viiper-api.sh index 6e45796a..8736b546 100644 --- a/scripts/viiper-api.sh +++ b/scripts/viiper-api.sh @@ -60,8 +60,8 @@ if nc -h 2>&1 | grep -q -- "-q "; then fi -# Send command with double newline delimiter -if ! OUTPUT=$(printf '%s\n\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 From 7d67404a1a1544f9fcddee0f028ac9fad07e2696 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 14:13:25 +0100 Subject: [PATCH 027/298] Fix api_test_helpers --- viiper/internal/testing/api_test_helpers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viiper/internal/testing/api_test_helpers.go b/viiper/internal/testing/api_test_helpers.go index 49933c46..b7124876 100644 --- a/viiper/internal/testing/api_test_helpers.go +++ b/viiper/internal/testing/api_test_helpers.go @@ -60,8 +60,8 @@ func ExecCmd(t *testing.T, addr string, cmd string) string { } defer c.Close() - // Send command with double newline delimiter - _, _ = fmt.Fprintf(c, "%s\n\n", cmd) + // Send command with null terminator (\x00) — this matches API server framing + _, _ = fmt.Fprintf(c, "%s\x00", cmd) // Read response r := bufio.NewReader(c) From f3c38032c72ee45fb304005499206c0201784940 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 21 Nov 2025 14:14:53 +0100 Subject: [PATCH 028/298] Update docs --- README.md | 18 ++++++++--------- docs/api/overview.md | 2 +- docs/clients/csharp.md | 8 ++++---- docs/clients/go.md | 29 +++++++++++++++++++++++----- docs/clients/typescript.md | 14 ++++++++++---- docs/getting-started/installation.md | 8 ++++---- docs/index.md | 9 +++++---- 7 files changed, 57 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 49623428..e32bf2ac 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,12 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. -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. +VIIPER is a self-contained, standalone binary that uses USBIP to handle the USB protocol layer. +Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. +Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. +The binary is portable and can be bundled with your application. -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. +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. @@ -54,8 +55,7 @@ All devices _**can and must be**_ controlled programmatically via an API. ## 🔌 Requirements -VIIPER relies on USBIP. -You must have USBIP installed on your system. +VIIPER is a standalone binary that requires USBIP. **Linux:** @@ -125,9 +125,9 @@ This works with Steam, native Windows games, and any other application supportin ### How is VIIPER different from other controller emulators? -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. +Most controller emulators require custom kernel drivers for each device type. +VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without kernel drivers. +This makes VIIPER portable, easier to extend, and simpler to bundle with applications. ### Can I add support for other device types? diff --git a/docs/api/overview.md b/docs/api/overview.md index 97ee47e4..a67f33bc 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -1,6 +1,6 @@ # 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 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 null-byte-terminated commands. !!! 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: diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index a831f914..9b3970fe 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -153,7 +153,7 @@ using Viiper.Client.Devices.Xbox360; var input = new Xbox360Input { - Buttons = (ushort)Button.A, + Buttons = (uint)Button.A, LeftTrigger = 255, RightTrigger = 0, ThumbLX = -32768, // Left stick left @@ -356,14 +356,14 @@ byte rightMotor = data[1]; public struct MouseInput { public byte Buttons; // Button flags - public short X; // Relative X movement - public short Y; // Relative Y movement + public sbyte X; // Relative X movement (-128 to 127) + public sbyte Y; // Relative Y movement (-128 to 127) public sbyte Wheel; // Vertical scroll public sbyte Pan; // Horizontal scroll } ``` -**Wire format:** Fixed 8 bytes, packed structure +**Wire format:** Fixed 5 bytes, packed structure ## Configuration and Advanced Usage diff --git a/docs/clients/go.md b/docs/clients/go.md index f4c4521c..db70c172 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -20,8 +20,9 @@ import ( "context" "log" "time" - + apiclient "viiper/pkg/apiclient" + "viiper/pkg/device" "viiper/pkg/device/keyboard" ) @@ -47,8 +48,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) } @@ -78,10 +80,11 @@ func main() { ### Creating and Connecting -The simplest way to add a device and open its stream: +// The simplest way to add a device and open its 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,6 +93,22 @@ defer stream.Close() log.Printf("Connected to device %s", resp.ID) ``` +// Or specify VID/PID using CreateOptions: + +```go +opts := &device.CreateOptions{ + IdVendor: func() *uint16 { v := uint16(0x1234); return &v }(), + IdProduct: func() *uint16 { p := uint16(0x5678); return &p }(), +} +stream2, resp2, err := client.AddDeviceAndConnect(ctx, busID, "keyboard", opts) +if err != nil { + log.Fatal(err) +} +defer stream2.Close() + +log.Printf("Connected to device %s (custom VID/PID)", resp2.ID) +``` + Or connect to an existing device: ```go diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index d009c227..bffe82d9 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -428,14 +428,14 @@ const rightMotor = data.readUInt8(1); ```typescript interface MouseInput { Buttons: number; // Button flags - Dx: number; // Relative X movement (-32768 to 32767) - Dy: number; // Relative Y movement (-32768 to 32767) + Dx: number; // Relative X movement (-128 to 127) + Dy: number; // Relative Y movement (-128 to 127) Wheel: number; // Vertical scroll (-128 to 127) Pan: number; // Horizontal scroll (-128 to 127) } ``` -**Wire format:** Fixed 8 bytes, packed structure +**Wire format:** Fixed 5 bytes, packed structure ## Configuration and Advanced Usage @@ -508,7 +508,13 @@ node dist/virtual_keyboard.js localhost:3242 ## Troubleshooting -TODO +Some quick troubleshooting tips for the TypeScript SDK and device streams: + +- Connection refused / timeout: Verify VIIPER server is running and listening on the expected API port (default 3242). Ensure firewall/ACLs allow TCP connections. +- Unexpected response or parse errors: The VIIPER API uses null-byte (\x00) terminated requests. Use the provided SDK helper methods or ensure raw sockets append a null terminator when calling the server. +- Stream closed unexpectedly: Confirm the device stream was opened (device added and connected) and that the device handler did not time out (default 5s reconnect window). Check server logs for reasons. +- Device not appearing to OS: Remember you must attach the virtual device via USBIP (USB-IP server default port :3241) AFTER you create the device via the API (or enable auto-attach on local host). +- Use examples: See the repository examples in `examples/typescript/` for working end-to-end samples that demonstrate bus creation, device streams, and cleanup. ## See Also diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 94aac550..c8cee872 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -26,12 +26,12 @@ sudo pacman -S usbip [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). -### Linux Kernel Module Setup (for Auto-Attach) +### Linux Kernel Module Setup -!!! info "Auto-Attach Feature" - VIIPER can automatically attach created devices to the local machine (localhost) when enabled. This requires the `vhci-hcd` kernel module on Linux. +!!! 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. -The `vhci-hcd` (Virtual Host Controller Interface) module is required for the auto-attach feature. Most Linux distributions include this module but don't load it automatically. +Most Linux distributions include this module but don't load it automatically. #### One-Time Setup diff --git a/docs/index.md b/docs/index.md index 9fa131b9..7ef44362 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,11 +19,12 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. -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. +VIIPER is a self-contained, standalone binary that uses USBIP to handle the USB protocol layer. +Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. +Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. +The binary is portable and can be bundled with your application. -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. +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. From 885623735aa19e6451cb65996aac432e7b02d7a4 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 22 Nov 2025 15:38:55 +0100 Subject: [PATCH 029/298] Snapshots: Reuse existing pre-release --- .github/workflows/snapshots.yml | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 49bf7d85..79feccde 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -102,20 +102,14 @@ jobs: 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 - with: - tag_name: dev-latest - delete_release: true + - name: Update Dev Snapshot Release + uses: andelf/nightly-release@main env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 with: - tag_name: dev-latest - name: "Dev Build ${{ steps.build_info.outputs.version }} (${{ steps.build_info.outputs.date }})" + tag_name: dev-snapshot + name: "Dev Build ${{ steps.build_info.outputs.version }}" + prerelease: true body: | Automated development build. @@ -127,7 +121,5 @@ jobs: ### Changes ${{ needs.generate-changelog.outputs.changelog }} - files: release_files/**/* - prerelease: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + files: | + ./release_files/* From e20d449e4aa4468454ad395e9ce90b84d591f763 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 22 Nov 2025 18:18:57 +0100 Subject: [PATCH 030/298] Add E2E Latency benchmarks --- README.md | 5 + docs/index.md | 6 + docs/testing/e2e_latency.md | 61 ++++ viiper/go.mod | 4 + viiper/go.sum | 8 + viiper/internal/cmd/server.go | 3 + viiper/internal/server/usb/server.go | 4 +- viiper/testing/e2e/bench_test.go | 231 +++++++++++++++ viiper/testing/e2e/scripts/lat_bench.go | 359 ++++++++++++++++++++++++ 9 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 docs/testing/e2e_latency.md create mode 100644 viiper/testing/e2e/bench_test.go create mode 100644 viiper/testing/e2e/scripts/lat_bench.go diff --git a/README.md b/README.md index e32bf2ac..2549eac0 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,11 @@ Proxy mode sits between a USBIP client and a USBIP server (like a Linux machine 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](docs/testing/e2e_latency.md). + ## 📄 License ```license diff --git a/docs/index.md b/docs/index.md index 7ef44362..25165a57 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,3 +43,9 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ API server for device/bus management and controlling virtual devices programmatically - ✅ Multiple client SDKs for easy integration; see [Client SDKs](api/overview.md) MIT Licensed + +--- + +### Additional Resources / Misc + +- [E2E Latency Benchmarks](testing/e2e_latency.md) diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md new file mode 100644 index 00000000..dfc1bf12 --- /dev/null +++ b/docs/testing/e2e_latency.md @@ -0,0 +1,61 @@ +# 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 viiper/testing/e2e +# Single run, 1000 fixed iterations per sub benchmark +go run ./scripts/lat_bench.go -benchtime=1000x -count=1 -format markdown +``` + +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 any other game controllers connected and without an already running VIIPER server instance. diff --git a/viiper/go.mod b/viiper/go.mod index 300e50df..5b79330b 100644 --- a/viiper/go.mod +++ b/viiper/go.mod @@ -3,6 +3,7 @@ module viiper go 1.25 require ( + github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7 github.com/alecthomas/kong v1.13.0 github.com/alecthomas/kong-toml v0.4.0 github.com/alecthomas/kong-yaml v0.2.0 @@ -12,7 +13,10 @@ require ( ) require ( + github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.7.0 // indirect ) diff --git a/viiper/go.sum b/viiper/go.sum index 95f46b66..f0b1b92c 100644 --- a/viiper/go.sum +++ b/viiper/go.sum @@ -1,3 +1,7 @@ +github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7 h1:ZRKdFIh1if8ZitQ9KKKnbglmziT9Sc3JhxY5vauMnTc= +github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7/go.mod h1:a+48Psmm0D/PuXXB9CW0u9faFMxhNoWvZdfSXuKoD58= +github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c h1:3z1BdpfvUbaP7oXjPabl7STN7zz88S432hZJ8M095kI= +github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c/go.mod h1:xpUxPkAb7v0Ffn/NGp1XpD+tZly4dpSPI7DTAFT37es= 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= @@ -11,6 +15,8 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 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/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b h1:/KAOJuXR4cWaQIiA9xBMDSQJ1JXq5gZHdSK8prrtUqQ= +github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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= @@ -23,6 +29,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= diff --git a/viiper/internal/cmd/server.go b/viiper/internal/cmd/server.go index b87e2a1a..418a2637 100644 --- a/viiper/internal/cmd/server.go +++ b/viiper/internal/cmd/server.go @@ -24,7 +24,10 @@ type Server struct { 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 { s.UsbServerConfig.ConnectionTimeout = s.ConnectionTimeout s.ApiServerConfig.ConnectionTimeout = s.ConnectionTimeout diff --git a/viiper/internal/server/usb/server.go b/viiper/internal/server/usb/server.go index 8bed3dac..cafc9981 100644 --- a/viiper/internal/server/usb/server.go +++ b/viiper/internal/server/usb/server.go @@ -383,7 +383,7 @@ type logConn struct { func (lc *logConn) Read(p []byte) (int, error) { n, err := lc.Conn.Read(p) - if n > 0 { + if n > 0 && lc.s.rawLogger != nil { lc.s.rawLogger.Log(true, p[:n]) } return n, err @@ -391,7 +391,7 @@ func (lc *logConn) Read(p []byte) (int, error) { func (lc *logConn) Write(p []byte) (int, error) { n, err := lc.Conn.Write(p) - if n > 0 { + if n > 0 && lc.s.rawLogger != nil { lc.s.rawLogger.Log(false, p[:n]) } return n, err diff --git a/viiper/testing/e2e/bench_test.go b/viiper/testing/e2e/bench_test.go new file mode 100644 index 00000000..adca901c --- /dev/null +++ b/viiper/testing/e2e/bench_test.go @@ -0,0 +1,231 @@ +package e2e_bench_test + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "testing" + "time" + "viiper/internal/cmd" + "viiper/internal/server/api" + "viiper/internal/server/usb" + "viiper/pkg/apiclient" + "viiper/pkg/apitypes" + "viiper/pkg/device/xbox360" + + _ "viiper/internal/registry" // Register all device handlers + + "github.com/Zyko0/go-sdl3/bin/binsdl" + "github.com/Zyko0/go-sdl3/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) + } + benches := []bench{ + { + name: "1 Go-Client-Write", + 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", + 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", + 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", + 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() + } + }, + }, + } + + b.SetParallelism(1) + + defer binsdl.Load().Unload() + defer sdl.Quit() + sdl.Init(sdl.INIT_GAMEPAD) + + s := cmd.Server{ + UsbServerConfig: usb.ServerConfig{ + Addr: ":3241", + }, + ApiServerConfig: api.ServerConfig{ + Addr: ":3242", + AutoAttachLocalClient: true, + DeviceHandlerConnectTimeout: time.Second * 5, + }, + ConnectionTimeout: 5, + } + 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) + } + }() + c := apiclient.New("localhost:3242") + var busResp *apitypes.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() + + var gamepad *sdl.Gamepad + for range 10 { + sdl.UpdateGamepads() + gIDs, _ := sdl.GetGamepads() + if len(gIDs) > 0 { + gamepad, err = gIDs[0].OpenGamepad() + defer gamepad.Close() + if err != nil { + b.Fatalf("OpenGamepad failed: %v", err) + } + break + } + time.Sleep(time.Second * 1) + } + if gamepad == nil { + b.Fatalf("No gamepad found for testing") + } + padChann := make(chan bool) + prevPadPressed := false + go func() { + defer close(padChann) + for { + select { + case <-ctx.Done(): + return + default: + } + sdl.UpdateGamepads() + pressed := gamepad.Button(sdl.GAMEPAD_BUTTON_SOUTH) + if pressed != prevPadPressed { + padChann <- pressed + prevPadPressed = pressed + } + } + }() + + for _, bench := range benches { + 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/viiper/testing/e2e/scripts/lat_bench.go b/viiper/testing/e2e/scripts/lat_bench.go new file mode 100644 index 00000000..457f527e --- /dev/null +++ b/viiper/testing/e2e/scripts/lat_bench.go @@ -0,0 +1,359 @@ +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 +// +// 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).") +) + +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) + } + 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 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() +} From a833c2ed20acc14679054fd6ea1da3a6888baf71 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 22 Nov 2025 23:17:50 +0100 Subject: [PATCH 031/298] Fix makefile X-Platform compat --- Makefile | 51 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 84f2b0ca..a43259e2 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,50 @@ # VIIPER Makefile # Cross-platform build automation for VIIPER +############################################################ # Variables +# These are defined in a cross-platform way. We branch early +# so that later variable definitions do not need per-OS logic. +############################################################ + 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'") + +# OS-specific helpers +ifeq ($(OS),Windows_NT) + NULL_DEVICE := nul + DATE_CMD := powershell -NoProfile -NonInteractive -Command "Get-Date -Format 'yyyy-MM-dd_HH:mm:ss'" + EXE_EXT := .exe + RM_DIR := rmdir /S /Q + RM_FILE := del /Q + COVERAGE_OUT := $(SRC_DIR)\coverage.out + COVERAGE_HTML := $(SRC_DIR)\coverage.html + export CGO_ENABLED=0 +else + NULL_DEVICE := /dev/null + DATE_CMD := date -u +"%Y-%m-%d_%H:%M:%S" + EXE_EXT := + RM_DIR := rm -rf + RM_FILE := rm -f + COVERAGE_OUT := $(SRC_DIR)/coverage.out + COVERAGE_HTML := $(SRC_DIR)/coverage.html + export CGO_ENABLED=0 +endif + +# Git-derived metadata (robust to missing git by redirecting errors) +VERSION ?= $(shell git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always 2>$(NULL_DEVICE) || echo v0.0.0-dev) +COMMIT := $(shell git rev-parse --short HEAD 2>$(NULL_DEVICE) || echo unknown) +BUILD_TIME := $(shell $(DATE_CMD)) # Go build flags LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X viiper/internal/codegen/common.Version=$(VERSION) 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 +############################################################ +# (Legacy) Windows detection section replaced by unified block +############################################################ .PHONY: all all: test build @@ -77,9 +100,9 @@ build: ## Build for current platform .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 + -@$(RM_DIR) $(DIST_DIR) 2>$(NULL_DEVICE) + -@$(RM_FILE) $(COVERAGE_OUT) 2>$(NULL_DEVICE) + -@$(RM_FILE) $(COVERAGE_HTML) 2>$(NULL_DEVICE) .PHONY: fmt fmt: ## Format Go code From 2631bdc9a425f5fab461e7b4eae63cdb01452dee Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 22 Nov 2025 23:18:03 +0100 Subject: [PATCH 032/298] cleanup --- viiper/cmd/viiper/viiper.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/viiper/cmd/viiper/viiper.go b/viiper/cmd/viiper/viiper.go index 23f5a479..d810283b 100644 --- a/viiper/cmd/viiper/viiper.go +++ b/viiper/cmd/viiper/viiper.go @@ -16,22 +16,6 @@ import ( 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) @@ -79,3 +63,19 @@ func main() { err = ctx.Run() ctx.FatalIfErrorf(err) } + +func findUserConfig(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 "" +} From 9641afda620cb1526685cb7e90eeeb73f20b4bc0 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 22 Nov 2025 23:18:19 +0100 Subject: [PATCH 033/298] Fix go package Changelog(fix) --- .github/workflows/build_base.yml | 16 ++++------ .github/workflows/clients_ci.yml | 5 ++-- {viiper/.vscode => .vscode}/launch.json | 0 Makefile | 6 ++-- README.md | 4 +-- {viiper/pkg/apiclient => apiclient}/client.go | 4 +-- .../apiclient => apiclient}/client_test.go | 4 +-- {viiper/pkg/apiclient => apiclient}/stream.go | 4 +-- .../apiclient => apiclient}/stream_test.go | 22 +++++++------- .../pkg/apiclient => apiclient}/transport.go | 0 .../apiclient => apiclient}/transport_test.go | 2 +- {viiper/pkg/apitypes => apitypes}/structs.go | 0 {viiper/cmd => cmd}/viiper/viiper.go | 11 +++---- {viiper/pkg/device => device}/context.go | 3 +- .../pkg/device => device}/keyboard/const.go | 0 .../pkg/device => device}/keyboard/device.go | 6 ++-- .../pkg/device => device}/keyboard/handler.go | 6 ++-- .../pkg/device => device}/keyboard/helpers.go | 0 .../device => device}/keyboard/inputstate.go | 0 {viiper/pkg/device => device}/mouse/const.go | 0 {viiper/pkg/device => device}/mouse/device.go | 6 ++-- .../pkg/device => device}/mouse/handler.go | 7 +++-- .../pkg/device => device}/mouse/inputstate.go | 0 {viiper/pkg/device => device}/options.go | 0 {viiper/pkg/device => device}/report.go | 0 .../pkg/device => device}/xbox360/const.go | 0 .../pkg/device => device}/xbox360/device.go | 6 ++-- .../pkg/device => device}/xbox360/handler.go | 7 +++-- .../device => device}/xbox360/inputstate.go | 0 docs/api/overview.md | 6 ++-- docs/cli/codegen.md | 14 ++++----- docs/clients/c.md | 1 - docs/clients/csharp.md | 29 ++++++++++--------- docs/clients/generator.md | 21 +++++++------- docs/clients/go.md | 14 ++++----- docs/clients/typescript.md | 3 +- docs/devices/keyboard.md | 6 ++-- docs/devices/mouse.md | 2 +- docs/devices/xbox360.md | 4 +-- docs/testing/e2e_latency.md | 2 +- examples/go/go.mod | 7 ----- examples/go/virtual_keyboard/main.go | 4 +-- examples/go/virtual_mouse/main.go | 4 +-- examples/go/virtual_x360_pad/main.go | 4 +-- viiper/go.mod => go.mod | 2 +- viiper/go.sum => go.sum | 0 internal/.gitignore | 1 + {viiper/internal => internal}/cmd/codegen.go | 5 ++-- {viiper/internal => internal}/cmd/config.go | 2 +- {viiper/internal => internal}/cmd/proxy.go | 5 ++-- {viiper/internal => internal}/cmd/server.go | 9 +++--- .../codegen/cmd/scan-constants/main.go | 2 +- .../codegen/cmd/scan-dtos/main.go | 2 +- .../codegen/cmd/scan-routes/main.go | 2 +- .../codegen/common/enums.go | 0 .../codegen/common/fileheader.go | 0 .../codegen/common/gotypes.go | 0 .../codegen/common/license.go | 0 .../codegen/common/naming.go | 0 .../codegen/common/readme.go | 0 .../codegen/common/version.go | 0 .../codegen/generator/c/cmake.go | 3 +- .../codegen/generator/c/gen.go | 7 +++-- .../codegen/generator/c/header_common.go | 3 +- .../codegen/generator/c/header_device.go | 6 ++-- .../codegen/generator/c/helpers.go | 7 +++-- .../codegen/generator/c/source_common.go | 5 ++-- .../codegen/generator/c/source_device.go | 9 +++--- .../codegen/generator/csharp/client.go | 5 ++-- .../codegen/generator/csharp/constants.go | 6 ++-- .../codegen/generator/csharp/device.go | 3 +- .../codegen/generator/csharp/device_types.go | 4 +-- .../codegen/generator/csharp/gen.go | 5 ++-- .../codegen/generator/csharp/helpers.go | 2 +- .../codegen/generator/csharp/project.go | 3 +- .../codegen/generator/csharp/types.go | 3 +- .../codegen/generator/generator.go | 17 ++++++----- .../generator/typescript/binary_utils.go | 0 .../codegen/generator/typescript/client.go | 7 +++-- .../codegen/generator/typescript/constants.go | 7 +++-- .../generator/typescript/device_types.go | 7 +++-- .../generator/typescript/device_wrapper.go | 0 .../codegen/generator/typescript/gen.go | 5 ++-- .../codegen/generator/typescript/helpers.go | 2 +- .../codegen/generator/typescript/index.go | 3 +- .../codegen/generator/typescript/project.go | 0 .../codegen/generator/typescript/types.go | 5 ++-- .../codegen/meta/meta.go | 2 +- .../codegen/scanner/constants.go | 0 .../codegen/scanner/constants_test.go | 4 +-- .../codegen/scanner/dtos.go | 0 .../codegen/scanner/dtos_test.go | 2 +- .../codegen/scanner/payload.go | 0 .../codegen/scanner/returns.go | 0 .../codegen/scanner/routes.go | 0 .../codegen/scanner/routes_test.go | 0 .../codegen/scanner/wiretags.go | 0 .../internal => internal}/config/config.go | 2 +- .../configpaths/files.go | 6 ++-- {viiper/internal => internal}/log/logging.go | 0 .../internal => internal}/log/rawlogger.go | 0 internal/registry/devices.go | 7 +++++ .../server/api/autoattach.go | 3 +- .../server/api/autoattach_linux.go | 0 .../server/api/autoattach_windows.go | 0 .../server/api/config.go | 0 .../server/api/device_registry.go | 5 ++-- .../server/api/device_registry_test.go | 8 ++--- .../server/api/device_stream_handler.go | 7 +++-- .../server/api/device_stream_handler_test.go | 18 ++++++------ .../server/api/errors.go | 2 +- .../server/api/handler/bus_create.go | 9 +++--- .../server/api/handler/bus_create_test.go | 12 ++++---- .../server/api/handler/bus_device_add.go | 8 ++--- .../server/api/handler/bus_device_add_test.go | 20 ++++++------- .../server/api/handler/bus_device_remove.go | 7 +++-- .../api/handler/bus_device_remove_test.go | 14 ++++----- .../server/api/handler/bus_devices_list.go | 11 +++---- .../api/handler/bus_devices_list_test.go | 14 ++++----- .../server/api/handler/bus_list.go | 7 +++-- .../server/api/handler/bus_list_test.go | 12 ++++---- .../server/api/handler/bus_remove.go | 7 +++-- .../server/api/handler/bus_remove_test.go | 14 ++++----- .../server/api/router.go | 3 +- .../server/api/server.go | 6 ++-- .../server/api/server_test.go | 16 +++++----- .../server/proxy/parser.go | 3 +- .../server/proxy/server.go | 3 +- .../server/usb/config.go | 0 .../server/usb/server.go | 9 +++--- .../testing/api_test_helpers.go | 6 ++-- .../internal => internal}/testing/mocks.go | 7 +++-- {viiper/testing => testing}/e2e/bench_test.go | 17 ++++++----- .../e2e/scripts/lat_bench.go | 0 {viiper/pkg/usb => usb}/device.go | 0 {viiper/pkg/usb => usb}/usbdesc.go | 0 {viiper/pkg/usbip => usbip}/usbip.go | 0 viiper/internal/registry/devices.go | 7 ----- .../virtualbus => virtualbus}/virtualbus.go | 6 ++-- vsc.code-workspace | 3 -- 140 files changed, 351 insertions(+), 330 deletions(-) rename {viiper/.vscode => .vscode}/launch.json (100%) rename {viiper/pkg/apiclient => apiclient}/client.go (98%) rename {viiper/pkg/apiclient => apiclient}/client_test.go (94%) rename {viiper/pkg/apiclient => apiclient}/stream.go (94%) rename {viiper/pkg/apiclient => apiclient}/stream_test.go (89%) rename {viiper/pkg/apiclient => apiclient}/transport.go (100%) rename {viiper/pkg/apiclient => apiclient}/transport_test.go (94%) rename {viiper/pkg/apitypes => apitypes}/structs.go (100%) rename {viiper/cmd => cmd}/viiper/viiper.go (87%) rename {viiper/pkg/device => device}/context.go (91%) rename {viiper/pkg/device => device}/keyboard/const.go (100%) rename {viiper/pkg/device => device}/keyboard/device.go (94%) rename {viiper/pkg/device => device}/keyboard/handler.go (90%) rename {viiper/pkg/device => device}/keyboard/helpers.go (100%) rename {viiper/pkg/device => device}/keyboard/inputstate.go (100%) rename {viiper/pkg/device => device}/mouse/const.go (100%) rename {viiper/pkg/device => device}/mouse/device.go (94%) rename {viiper/pkg/device => device}/mouse/handler.go (85%) rename {viiper/pkg/device => device}/mouse/inputstate.go (100%) rename {viiper/pkg/device => device}/options.go (100%) rename {viiper/pkg/device => device}/report.go (100%) rename {viiper/pkg/device => device}/xbox360/const.go (100%) rename {viiper/pkg/device => device}/xbox360/device.go (98%) rename {viiper/pkg/device => device}/xbox360/handler.go (87%) rename {viiper/pkg/device => device}/xbox360/inputstate.go (100%) delete mode 100644 examples/go/go.mod rename viiper/go.mod => go.mod (95%) rename viiper/go.sum => go.sum (100%) create mode 100644 internal/.gitignore rename {viiper/internal => internal}/cmd/codegen.go (76%) rename {viiper/internal => internal}/cmd/config.go (94%) rename {viiper/internal => internal}/cmd/proxy.go (89%) rename {viiper/internal => internal}/cmd/server.go (90%) rename {viiper/internal => internal}/codegen/cmd/scan-constants/main.go (92%) rename {viiper/internal => internal}/codegen/cmd/scan-dtos/main.go (87%) rename {viiper/internal => internal}/codegen/cmd/scan-routes/main.go (90%) rename {viiper/internal => internal}/codegen/common/enums.go (100%) rename {viiper/internal => internal}/codegen/common/fileheader.go (100%) rename {viiper/internal => internal}/codegen/common/gotypes.go (100%) rename {viiper/internal => internal}/codegen/common/license.go (100%) rename {viiper/internal => internal}/codegen/common/naming.go (100%) rename {viiper/internal => internal}/codegen/common/readme.go (100%) rename {viiper/internal => internal}/codegen/common/version.go (100%) rename {viiper/internal => internal}/codegen/generator/c/cmake.go (92%) rename {viiper/internal => internal}/codegen/generator/c/gen.go (88%) rename {viiper/internal => internal}/codegen/generator/c/header_common.go (95%) rename {viiper/internal => internal}/codegen/generator/c/header_device.go (89%) rename {viiper/internal => internal}/codegen/generator/c/helpers.go (95%) rename {viiper/internal => internal}/codegen/generator/c/source_common.go (96%) rename {viiper/internal => internal}/codegen/generator/c/source_device.go (85%) rename {viiper/internal => internal}/codegen/generator/csharp/client.go (95%) rename {viiper/internal => internal}/codegen/generator/csharp/constants.go (94%) rename {viiper/internal => internal}/codegen/generator/csharp/device.go (94%) rename {viiper/internal => internal}/codegen/generator/csharp/device_types.go (94%) rename {viiper/internal => internal}/codegen/generator/csharp/gen.go (91%) rename {viiper/internal => internal}/codegen/generator/csharp/helpers.go (88%) rename {viiper/internal => internal}/codegen/generator/csharp/project.go (93%) rename {viiper/internal => internal}/codegen/generator/csharp/types.go (93%) rename {viiper/internal => internal}/codegen/generator/generator.go (87%) rename {viiper/internal => internal}/codegen/generator/typescript/binary_utils.go (100%) rename {viiper/internal => internal}/codegen/generator/typescript/client.go (95%) rename {viiper/internal => internal}/codegen/generator/typescript/constants.go (93%) rename {viiper/internal => internal}/codegen/generator/typescript/device_types.go (93%) rename {viiper/internal => internal}/codegen/generator/typescript/device_wrapper.go (100%) rename {viiper/internal => internal}/codegen/generator/typescript/gen.go (92%) rename {viiper/internal => internal}/codegen/generator/typescript/helpers.go (87%) rename {viiper/internal => internal}/codegen/generator/typescript/index.go (93%) rename {viiper/internal => internal}/codegen/generator/typescript/project.go (100%) rename {viiper/internal => internal}/codegen/generator/typescript/types.go (90%) rename {viiper/internal => internal}/codegen/meta/meta.go (88%) rename {viiper/internal => internal}/codegen/scanner/constants.go (100%) rename {viiper/internal => internal}/codegen/scanner/constants_test.go (86%) rename {viiper/internal => internal}/codegen/scanner/dtos.go (100%) rename {viiper/internal => internal}/codegen/scanner/dtos_test.go (94%) rename {viiper/internal => internal}/codegen/scanner/payload.go (100%) rename {viiper/internal => internal}/codegen/scanner/returns.go (100%) rename {viiper/internal => internal}/codegen/scanner/routes.go (100%) rename {viiper/internal => internal}/codegen/scanner/routes_test.go (100%) rename {viiper/internal => internal}/codegen/scanner/wiretags.go (100%) rename {viiper/internal => internal}/config/config.go (95%) rename {viiper/internal => internal}/configpaths/files.go (89%) rename {viiper/internal => internal}/log/logging.go (100%) rename {viiper/internal => internal}/log/rawlogger.go (100%) create mode 100644 internal/registry/devices.go rename {viiper/internal => internal}/server/api/autoattach.go (92%) rename {viiper/internal => internal}/server/api/autoattach_linux.go (100%) rename {viiper/internal => internal}/server/api/autoattach_windows.go (100%) rename {viiper/internal => internal}/server/api/config.go (100%) rename {viiper/internal => internal}/server/api/device_registry.go (92%) rename {viiper/internal => internal}/server/api/device_registry_test.go (92%) rename {viiper/internal => internal}/server/api/device_stream_handler.go (83%) rename {viiper/internal => internal}/server/api/device_stream_handler_test.go (84%) rename {viiper/internal => internal}/server/api/errors.go (92%) rename {viiper/internal => internal}/server/api/handler/bus_create.go (88%) rename {viiper/internal => internal}/server/api/handler/bus_create_test.go (86%) rename {viiper/internal => internal}/server/api/handler/bus_device_add.go (94%) rename {viiper/internal => internal}/server/api/handler/bus_device_add_test.go (90%) rename {viiper/internal => internal}/server/api/handler/bus_device_remove.go (89%) rename {viiper/internal => internal}/server/api/handler/bus_device_remove_test.go (85%) rename {viiper/internal => internal}/server/api/handler/bus_devices_list.go (86%) rename {viiper/internal => internal}/server/api/handler/bus_devices_list_test.go (87%) rename {viiper/internal => internal}/server/api/handler/bus_list.go (78%) rename {viiper/internal => internal}/server/api/handler/bus_list_test.go (77%) rename {viiper/internal => internal}/server/api/handler/bus_remove.go (86%) rename {viiper/internal => internal}/server/api/handler/bus_remove_test.go (87%) rename {viiper/internal => internal}/server/api/router.go (99%) rename {viiper/internal => internal}/server/api/server.go (98%) rename {viiper/internal => internal}/server/api/server_test.go (79%) rename {viiper/internal => internal}/server/proxy/parser.go (95%) rename {viiper/internal => internal}/server/proxy/server.go (95%) rename {viiper/internal => internal}/server/usb/config.go (100%) rename {viiper/internal => internal}/server/usb/server.go (99%) rename {viiper/internal => internal}/testing/api_test_helpers.go (95%) rename {viiper/internal => internal}/testing/mocks.go (80%) rename {viiper/testing => testing}/e2e/bench_test.go (89%) rename {viiper/testing => testing}/e2e/scripts/lat_bench.go (100%) rename {viiper/pkg/usb => usb}/device.go (100%) rename {viiper/pkg/usb => usb}/usbdesc.go (100%) rename {viiper/pkg/usbip => usbip}/usbip.go (100%) delete mode 100644 viiper/internal/registry/devices.go rename {viiper/pkg/virtualbus => virtualbus}/virtualbus.go (98%) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index 13dd6513..00efbd43 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -18,9 +18,6 @@ jobs: test: name: Test runs-on: ubuntu-latest - defaults: - run: - working-directory: viiper steps: - name: Checkout code uses: actions/checkout@v4 @@ -31,13 +28,12 @@ jobs: go-version: stable cache: true cache-dependency-path: | - viiper/go.sum + go.sum - name: Show Go version run: go version - name: Run tests - working-directory: . run: make test @@ -53,9 +49,6 @@ jobs: - { 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 @@ -66,7 +59,7 @@ jobs: go-version: stable cache: true cache-dependency-path: | - viiper/go.sum + go.sum - name: Build env: @@ -75,8 +68,9 @@ jobs: CGO_ENABLED: 0 run: | VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - go build -trimpath -ldflags "-s -w -X viiper/internal/codegen/common.Version=${VERSION}" -o ../dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} ./cmd/viiper - ls -la ../dist + mkdir -p dist + go build -trimpath -ldflags "-s -w -X github.com/Alia5/VIIPER/internal/codegen/common.Version=${VERSION}" -o dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} ./cmd/viiper + ls -la dist - name: Upload artifact if: ${{ inputs.upload_artifacts }} diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 10f0264c..eecccc60 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -38,16 +38,15 @@ jobs: go-version: stable cache: true cache-dependency-path: | - viiper/go.sum + go.sum - name: Run code generation - working-directory: viiper shell: bash run: | set -euo pipefail if [ -n "${{ inputs.version }}" ]; then echo "Running codegen with injected version: ${{ inputs.version }}" - go run -ldflags "-X viiper/internal/codegen/common.Version=${{ inputs.version }}" ./cmd/viiper codegen + 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 diff --git a/viiper/.vscode/launch.json b/.vscode/launch.json similarity index 100% rename from viiper/.vscode/launch.json rename to .vscode/launch.json diff --git a/Makefile b/Makefile index a43259e2..0bd15879 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BINARY_NAME := viiper MAIN_PKG := ./cmd/viiper -SRC_DIR := viiper +SRC_DIR := . DIST_DIR := dist # OS-specific helpers @@ -39,7 +39,7 @@ COMMIT := $(shell git rev-parse --short HEAD 2>$(NULL_DEVICE) || echo unknown) BUILD_TIME := $(shell $(DATE_CMD)) # Go build flags -LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X viiper/internal/codegen/common.Version=$(VERSION) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X github.com/Alia5/VIIPER/internal/codegen/common.Version=$(VERSION) BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" ############################################################ @@ -96,7 +96,7 @@ test-coverage: ## Run tests with coverage .PHONY: build build: ## Build for current platform - cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o ../$(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) + cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) .PHONY: clean clean: ## Remove build artifacts diff --git a/README.md b/README.md index 2549eac0..19936b28 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![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.txt) -[![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) +[![Client SDKs: MIT](https://img.shields.io/badge/Client_SDKs-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) @@ -132,7 +132,7 @@ This makes VIIPER portable, easier to extend, and simpler to bundle with applica ### Can I add support for other device types? 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. +Check the [xbox360 device implementation](.//device/xbox360/) as a reference for creating new device types. ### What about the proxy mode? diff --git a/viiper/pkg/apiclient/client.go b/apiclient/client.go similarity index 98% rename from viiper/pkg/apiclient/client.go rename to apiclient/client.go index fd5aae4d..d55905a3 100644 --- a/viiper/pkg/apiclient/client.go +++ b/apiclient/client.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - apitypes "viiper/pkg/apitypes" - "viiper/pkg/device" + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" ) // Client provides a high-level interface to the VIIPER API, handling request diff --git a/viiper/pkg/apiclient/client_test.go b/apiclient/client_test.go similarity index 94% rename from viiper/pkg/apiclient/client_test.go rename to apiclient/client_test.go index fd887ea4..56961323 100644 --- a/viiper/pkg/apiclient/client_test.go +++ b/apiclient/client_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - apiclient "viiper/pkg/apiclient" - apitypes "viiper/pkg/apitypes" + apiclient "github.com/Alia5/VIIPER/apiclient" + apitypes "github.com/Alia5/VIIPER/apitypes" "github.com/stretchr/testify/assert" ) diff --git a/viiper/pkg/apiclient/stream.go b/apiclient/stream.go similarity index 94% rename from viiper/pkg/apiclient/stream.go rename to apiclient/stream.go index f0746c74..ecc9de85 100644 --- a/viiper/pkg/apiclient/stream.go +++ b/apiclient/stream.go @@ -10,8 +10,8 @@ import ( "sync" "time" - apitypes "viiper/pkg/apitypes" - "viiper/pkg/device" + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" ) // DeviceStream represents a bidirectional connection to a device stream. diff --git a/viiper/pkg/apiclient/stream_test.go b/apiclient/stream_test.go similarity index 89% rename from viiper/pkg/apiclient/stream_test.go rename to apiclient/stream_test.go index 83977e92..128de139 100644 --- a/viiper/pkg/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -8,17 +8,17 @@ import ( "testing" "time" - "viiper/internal/log" - api "viiper/internal/server/api" - handler "viiper/internal/server/api/handler" - "viiper/internal/server/usb" - htesting "viiper/internal/testing" - apiclient "viiper/pkg/apiclient" - apitypes "viiper/pkg/apitypes" - "viiper/pkg/device" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" + apiclient "github.com/Alia5/VIIPER/apiclient" + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/log" + api "github.com/Alia5/VIIPER/internal/server/api" + handler "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + htesting "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/viiper/pkg/apiclient/transport.go b/apiclient/transport.go similarity index 100% rename from viiper/pkg/apiclient/transport.go rename to apiclient/transport.go diff --git a/viiper/pkg/apiclient/transport_test.go b/apiclient/transport_test.go similarity index 94% rename from viiper/pkg/apiclient/transport_test.go rename to apiclient/transport_test.go index 25c51178..e3bf4c70 100644 --- a/viiper/pkg/apiclient/transport_test.go +++ b/apiclient/transport_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "viiper/pkg/apiclient" + "github.com/Alia5/VIIPER/apiclient" "github.com/stretchr/testify/assert" ) diff --git a/viiper/pkg/apitypes/structs.go b/apitypes/structs.go similarity index 100% rename from viiper/pkg/apitypes/structs.go rename to apitypes/structs.go diff --git a/viiper/cmd/viiper/viiper.go b/cmd/viiper/viiper.go similarity index 87% rename from viiper/cmd/viiper/viiper.go rename to cmd/viiper/viiper.go index d810283b..ae172800 100644 --- a/viiper/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -3,11 +3,12 @@ package main import ( "os" "strings" - "viiper/internal/config" - "viiper/internal/configpaths" - "viiper/internal/log" - _ "viiper/internal/registry" // Register all device handlers + "github.com/Alia5/VIIPER/internal/config" + "github.com/Alia5/VIIPER/internal/configpaths" + "github.com/Alia5/VIIPER/internal/log" + + _ "github.com/Alia5/VIIPER/internal/registry" // Register all device handlers "github.com/alecthomas/kong" kongtoml "github.com/alecthomas/kong-toml" @@ -21,7 +22,7 @@ func main() { var cli config.CLI ctx := kong.Parse(&cli, - kong.Name("viiper"), + kong.Name("github.com/Alia5/viiper"), kong.Description("Virtual Input over IP EmulatoR"), kong.UsageOnError(), // Load configuration from JSON/YAML/TOML in priority order; flags/env override config values. diff --git a/viiper/pkg/device/context.go b/device/context.go similarity index 91% rename from viiper/pkg/device/context.go rename to device/context.go index 3367e43a..9175b4f6 100644 --- a/viiper/pkg/device/context.go +++ b/device/context.go @@ -4,7 +4,8 @@ package device import ( "context" "time" - "viiper/pkg/usbip" + + "github.com/Alia5/VIIPER/usbip" ) type contextKey int diff --git a/viiper/pkg/device/keyboard/const.go b/device/keyboard/const.go similarity index 100% rename from viiper/pkg/device/keyboard/const.go rename to device/keyboard/const.go diff --git a/viiper/pkg/device/keyboard/device.go b/device/keyboard/device.go similarity index 94% rename from viiper/pkg/device/keyboard/device.go rename to device/keyboard/device.go index b992692f..1dd127e4 100644 --- a/viiper/pkg/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" - "viiper/pkg/device" - "viiper/pkg/usb" - "viiper/pkg/usbip" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" ) // Keyboard implements the Device interface for a full HID keyboard with LED support. diff --git a/viiper/pkg/device/keyboard/handler.go b/device/keyboard/handler.go similarity index 90% rename from viiper/pkg/device/keyboard/handler.go rename to device/keyboard/handler.go index cb563aae..c958648f 100644 --- a/viiper/pkg/device/keyboard/handler.go +++ b/device/keyboard/handler.go @@ -6,9 +6,9 @@ import ( "log/slog" "net" - "viiper/internal/server/api" - "viiper/pkg/device" - "viiper/pkg/usb" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" ) func init() { diff --git a/viiper/pkg/device/keyboard/helpers.go b/device/keyboard/helpers.go similarity index 100% rename from viiper/pkg/device/keyboard/helpers.go rename to device/keyboard/helpers.go diff --git a/viiper/pkg/device/keyboard/inputstate.go b/device/keyboard/inputstate.go similarity index 100% rename from viiper/pkg/device/keyboard/inputstate.go rename to device/keyboard/inputstate.go diff --git a/viiper/pkg/device/mouse/const.go b/device/mouse/const.go similarity index 100% rename from viiper/pkg/device/mouse/const.go rename to device/mouse/const.go diff --git a/viiper/pkg/device/mouse/device.go b/device/mouse/device.go similarity index 94% rename from viiper/pkg/device/mouse/device.go rename to device/mouse/device.go index e4ddbe7e..bc91abeb 100644 --- a/viiper/pkg/device/mouse/device.go +++ b/device/mouse/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" - "viiper/pkg/device" - "viiper/pkg/usb" - "viiper/pkg/usbip" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" ) // Mouse implements the minimal Device interface for a 5-button HID mouse diff --git a/viiper/pkg/device/mouse/handler.go b/device/mouse/handler.go similarity index 85% rename from viiper/pkg/device/mouse/handler.go rename to device/mouse/handler.go index 189aa5e6..206d61f8 100644 --- a/viiper/pkg/device/mouse/handler.go +++ b/device/mouse/handler.go @@ -5,9 +5,10 @@ import ( "io" "log/slog" "net" - "viiper/internal/server/api" - "viiper/pkg/device" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" ) func init() { diff --git a/viiper/pkg/device/mouse/inputstate.go b/device/mouse/inputstate.go similarity index 100% rename from viiper/pkg/device/mouse/inputstate.go rename to device/mouse/inputstate.go diff --git a/viiper/pkg/device/options.go b/device/options.go similarity index 100% rename from viiper/pkg/device/options.go rename to device/options.go diff --git a/viiper/pkg/device/report.go b/device/report.go similarity index 100% rename from viiper/pkg/device/report.go rename to device/report.go diff --git a/viiper/pkg/device/xbox360/const.go b/device/xbox360/const.go similarity index 100% rename from viiper/pkg/device/xbox360/const.go rename to device/xbox360/const.go diff --git a/viiper/pkg/device/xbox360/device.go b/device/xbox360/device.go similarity index 98% rename from viiper/pkg/device/xbox360/device.go rename to device/xbox360/device.go index 42f1989e..41217f83 100644 --- a/viiper/pkg/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -5,9 +5,9 @@ import ( "sync" "sync/atomic" - "viiper/pkg/device" - "viiper/pkg/usb" - "viiper/pkg/usbip" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" ) // Xbox360 implements only the minimal Device interface. diff --git a/viiper/pkg/device/xbox360/handler.go b/device/xbox360/handler.go similarity index 87% rename from viiper/pkg/device/xbox360/handler.go rename to device/xbox360/handler.go index 3d9abdd5..65336dce 100644 --- a/viiper/pkg/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -5,9 +5,10 @@ import ( "io" "log/slog" "net" - "viiper/internal/server/api" - "viiper/pkg/device" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" ) func init() { diff --git a/viiper/pkg/device/xbox360/inputstate.go b/device/xbox360/inputstate.go similarity index 100% rename from viiper/pkg/device/xbox360/inputstate.go rename to device/xbox360/inputstate.go diff --git a/docs/api/overview.md b/docs/api/overview.md index a67f33bc..9975be51 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -88,7 +88,7 @@ Direction: server ➜ client (rumble) - Fixed 2-byte packets: - `LeftMotor` uint8, `RightMotor` uint8 -See `pkg/device/xbox360/protocol.go` for full details. +See `/device/xbox360/protocol.go` for full details. #### HID keyboard stream (device type: `keyboard`) @@ -105,7 +105,7 @@ Direction: server ➜ client (LED state) Host-facing HID input report is 34 bytes: [Modifiers (1), Reserved (1), 256-bit key bitmap (32)]. -See `pkg/device/keyboard/` for helpers and constants. +See `/device/keyboard/` for helpers and constants. #### HID mouse stream (device type: `mouse`) @@ -195,7 +195,7 @@ func main() { } ``` -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 diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index 1efcb26c..3c8f63d1 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -24,7 +24,7 @@ Then generates client SDKs with: - Typed constants and enums !!! note "Sourcecode access is required" - The codegen command requires access to VIIPER source code. It must be executed from the `viiper/` module directory within the repository. + The codegen command requires access to VIIPER source code. Run it from the repository root. ## Flags @@ -32,7 +32,7 @@ Then generates client SDKs with: Output directory for generated SDKs (relative to repository root). -**Default:** `../clients` +**Default:** `clients` **Environment Variable:** `VIIPER_CODEGEN_OUTPUT` **Example:** @@ -70,16 +70,14 @@ viiper codegen --lang=typescript ### Generate All SDKs ```bash -cd viiper go run ./cmd/viiper codegen ``` ### Generate C SDK and Rebuild Examples ```bash -cd viiper go run ./cmd/viiper codegen --lang=c -cd ../examples/c +cd examples/c cmake --build build --config Release ``` @@ -87,9 +85,9 @@ cmake --build build --config Release 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 +- `/apitypes/*.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/` diff --git a/docs/clients/c.md b/docs/clients/c.md index 132f16f5..c82f6618 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -23,7 +23,6 @@ The C SDK features: The C SDK is generated from the VIIPER server codebase: ```bash -cd viiper go run ./cmd/viiper codegen --lang=c ``` diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 9b3970fe..d4727d45 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -65,9 +65,8 @@ Use this when modifying the generator or contributing new device types: Only required when enhancing VIIPER itself: ```bash -cd viiper go run ./cmd/viiper codegen --lang=csharp -cd ../clients/csharp +cd clients/csharp dotnet build -c Release Viiper.Client ``` @@ -460,7 +459,7 @@ dotnet run -- localhost Generated SDK layout: -``` +```text clients/csharp/Viiper.Client/ ├── ViiperClient.cs # Management API client ├── ViiperDevice.cs # Device stream wrapper @@ -469,16 +468,16 @@ clients/csharp/Viiper.Client/ │ ├── BusCreateResponse.cs │ └── ... └── Devices/ - ├── Keyboard/ - │ ├── KeyboardInput.cs # Wire format struct - │ └── KeyboardConstants.cs # Enums + maps - ├── Mouse/ - │ ├── MouseInput.cs - │ └── MouseConstants.cs - └── Xbox360/ - ├── Xbox360Input.cs - ├── Xbox360Output.cs - └── Xbox360Constants.cs + ├── Keyboard/ + │ ├── KeyboardInput.cs # Wire format struct + │ └── KeyboardConstants.cs # Enums + maps + ├── Mouse/ + │ ├── MouseInput.cs + │ └── MouseConstants.cs + └── Xbox360/ + ├── Xbox360Input.cs + ├── Xbox360Output.cs + └── Xbox360Constants.cs ``` ## Troubleshooting @@ -486,16 +485,18 @@ clients/csharp/Viiper.Client/ **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 + $(NoWarn);CS8601;CS8625 ``` diff --git a/docs/clients/generator.md b/docs/clients/generator.md index 5e1bb537..1e65bcf9 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -18,10 +18,9 @@ The VIIPER client generator scans Go source code to extract API routes, device w ## 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=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 ``` @@ -58,7 +57,7 @@ type InputState struct { ... } ### Constant and Map Export -The generator automatically exports all constants and map literals from `pkg/device/*/const.go` for each device type. +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 (C#/TS) or `#define` macros (C) based on common prefixes @@ -69,10 +68,10 @@ No special tags are required. Exported Go constants and maps are emitted with la **Scan Phase:** 1. Parse API routes from `internal/server/api/*.go` -2. Reflect response DTOs from `pkg/apitypes/*.go` +2. Reflect response DTOs from `/apitypes/*.go` 3. Find device types via `RegisterDevice()` calls 4. Parse `viiper:wire` comments for packet layouts -5. Extract all exported constants and map literals 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. @@ -137,7 +136,7 @@ typedef struct { ## Example: Constant and Map Export -**Go source (`pkg/device/keyboard/const.go`):** +**Go source (`/device/keyboard/const.go`):** ```go const ( @@ -206,9 +205,9 @@ public static class CharToKey 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 and map literals +- `/apitypes/*.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) diff --git a/docs/clients/go.md b/docs/clients/go.md index db70c172..29c576bb 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -1,6 +1,6 @@ # 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`. +The Go client is the reference implementation for interacting with VIIPER servers. It's included in the repository under `/apiclient` and `/device`. ## Overview @@ -21,9 +21,9 @@ import ( "log" "time" - apiclient "viiper/pkg/apiclient" - "viiper/pkg/device" - "viiper/pkg/device/keyboard" + apiclient "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/keyboard" ) func main() { @@ -124,7 +124,7 @@ defer stream.Close() Device input is sent using structs that implement `encoding.BinaryMarshaler`: ```go -import "viiper/pkg/device/xbox360" +import "github.com/Alia5/VIIPER/device/xbox360" input := &xbox360.InputState{ Buttons: xbox360.ButtonA, @@ -145,7 +145,7 @@ import ( "bufio" "encoding" "io" - "viiper/pkg/device/xbox360" + "github.com/Alia5/VIIPER/device/xbox360" ) // Start async reading for rumble commands @@ -181,7 +181,7 @@ stream.Close() 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 diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index bffe82d9..e26e9ab7 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -74,9 +74,8 @@ npm run build This is only required if you are contributing to VIIPER or adding device types. Normal users should use the npm package. ```bash -cd viiper go run ./cmd/viiper codegen --lang=typescript -cd ../clients/typescript +cd clients/typescript npm install npm run build ``` diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index 5bcbafda..1ba047fc 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -10,7 +10,7 @@ A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, plu ## Client SDK Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/keyboard`), and **generated SDKs** provide equivalent structures with proper packing. +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/keyboard`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) @@ -53,8 +53,8 @@ The device sends the current LED state (1 byte) back on the same stream whenever 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) +- `/device/keyboard/helpers.go`: TypeString, TypeChar, PressKey, Release, etc. +- `/device/keyboard/const.go`: Modifiers, LED bits, and HID usage IDs, including media keys (Mute, VolumeUp/Down, PlayPause, Stop, Next, Previous) ## Adding the device diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index 854648ab..80638b10 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -8,7 +8,7 @@ A standard 5-button mouse with vertical and horizontal scroll wheels. Reports re ## Client SDK Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/mouse`), and **generated SDKs** provide equivalent structures with proper packing. +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/mouse`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 577b35da..1cc13583 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -8,7 +8,7 @@ The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most ## Client SDK Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`pkg/device/xbox360`), and **generated SDKs** provide equivalent structures with proper packing. +The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/xbox360`), and **generated SDKs** 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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) @@ -45,7 +45,7 @@ Direction: server → client (rumble feedback) - 2-byte packets: - LeftMotor: uint8, RightMotor: uint8 -See `pkg/device/xbox360/inputstate.go` for details. +See `/device/xbox360/inputstate.go` for details. ## Example diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index dfc1bf12..a579af9d 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -35,7 +35,7 @@ Runs use a fixed-iteration benchtime (e.g. `-benchtime=1000x`, `-benchtime=10000 From repository root: ```bash -cd viiper/testing/e2e +cd testing/e2e # Single run, 1000 fixed iterations per sub benchmark go run ./scripts/lat_bench.go -benchtime=1000x -count=1 -format markdown ``` 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_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 88b4c063..164c0980 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -11,8 +11,8 @@ import ( "syscall" "time" - "viiper/pkg/apiclient" - "viiper/pkg/device/keyboard" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/keyboard" ) // Minimal example: create a keyboard device, type "Hello!" + Enter every 5 seconds, monitor LEDs. diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 7115379b..1150d957 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -8,8 +8,8 @@ import ( "syscall" "time" - "viiper/pkg/apiclient" - "viiper/pkg/device/mouse" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/mouse" ) // Minimal example: ensure a bus, create a mouse device, stream inputs, clean up on exit. diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 26867eb3..dd6724c4 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -11,8 +11,8 @@ import ( "syscall" "time" - "viiper/pkg/apiclient" - "viiper/pkg/device/xbox360" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/xbox360" ) // Minimal example: ensure a bus, create an xbox360 device, stream inputs, read rumble, clean up on exit. diff --git a/viiper/go.mod b/go.mod similarity index 95% rename from viiper/go.mod rename to go.mod index 5b79330b..5d8866a9 100644 --- a/viiper/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module viiper +module github.com/Alia5/VIIPER go 1.25 diff --git a/viiper/go.sum b/go.sum similarity index 100% rename from viiper/go.sum rename to go.sum 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/cmd/codegen.go b/internal/cmd/codegen.go similarity index 76% rename from viiper/internal/cmd/codegen.go rename to internal/cmd/codegen.go index 7170c86c..7eb80b14 100644 --- a/viiper/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -2,11 +2,12 @@ package cmd import ( "log/slog" - "viiper/internal/codegen/generator" + + "github.com/Alia5/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"` + 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"` } diff --git a/viiper/internal/cmd/config.go b/internal/cmd/config.go similarity index 94% rename from viiper/internal/cmd/config.go rename to internal/cmd/config.go index b7fae7f6..8d2ad684 100644 --- a/viiper/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "viiper/internal/configpaths" + "github.com/Alia5/VIIPER/internal/configpaths" toml "github.com/pelletier/go-toml" yaml "gopkg.in/yaml.v3" diff --git a/viiper/internal/cmd/proxy.go b/internal/cmd/proxy.go similarity index 89% rename from viiper/internal/cmd/proxy.go rename to internal/cmd/proxy.go index c2a5e55a..8d385ad2 100644 --- a/viiper/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -8,8 +8,9 @@ import ( "os/signal" "syscall" "time" - "viiper/internal/log" - "viiper/internal/server/proxy" + + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/proxy" ) type Proxy struct { diff --git a/viiper/internal/cmd/server.go b/internal/cmd/server.go similarity index 90% rename from viiper/internal/cmd/server.go rename to internal/cmd/server.go index 418a2637..23df7d84 100644 --- a/viiper/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -8,10 +8,11 @@ import ( "os/signal" "syscall" "time" - "viiper/internal/log" - "viiper/internal/server/api" - "viiper/internal/server/api/handler" - "viiper/internal/server/usb" + + "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" ) type Server struct { diff --git a/viiper/internal/codegen/cmd/scan-constants/main.go b/internal/codegen/cmd/scan-constants/main.go similarity index 92% rename from viiper/internal/codegen/cmd/scan-constants/main.go rename to internal/codegen/cmd/scan-constants/main.go index 54cd5af7..f3505bc5 100644 --- a/viiper/internal/codegen/cmd/scan-constants/main.go +++ b/internal/codegen/cmd/scan-constants/main.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "viiper/internal/codegen/scanner" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) func main() { diff --git a/viiper/internal/codegen/cmd/scan-dtos/main.go b/internal/codegen/cmd/scan-dtos/main.go similarity index 87% rename from viiper/internal/codegen/cmd/scan-dtos/main.go rename to internal/codegen/cmd/scan-dtos/main.go index bef5afd0..f339cc11 100644 --- a/viiper/internal/codegen/cmd/scan-dtos/main.go +++ b/internal/codegen/cmd/scan-dtos/main.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - "viiper/internal/codegen/scanner" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) func main() { diff --git a/viiper/internal/codegen/cmd/scan-routes/main.go b/internal/codegen/cmd/scan-routes/main.go similarity index 90% rename from viiper/internal/codegen/cmd/scan-routes/main.go rename to internal/codegen/cmd/scan-routes/main.go index 5b354347..96331f7a 100644 --- a/viiper/internal/codegen/cmd/scan-routes/main.go +++ b/internal/codegen/cmd/scan-routes/main.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - "viiper/internal/codegen/scanner" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) func main() { diff --git a/viiper/internal/codegen/common/enums.go b/internal/codegen/common/enums.go similarity index 100% rename from viiper/internal/codegen/common/enums.go rename to internal/codegen/common/enums.go diff --git a/viiper/internal/codegen/common/fileheader.go b/internal/codegen/common/fileheader.go similarity index 100% rename from viiper/internal/codegen/common/fileheader.go rename to internal/codegen/common/fileheader.go diff --git a/viiper/internal/codegen/common/gotypes.go b/internal/codegen/common/gotypes.go similarity index 100% rename from viiper/internal/codegen/common/gotypes.go rename to internal/codegen/common/gotypes.go diff --git a/viiper/internal/codegen/common/license.go b/internal/codegen/common/license.go similarity index 100% rename from viiper/internal/codegen/common/license.go rename to internal/codegen/common/license.go diff --git a/viiper/internal/codegen/common/naming.go b/internal/codegen/common/naming.go similarity index 100% rename from viiper/internal/codegen/common/naming.go rename to internal/codegen/common/naming.go diff --git a/viiper/internal/codegen/common/readme.go b/internal/codegen/common/readme.go similarity index 100% rename from viiper/internal/codegen/common/readme.go rename to internal/codegen/common/readme.go diff --git a/viiper/internal/codegen/common/version.go b/internal/codegen/common/version.go similarity index 100% rename from viiper/internal/codegen/common/version.go rename to internal/codegen/common/version.go diff --git a/viiper/internal/codegen/generator/c/cmake.go b/internal/codegen/generator/c/cmake.go similarity index 92% rename from viiper/internal/codegen/generator/c/cmake.go rename to internal/codegen/generator/c/cmake.go index 8e1a2e47..4d171f6b 100644 --- a/viiper/internal/codegen/generator/c/cmake.go +++ b/internal/codegen/generator/c/cmake.go @@ -8,7 +8,8 @@ import ( "path/filepath" "sort" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) var cmakeTmpl = template.Must(template.New("cmake").Parse(`cmake_minimum_required(VERSION 3.10) diff --git a/viiper/internal/codegen/generator/c/gen.go b/internal/codegen/generator/c/gen.go similarity index 88% rename from viiper/internal/codegen/generator/c/gen.go rename to internal/codegen/generator/c/gen.go index f2088d23..d29350c6 100644 --- a/viiper/internal/codegen/generator/c/gen.go +++ b/internal/codegen/generator/c/gen.go @@ -5,8 +5,9 @@ import ( "log/slog" "os" "path/filepath" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" ) // Generate produces the C SDK layout under outputDir. @@ -24,7 +25,7 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { major, minor, patch := common.ParseVersion(version) logger.Info("Using version", "version", version, "major", major, "minor", minor, "patch", patch) - includeDir := filepath.Join(outputDir, "include", "viiper") + includeDir := filepath.Join(outputDir, "include") srcDir := filepath.Join(outputDir, "src") if err := os.MkdirAll(includeDir, 0755); err != nil { diff --git a/viiper/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go similarity index 95% rename from viiper/internal/codegen/generator/c/header_common.go rename to internal/codegen/generator/c/header_common.go index cd5e87a9..2fcb564b 100644 --- a/viiper/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -6,7 +6,8 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const commonHeaderTmpl = `#ifndef VIIPER_H diff --git a/viiper/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go similarity index 89% rename from viiper/internal/codegen/generator/c/header_device.go rename to internal/codegen/generator/c/header_device.go index a26c955d..6ca028bf 100644 --- a/viiper/internal/codegen/generator/c/header_device.go +++ b/internal/codegen/generator/c/header_device.go @@ -6,8 +6,9 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H @@ -15,6 +16,7 @@ const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H /* Auto-generated VIIPER - C SDK: {{.Device}} device */ +/* Minimal path fix: include common header without duplicated module path */ #include "viiper.h" /* ======================================================================== diff --git a/viiper/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go similarity index 95% rename from viiper/internal/codegen/generator/c/helpers.go rename to internal/codegen/generator/c/helpers.go index a1529fa1..272345b3 100644 --- a/viiper/internal/codegen/generator/c/helpers.go +++ b/internal/codegen/generator/c/helpers.go @@ -4,9 +4,10 @@ import ( "fmt" "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "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 { diff --git a/viiper/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go similarity index 96% rename from viiper/internal/codegen/generator/c/source_common.go rename to internal/codegen/generator/c/source_common.go index 34e6b0a9..810d43d7 100644 --- a/viiper/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -6,12 +6,13 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const commonSourceTmpl = `/* Auto-generated VIIPER - C SDK: common source */ -#include "viiper/viiper.h" +#include "viiper.h" #include #include #include diff --git a/viiper/internal/codegen/generator/c/source_device.go b/internal/codegen/generator/c/source_device.go similarity index 85% rename from viiper/internal/codegen/generator/c/source_device.go rename to internal/codegen/generator/c/source_device.go index 79bf4496..51344152 100644 --- a/viiper/internal/codegen/generator/c/source_device.go +++ b/internal/codegen/generator/c/source_device.go @@ -6,14 +6,15 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) const deviceSourceTmpl = `/* Auto-generated VIIPER - C SDK: device source ({{.Device}}) */ -#include "viiper/viiper.h" -#include "viiper/viiper_{{.Device}}.h" +#include "viiper.h" +#include "viiper_{{.Device}}.h" /* ======================================================================== * {{.Device}} Map Implementations diff --git a/viiper/internal/codegen/generator/csharp/client.go b/internal/codegen/generator/csharp/client.go similarity index 95% rename from viiper/internal/codegen/generator/csharp/client.go rename to internal/codegen/generator/csharp/client.go index f4033aa2..ba803b38 100644 --- a/viiper/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -7,8 +7,9 @@ import ( "path/filepath" "strings" "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) const clientTemplate = `{{writeFileHeader}}using System.Net.Sockets; diff --git a/viiper/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go similarity index 94% rename from viiper/internal/codegen/generator/csharp/constants.go rename to internal/codegen/generator/csharp/constants.go index 72fab9ec..9c277084 100644 --- a/viiper/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -9,9 +9,9 @@ import ( "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + "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 { diff --git a/viiper/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go similarity index 94% rename from viiper/internal/codegen/generator/csharp/device.go rename to internal/codegen/generator/csharp/device.go index 269a0a7b..0ebe1d20 100644 --- a/viiper/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -6,7 +6,8 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const deviceTemplate = `{{writeFileHeader}}using System.IO; diff --git a/viiper/internal/codegen/generator/csharp/device_types.go b/internal/codegen/generator/csharp/device_types.go similarity index 94% rename from viiper/internal/codegen/generator/csharp/device_types.go rename to internal/codegen/generator/csharp/device_types.go index 7faf4fbe..3ef9fbf9 100644 --- a/viiper/internal/codegen/generator/csharp/device_types.go +++ b/internal/codegen/generator/csharp/device_types.go @@ -8,8 +8,8 @@ import ( "strings" "text/template" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + "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 { diff --git a/viiper/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go similarity index 91% rename from viiper/internal/codegen/generator/csharp/gen.go rename to internal/codegen/generator/csharp/gen.go index 6f80bbb2..8422f1e4 100644 --- a/viiper/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -5,8 +5,9 @@ import ( "log/slog" "os" "path/filepath" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" ) // Generate produces the C# SDK layout under outputDir. diff --git a/viiper/internal/codegen/generator/csharp/helpers.go b/internal/codegen/generator/csharp/helpers.go similarity index 88% rename from viiper/internal/codegen/generator/csharp/helpers.go rename to internal/codegen/generator/csharp/helpers.go index 9709eb04..ebec0309 100644 --- a/viiper/internal/codegen/generator/csharp/helpers.go +++ b/internal/codegen/generator/csharp/helpers.go @@ -1,7 +1,7 @@ package csharp import ( - "viiper/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/common" ) func toPascalCase(s string) string { diff --git a/viiper/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go similarity index 93% rename from viiper/internal/codegen/generator/csharp/project.go rename to internal/codegen/generator/csharp/project.go index 6c61bcad..a2c2fb99 100644 --- a/viiper/internal/codegen/generator/csharp/project.go +++ b/internal/codegen/generator/csharp/project.go @@ -6,7 +6,8 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const projectTemplate = ` diff --git a/viiper/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go similarity index 93% rename from viiper/internal/codegen/generator/csharp/types.go rename to internal/codegen/generator/csharp/types.go index 795b8ac8..abbc768c 100644 --- a/viiper/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -8,7 +8,8 @@ import ( "reflect" "strings" "text/template" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const dtoTemplate = `{{writeFileHeader}}using System.Text.Json.Serialization; diff --git a/viiper/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go similarity index 87% rename from viiper/internal/codegen/generator/generator.go rename to internal/codegen/generator/generator.go index 4f053ace..f092d67a 100644 --- a/viiper/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -5,11 +5,12 @@ import ( "log/slog" "os" "path/filepath" - cgen "viiper/internal/codegen/generator/c" - "viiper/internal/codegen/generator/csharp" - "viiper/internal/codegen/generator/typescript" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" + "github.com/Alia5/VIIPER/internal/codegen/generator/csharp" + "github.com/Alia5/VIIPER/internal/codegen/generator/typescript" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) // Generator orchestrates SDK generation for all target languages @@ -77,7 +78,7 @@ func (g *Generator) GenerateLang(lang string) error { // ScanAll runs all scanners to collect metadata func (g *Generator) ScanAll() (*meta.Metadata, error) { - requiredPaths := []string{"internal/cmd", "pkg/apitypes", "pkg/device"} + requiredPaths := []string{"internal/cmd", "apitypes", "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) @@ -100,7 +101,7 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { g.logger.Info("Found API routes", "count", len(routes)) g.logger.Debug("Scanning DTOs") - dtos, err := scanner.ScanDTOsInPackage("pkg/apitypes") + dtos, err := scanner.ScanDTOsInPackage("apitypes") if err != nil { return nil, fmt.Errorf("failed to scan DTOs: %w", err) } @@ -116,7 +117,7 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { } g.logger.Debug("Discovering device packages") - deviceBaseDir := "pkg/device" + deviceBaseDir := "device" entries, err := os.ReadDir(deviceBaseDir) if err != nil { return nil, fmt.Errorf("failed to read device directory: %w", err) diff --git a/viiper/internal/codegen/generator/typescript/binary_utils.go b/internal/codegen/generator/typescript/binary_utils.go similarity index 100% rename from viiper/internal/codegen/generator/typescript/binary_utils.go rename to internal/codegen/generator/typescript/binary_utils.go diff --git a/viiper/internal/codegen/generator/typescript/client.go b/internal/codegen/generator/typescript/client.go similarity index 95% rename from viiper/internal/codegen/generator/typescript/client.go rename to internal/codegen/generator/typescript/client.go index 72cebabe..e0f97388 100644 --- a/viiper/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -7,9 +7,10 @@ import ( "path/filepath" "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) const clientTemplateTS = `{{writeFileHeaderTS}} diff --git a/viiper/internal/codegen/generator/typescript/constants.go b/internal/codegen/generator/typescript/constants.go similarity index 93% rename from viiper/internal/codegen/generator/typescript/constants.go rename to internal/codegen/generator/typescript/constants.go index efb23077..d07a6405 100644 --- a/viiper/internal/codegen/generator/typescript/constants.go +++ b/internal/codegen/generator/typescript/constants.go @@ -8,9 +8,10 @@ import ( "sort" "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" + "github.com/Alia5/VIIPER/internal/codegen/scanner" ) type tsEnumGroup struct { diff --git a/viiper/internal/codegen/generator/typescript/device_types.go b/internal/codegen/generator/typescript/device_types.go similarity index 93% rename from viiper/internal/codegen/generator/typescript/device_types.go rename to internal/codegen/generator/typescript/device_types.go index 061dab04..8c6e061b 100644 --- a/viiper/internal/codegen/generator/typescript/device_types.go +++ b/internal/codegen/generator/typescript/device_types.go @@ -7,9 +7,10 @@ import ( "path/filepath" "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" - "viiper/internal/codegen/scanner" + + "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 { diff --git a/viiper/internal/codegen/generator/typescript/device_wrapper.go b/internal/codegen/generator/typescript/device_wrapper.go similarity index 100% rename from viiper/internal/codegen/generator/typescript/device_wrapper.go rename to internal/codegen/generator/typescript/device_wrapper.go diff --git a/viiper/internal/codegen/generator/typescript/gen.go b/internal/codegen/generator/typescript/gen.go similarity index 92% rename from viiper/internal/codegen/generator/typescript/gen.go rename to internal/codegen/generator/typescript/gen.go index 99b92a69..bf4ffeee 100644 --- a/viiper/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -5,8 +5,9 @@ import ( "log/slog" "os" "path/filepath" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" ) // Generate produces the TypeScript SDK layout under outputDir. diff --git a/viiper/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go similarity index 87% rename from viiper/internal/codegen/generator/typescript/helpers.go rename to internal/codegen/generator/typescript/helpers.go index ad5ec862..98507c6e 100644 --- a/viiper/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -1,7 +1,7 @@ package typescript import ( - "viiper/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/common" ) // goTypeToTS maps Go types to TypeScript types diff --git a/viiper/internal/codegen/generator/typescript/index.go b/internal/codegen/generator/typescript/index.go similarity index 93% rename from viiper/internal/codegen/generator/typescript/index.go rename to internal/codegen/generator/typescript/index.go index 5d4351ef..880aff01 100644 --- a/viiper/internal/codegen/generator/typescript/index.go +++ b/internal/codegen/generator/typescript/index.go @@ -6,7 +6,8 @@ import ( "os" "path/filepath" "text/template" - "viiper/internal/codegen/common" + + "github.com/Alia5/VIIPER/internal/codegen/common" ) const indexTemplate = `{{writeFileHeaderTS}} diff --git a/viiper/internal/codegen/generator/typescript/project.go b/internal/codegen/generator/typescript/project.go similarity index 100% rename from viiper/internal/codegen/generator/typescript/project.go rename to internal/codegen/generator/typescript/project.go diff --git a/viiper/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go similarity index 90% rename from viiper/internal/codegen/generator/typescript/types.go rename to internal/codegen/generator/typescript/types.go index b70fb3d4..2130368e 100644 --- a/viiper/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -8,8 +8,9 @@ import ( "reflect" "strings" "text/template" - "viiper/internal/codegen/common" - "viiper/internal/codegen/meta" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" ) const dtoTemplateTS = `{{writeFileHeaderTS}} diff --git a/viiper/internal/codegen/meta/meta.go b/internal/codegen/meta/meta.go similarity index 88% rename from viiper/internal/codegen/meta/meta.go rename to internal/codegen/meta/meta.go index f5956caf..b98365fa 100644 --- a/viiper/internal/codegen/meta/meta.go +++ b/internal/codegen/meta/meta.go @@ -1,6 +1,6 @@ package meta -import "viiper/internal/codegen/scanner" +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. diff --git a/viiper/internal/codegen/scanner/constants.go b/internal/codegen/scanner/constants.go similarity index 100% rename from viiper/internal/codegen/scanner/constants.go rename to internal/codegen/scanner/constants.go diff --git a/viiper/internal/codegen/scanner/constants_test.go b/internal/codegen/scanner/constants_test.go similarity index 86% rename from viiper/internal/codegen/scanner/constants_test.go rename to internal/codegen/scanner/constants_test.go index e50fde79..87ff2884 100644 --- a/viiper/internal/codegen/scanner/constants_test.go +++ b/internal/codegen/scanner/constants_test.go @@ -7,7 +7,7 @@ import ( func TestScanKeyboardConstants(t *testing.T) { // Relative path from scanner package to keyboard device - keyboardPath := filepath.Join("..", "..", "..", "pkg", "device", "keyboard") + keyboardPath := filepath.Join("..", "..", "..", "device", "keyboard") result, err := ScanDeviceConstants(keyboardPath) if err != nil { @@ -35,7 +35,7 @@ func TestScanKeyboardConstants(t *testing.T) { } func TestScanXbox360Constants(t *testing.T) { - xbox360Path := filepath.Join("..", "..", "..", "pkg", "device", "xbox360") + xbox360Path := filepath.Join("..", "..", "..", "device", "xbox360") result, err := ScanDeviceConstants(xbox360Path) if err != nil { diff --git a/viiper/internal/codegen/scanner/dtos.go b/internal/codegen/scanner/dtos.go similarity index 100% rename from viiper/internal/codegen/scanner/dtos.go rename to internal/codegen/scanner/dtos.go diff --git a/viiper/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go similarity index 94% rename from viiper/internal/codegen/scanner/dtos_test.go rename to internal/codegen/scanner/dtos_test.go index 85dc1854..7098b600 100644 --- a/viiper/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -7,7 +7,7 @@ import ( func TestScanDTOs(t *testing.T) { // Scan the apitypes package - schemas, err := ScanDTOsInPackage("../../../pkg/apitypes") + schemas, err := ScanDTOsInPackage("../../..//apitypes") if err != nil { t.Fatalf("ScanDTOsInPackage failed: %v", err) } diff --git a/viiper/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go similarity index 100% rename from viiper/internal/codegen/scanner/payload.go rename to internal/codegen/scanner/payload.go diff --git a/viiper/internal/codegen/scanner/returns.go b/internal/codegen/scanner/returns.go similarity index 100% rename from viiper/internal/codegen/scanner/returns.go rename to internal/codegen/scanner/returns.go diff --git a/viiper/internal/codegen/scanner/routes.go b/internal/codegen/scanner/routes.go similarity index 100% rename from viiper/internal/codegen/scanner/routes.go rename to internal/codegen/scanner/routes.go diff --git a/viiper/internal/codegen/scanner/routes_test.go b/internal/codegen/scanner/routes_test.go similarity index 100% rename from viiper/internal/codegen/scanner/routes_test.go rename to internal/codegen/scanner/routes_test.go diff --git a/viiper/internal/codegen/scanner/wiretags.go b/internal/codegen/scanner/wiretags.go similarity index 100% rename from viiper/internal/codegen/scanner/wiretags.go rename to internal/codegen/scanner/wiretags.go diff --git a/viiper/internal/config/config.go b/internal/config/config.go similarity index 95% rename from viiper/internal/config/config.go rename to internal/config/config.go index dabc98a2..dfd8706c 100644 --- a/viiper/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,7 @@ package config import ( - "viiper/internal/cmd" + "github.com/Alia5/VIIPER/internal/cmd" ) type Log struct { diff --git a/viiper/internal/configpaths/files.go b/internal/configpaths/files.go similarity index 89% rename from viiper/internal/configpaths/files.go rename to internal/configpaths/files.go index 67cda167..c36cf8d6 100644 --- a/viiper/internal/configpaths/files.go +++ b/internal/configpaths/files.go @@ -17,10 +17,10 @@ func DefaultConfigDir() (string, error) { return "", errors.New("AppData not set") default: if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "viiper"), nil + return filepath.Join(xdg, "github.com/Alia5/viiper"), nil } if home := os.Getenv("HOME"); home != "" { - return filepath.Join(home, ".config", "viiper"), nil + return filepath.Join(home, ".config", "github.com/Alia5/viiper"), nil } return "", errors.New("HOME not set") } @@ -73,7 +73,7 @@ func ConfigCandidatePaths(userPath string) (jsonPaths, yamlPaths, tomlPaths []st // Working directory candidates wd, _ := os.Getwd() - for _, base := range []string{"viiper", "config", "server", "proxy"} { + 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")) diff --git a/viiper/internal/log/logging.go b/internal/log/logging.go similarity index 100% rename from viiper/internal/log/logging.go rename to internal/log/logging.go diff --git a/viiper/internal/log/rawlogger.go b/internal/log/rawlogger.go similarity index 100% rename from viiper/internal/log/rawlogger.go rename to internal/log/rawlogger.go diff --git a/internal/registry/devices.go b/internal/registry/devices.go new file mode 100644 index 00000000..31a88169 --- /dev/null +++ b/internal/registry/devices.go @@ -0,0 +1,7 @@ +package registry + +import ( + _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler + _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler + _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler +) diff --git a/viiper/internal/server/api/autoattach.go b/internal/server/api/autoattach.go similarity index 92% rename from viiper/internal/server/api/autoattach.go rename to internal/server/api/autoattach.go index 9b462699..a0bb5f17 100644 --- a/viiper/internal/server/api/autoattach.go +++ b/internal/server/api/autoattach.go @@ -6,7 +6,8 @@ import ( "log/slog" "os/exec" "strconv" - "viiper/pkg/usbip" + + "github.com/Alia5/VIIPER/usbip" ) func AttachLocalhostClient(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error { diff --git a/viiper/internal/server/api/autoattach_linux.go b/internal/server/api/autoattach_linux.go similarity index 100% rename from viiper/internal/server/api/autoattach_linux.go rename to internal/server/api/autoattach_linux.go diff --git a/viiper/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go similarity index 100% rename from viiper/internal/server/api/autoattach_windows.go rename to internal/server/api/autoattach_windows.go diff --git a/viiper/internal/server/api/config.go b/internal/server/api/config.go similarity index 100% rename from viiper/internal/server/api/config.go rename to internal/server/api/config.go diff --git a/viiper/internal/server/api/device_registry.go b/internal/server/api/device_registry.go similarity index 92% rename from viiper/internal/server/api/device_registry.go rename to internal/server/api/device_registry.go index 6c957ab6..00432856 100644 --- a/viiper/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -2,8 +2,9 @@ package api import ( "sync" - "viiper/pkg/device" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" ) // DeviceRegistration describes a device type, providing both device creation diff --git a/viiper/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go similarity index 92% rename from viiper/internal/server/api/device_registry_test.go rename to internal/server/api/device_registry_test.go index b466eb2d..c4f2afbd 100644 --- a/viiper/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -7,10 +7,10 @@ import ( "github.com/stretchr/testify/assert" - "viiper/internal/server/api" - th "viiper/internal/testing" - "viiper/pkg/device" - "viiper/pkg/usb" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + th "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/usb" ) type mockDevice struct { diff --git a/viiper/internal/server/api/device_stream_handler.go b/internal/server/api/device_stream_handler.go similarity index 83% rename from viiper/internal/server/api/device_stream_handler.go rename to internal/server/api/device_stream_handler.go index 739f5ecd..2570048c 100644 --- a/viiper/internal/server/api/device_stream_handler.go +++ b/internal/server/api/device_stream_handler.go @@ -7,8 +7,9 @@ import ( "path/filepath" "reflect" "strings" - "viiper/internal/server/usb" - pusb "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" ) // DeviceStreamHandler returns a stream handler func that dynamically dispatches @@ -42,7 +43,7 @@ func inferDeviceType(dev any) string { if t.Kind() == reflect.Ptr { t = t.Elem() } - pkg := t.PkgPath() // e.g., "viiper/pkg/device/xbox360" + pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" if pkg != "" { base := filepath.Base(pkg) if base != "." && base != string(filepath.Separator) { diff --git a/viiper/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go similarity index 84% rename from viiper/internal/server/api/device_stream_handler_test.go rename to internal/server/api/device_stream_handler_test.go index a23c4dee..42f2207d 100644 --- a/viiper/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -10,15 +10,15 @@ import ( "github.com/stretchr/testify/require" - "viiper/internal/log" - "viiper/internal/server/api" - srvusb "viiper/internal/server/usb" - htesting "viiper/internal/testing" - th "viiper/internal/testing" - "viiper/pkg/device" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + htesting "github.com/Alia5/VIIPER/internal/testing" + th "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" ) func TestDeviceStreamHandler_Dispatch(t *testing.T) { diff --git a/viiper/internal/server/api/errors.go b/internal/server/api/errors.go similarity index 92% rename from viiper/internal/server/api/errors.go rename to internal/server/api/errors.go index 3196a5fd..faf79418 100644 --- a/viiper/internal/server/api/errors.go +++ b/internal/server/api/errors.go @@ -1,6 +1,6 @@ package api -import "viiper/pkg/apitypes" +import "github.com/Alia5/VIIPER/apitypes" // Factory helpers returning *apitypes.ApiError (single canonical error type). func ErrBadRequest(detail string) *apitypes.ApiError { diff --git a/viiper/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go similarity index 88% rename from viiper/internal/server/api/handler/bus_create.go rename to internal/server/api/handler/bus_create.go index d953ba28..d5f082cb 100644 --- a/viiper/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -5,10 +5,11 @@ import ( "fmt" "log/slog" "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" - "viiper/pkg/virtualbus" + + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" + "github.com/Alia5/VIIPER/virtualbus" ) // BusCreate returns a handler that creates a new bus. diff --git a/viiper/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go similarity index 86% rename from viiper/internal/server/api/handler/bus_create_test.go rename to internal/server/api/handler/bus_create_test.go index b6ef7e0a..1465d0f5 100644 --- a/viiper/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -5,12 +5,12 @@ import ( "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" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusCreate(t *testing.T) { diff --git a/viiper/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go similarity index 94% rename from viiper/internal/server/api/handler/bus_device_add.go rename to internal/server/api/handler/bus_device_add.go index 8f330ee5..3441d44e 100644 --- a/viiper/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -7,10 +7,10 @@ import ( "strconv" "strings" - "viiper/internal/server/api" - usbs "viiper/internal/server/usb" - "viiper/pkg/apitypes" - "viiper/pkg/device" + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + usbs "github.com/Alia5/VIIPER/internal/server/usb" ) // BusDeviceAdd returns a handler to add devices to a bus. diff --git a/viiper/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go similarity index 90% rename from viiper/internal/server/api/handler/bus_device_add_test.go rename to internal/server/api/handler/bus_device_add_test.go index 542bb680..e5e22389 100644 --- a/viiper/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -9,16 +9,16 @@ import ( "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" - th "viiper/internal/testing" - "viiper/pkg/apiclient" - "viiper/pkg/device" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "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" + th "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusDeviceAdd(t *testing.T) { diff --git a/viiper/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go similarity index 89% rename from viiper/internal/server/api/handler/bus_device_remove.go rename to internal/server/api/handler/bus_device_remove.go index 88a713f7..3df67922 100644 --- a/viiper/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -5,9 +5,10 @@ import ( "fmt" "log/slog" "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" + + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" ) // BusDeviceRemove returns a handler that removes a device by device number. 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 85% rename from viiper/internal/server/api/handler/bus_device_remove_test.go rename to internal/server/api/handler/bus_device_remove_test.go index df101dee..7b4499b6 100644 --- a/viiper/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -5,13 +5,13 @@ import ( "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" + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusDeviceRemove(t *testing.T) { diff --git a/viiper/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go similarity index 86% rename from viiper/internal/server/api/handler/bus_devices_list.go rename to internal/server/api/handler/bus_devices_list.go index 84d99ffe..d6f4397a 100644 --- a/viiper/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -8,9 +8,10 @@ import ( "reflect" "strconv" "strings" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" + + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" ) // BusDevicesList returns a handler that lists devices on a bus. @@ -50,7 +51,7 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { } // 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"). +// 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 { @@ -60,7 +61,7 @@ func inferDeviceType(dev any) string { if t.Kind() == reflect.Ptr { t = t.Elem() } - pkg := t.PkgPath() // e.g., "viiper/pkg/device/xbox360" + pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" if pkg != "" { base := filepath.Base(pkg) if base != "." && base != string(filepath.Separator) { 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 87% rename from viiper/internal/server/api/handler/bus_devices_list_test.go rename to internal/server/api/handler/bus_devices_list_test.go index a4eb419a..7ae3fb30 100644 --- a/viiper/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -5,13 +5,13 @@ import ( "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" + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusDevicesList(t *testing.T) { diff --git a/viiper/internal/server/api/handler/bus_list.go b/internal/server/api/handler/bus_list.go similarity index 78% rename from viiper/internal/server/api/handler/bus_list.go rename to internal/server/api/handler/bus_list.go index fb97a75a..e2001d2b 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/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" ) // BusList returns a handler that lists registered busses. diff --git a/viiper/internal/server/api/handler/bus_list_test.go b/internal/server/api/handler/bus_list_test.go similarity index 77% rename from viiper/internal/server/api/handler/bus_list_test.go rename to internal/server/api/handler/bus_list_test.go index e19ebbf1..04f3741f 100644 --- a/viiper/internal/server/api/handler/bus_list_test.go +++ b/internal/server/api/handler/bus_list_test.go @@ -5,12 +5,12 @@ import ( "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" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusList(t *testing.T) { diff --git a/viiper/internal/server/api/handler/bus_remove.go b/internal/server/api/handler/bus_remove.go similarity index 86% rename from viiper/internal/server/api/handler/bus_remove.go rename to internal/server/api/handler/bus_remove.go index e1946573..3f6f7203 100644 --- a/viiper/internal/server/api/handler/bus_remove.go +++ b/internal/server/api/handler/bus_remove.go @@ -5,9 +5,10 @@ import ( "fmt" "log/slog" "strconv" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apitypes" + + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/usb" ) // BusRemove returns a handler that removes a bus. diff --git a/viiper/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go similarity index 87% rename from viiper/internal/server/api/handler/bus_remove_test.go rename to internal/server/api/handler/bus_remove_test.go index 981a25a6..37d9a8c0 100644 --- a/viiper/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -5,13 +5,13 @@ import ( "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" + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/virtualbus" ) func TestBusRemove(t *testing.T) { diff --git a/viiper/internal/server/api/router.go b/internal/server/api/router.go similarity index 99% rename from viiper/internal/server/api/router.go rename to internal/server/api/router.go index e016fa97..5e37e89e 100644 --- a/viiper/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -5,7 +5,8 @@ import ( "log/slog" "net" "strings" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/usb" ) // Request contains route parameters and additional args from the command. diff --git a/viiper/internal/server/api/server.go b/internal/server/api/server.go similarity index 98% rename from viiper/internal/server/api/server.go rename to internal/server/api/server.go index 00881d43..0340d474 100644 --- a/viiper/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -13,9 +13,9 @@ import ( "strconv" "strings" - "viiper/internal/server/usb" - "viiper/pkg/device" - pusb "viiper/pkg/usb" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/usb" + pusb "github.com/Alia5/VIIPER/usb" ) // Server implements a small TCP API for managing virtual bus topology. diff --git a/viiper/internal/server/api/server_test.go b/internal/server/api/server_test.go similarity index 79% rename from viiper/internal/server/api/server_test.go rename to internal/server/api/server_test.go index fe44444d..4d0d2ad2 100644 --- a/viiper/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -9,14 +9,14 @@ import ( "github.com/stretchr/testify/require" - "viiper/internal/log" - "viiper/internal/server/api" - srvusb "viiper/internal/server/usb" - th "viiper/internal/testing" - "viiper/pkg/device" - "viiper/pkg/device/xbox360" - pusb "viiper/pkg/usb" - "viiper/pkg/virtualbus" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + th "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" ) func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { diff --git a/viiper/internal/server/proxy/parser.go b/internal/server/proxy/parser.go similarity index 95% rename from viiper/internal/server/proxy/parser.go rename to internal/server/proxy/parser.go index 0b6972bb..dda9cb22 100644 --- a/viiper/internal/server/proxy/parser.go +++ b/internal/server/proxy/parser.go @@ -5,7 +5,8 @@ import ( "encoding/binary" "fmt" "log/slog" - "viiper/pkg/usbip" + + "github.com/Alia5/VIIPER/usbip" ) // Parser handles USB-IP packet parsing for structured logging. diff --git a/viiper/internal/server/proxy/server.go b/internal/server/proxy/server.go similarity index 95% rename from viiper/internal/server/proxy/server.go rename to internal/server/proxy/server.go index 2c46127c..4236daca 100644 --- a/viiper/internal/server/proxy/server.go +++ b/internal/server/proxy/server.go @@ -9,7 +9,8 @@ import ( "strings" "sync" "time" - "viiper/internal/log" + + "github.com/Alia5/VIIPER/internal/log" ) type Server struct { diff --git a/viiper/internal/server/usb/config.go b/internal/server/usb/config.go similarity index 100% rename from viiper/internal/server/usb/config.go rename to internal/server/usb/config.go diff --git a/viiper/internal/server/usb/server.go b/internal/server/usb/server.go similarity index 99% rename from viiper/internal/server/usb/server.go rename to internal/server/usb/server.go index cafc9981..768759a3 100644 --- a/viiper/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -13,10 +13,11 @@ import ( "sync" "syscall" "time" - "viiper/internal/log" - "viiper/pkg/usb" - "viiper/pkg/usbip" - "viiper/pkg/virtualbus" + + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" + "github.com/Alia5/VIIPER/virtualbus" ) const ( diff --git a/viiper/internal/testing/api_test_helpers.go b/internal/testing/api_test_helpers.go similarity index 95% rename from viiper/internal/testing/api_test_helpers.go rename to internal/testing/api_test_helpers.go index b7124876..3fdbc641 100644 --- a/viiper/internal/testing/api_test_helpers.go +++ b/internal/testing/api_test_helpers.go @@ -11,9 +11,9 @@ import ( "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" ) diff --git a/viiper/internal/testing/mocks.go b/internal/testing/mocks.go similarity index 80% rename from viiper/internal/testing/mocks.go rename to internal/testing/mocks.go index 72886544..d74127c5 100644 --- a/viiper/internal/testing/mocks.go +++ b/internal/testing/mocks.go @@ -2,9 +2,10 @@ package testing import ( "testing" - "viiper/internal/server/api" - "viiper/pkg/device" - "viiper/pkg/usb" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/usb" ) type mockRegistration struct { diff --git a/viiper/testing/e2e/bench_test.go b/testing/e2e/bench_test.go similarity index 89% rename from viiper/testing/e2e/bench_test.go rename to testing/e2e/bench_test.go index adca901c..9975e4fa 100644 --- a/viiper/testing/e2e/bench_test.go +++ b/testing/e2e/bench_test.go @@ -8,14 +8,15 @@ import ( "syscall" "testing" "time" - "viiper/internal/cmd" - "viiper/internal/server/api" - "viiper/internal/server/usb" - "viiper/pkg/apiclient" - "viiper/pkg/apitypes" - "viiper/pkg/device/xbox360" - - _ "viiper/internal/registry" // Register all device handlers + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/apitypes" + "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/internal/registry" // Register all device handlers "github.com/Zyko0/go-sdl3/bin/binsdl" "github.com/Zyko0/go-sdl3/sdl" diff --git a/viiper/testing/e2e/scripts/lat_bench.go b/testing/e2e/scripts/lat_bench.go similarity index 100% rename from viiper/testing/e2e/scripts/lat_bench.go rename to testing/e2e/scripts/lat_bench.go diff --git a/viiper/pkg/usb/device.go b/usb/device.go similarity index 100% rename from viiper/pkg/usb/device.go rename to usb/device.go diff --git a/viiper/pkg/usb/usbdesc.go b/usb/usbdesc.go similarity index 100% rename from viiper/pkg/usb/usbdesc.go rename to usb/usbdesc.go diff --git a/viiper/pkg/usbip/usbip.go b/usbip/usbip.go similarity index 100% rename from viiper/pkg/usbip/usbip.go rename to usbip/usbip.go 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/pkg/virtualbus/virtualbus.go b/virtualbus/virtualbus.go similarity index 98% rename from viiper/pkg/virtualbus/virtualbus.go rename to virtualbus/virtualbus.go index d616072b..7e6b9d75 100644 --- a/viiper/pkg/virtualbus/virtualbus.go +++ b/virtualbus/virtualbus.go @@ -7,9 +7,9 @@ import ( "sync" "time" - "viiper/pkg/device" - "viiper/pkg/usb" - "viiper/pkg/usbip" + "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" diff --git a/vsc.code-workspace b/vsc.code-workspace index 2732e783..a5c758fb 100644 --- a/vsc.code-workspace +++ b/vsc.code-workspace @@ -1,8 +1,5 @@ { "folders": [ - { - "path": "viiper" - }, { "path": "docs" }, From 7f0ec2cd8745b28b7b2ab492253d8f3a53e81bc7 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 23 Nov 2025 16:24:19 +0100 Subject: [PATCH 034/298] Fix Xbox360 Controller reports Changelog(fix) --- device/xbox360/device.go | 14 +++++++----- device/xbox360/inputstate.go | 41 ++++++++++++++++++------------------ 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 41217f83..6c6576d7 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -10,12 +10,11 @@ import ( "github.com/Alia5/VIIPER/usbip" ) -// 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 + rumbleFunc func(XRumbleState) descriptor usb.Descriptor } @@ -66,10 +65,15 @@ func (x *Xbox360) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { } } if dir == usbip.DirOut && ep == 1 { - if len(out) >= 8 { + // 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 { rumble := XRumbleState{ - LeftMotor: out[3], - RightMotor: out[4], + LeftMotor: out[3], // big / low-frequency motor + RightMotor: out[4], // small / high-frequency motor } if x.rumbleFunc != nil { x.rumbleFunc(rumble) diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index cc61627e..ac86c671 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -18,33 +18,32 @@ type InputState struct { RX, RY int16 } -// BuildReport encodes an InputState into the 20-byte Xbox 360 USB report -// layout used by the wired controller. +// BuildReport encodes an InputState into the 20-byte Xbox 360 wired USB input report. +// Layout (indices in the returned slice): // -// 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 +// 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 (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)) + b[4] = st.LT + b[5] = st.RT + binary.LittleEndian.PutUint16(b[6:8], uint16(st.LX)) + binary.LittleEndian.PutUint16(b[8:10], uint16(st.LY)) + binary.LittleEndian.PutUint16(b[10:12], uint16(st.RX)) + binary.LittleEndian.PutUint16(b[12:14], uint16(st.RY)) + // Remaining bytes (14-19) are left zeroed return b } From 9f0d975ae28d376d2f435bfaea03c64b44c9e03c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 23 Nov 2025 17:20:54 +0100 Subject: [PATCH 035/298] Add windows meta-info and Icon Changelog(misc) --- .github/workflows/build_base.yml | 54 +++++++++++++++++++++++++++++++ .gitignore | 6 ++++ Makefile | 30 ++++++++++++++--- scripts/inject-version.ps1 | 32 ++++++++++++++++++ versioninfo.json | 42 ++++++++++++++++++++++++ viiper.ico | Bin 0 -> 68288 bytes 6 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 scripts/inject-version.ps1 create mode 100644 versioninfo.json create mode 100644 viiper.ico diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index 00efbd43..e3cc1314 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 @@ -52,6 +54,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 @@ -61,6 +65,56 @@ jobs: cache-dependency-path: | go.sum + - name: Generate Windows version info + if: matrix.target.goos == 'windows' + run: | + VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") + git fetch --tags --force || true + if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then + VERSION="v0.0.0-dev" + fi + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + + VER_STRIPPED=${VERSION#v} + IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" + MAJOR=${PARTS[0]:-0} + MINOR=${PARTS[1]:-0} + PATCH=${PARTS[2]:-0} + BUILD=0 + if [ ${#PARTS[@]} -gt 3 ]; then + BUILD_STR="${PARTS[3]}" + if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then + BUILD=$BUILD_STR + fi + fi + if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then + MAJOR=0; MINOR=0; PATCH=0; BUILD=0; + fi + + jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ + --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ + '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | + .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.FileVersion.Build = ($build | tonumber) | + .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | + .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | + .StringFileInfo.FileVersion = $verStr | + .StringFileInfo.ProductVersion = $prodVer' \ + versioninfo.json > versioninfo.tmp.json + + echo "Generating resource.syso for ${{ matrix.target.goarch }}"; + if [ "${{ matrix.target.goarch }}" = "amd64" ]; then + goversioninfo -64 -o cmd/viiper/resource.syso versioninfo.tmp.json + elif [ "${{ matrix.target.goarch }}" = "arm64" ]; then + # arm64 requires both -arm and -64 flags per goversioninfo flag semantics + goversioninfo -arm -64 -o cmd/viiper/resource.syso versioninfo.tmp.json + else + goversioninfo -o cmd/viiper/resource.syso versioninfo.tmp.json + fi + - name: Build env: GOOS: ${{ matrix.target.goos }} 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/Makefile b/Makefile index 0bd15879..0ede4388 100644 --- a/Makefile +++ b/Makefile @@ -42,9 +42,9 @@ BUILD_TIME := $(shell $(DATE_CMD)) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X github.com/Alia5/VIIPER/internal/codegen/common.Version=$(VERSION) BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" -############################################################ -# (Legacy) Windows detection section replaced by unified block -############################################################ +# Windows resource embedding +VERSIONINFO_JSON := versioninfo.json +RESOURCE_SYSO := cmd/viiper/resource.syso .PHONY: all all: test build @@ -63,6 +63,8 @@ help: ## Show this help message @echo test Run tests @echo test-coverage Run tests with coverage @echo clean Remove build artifacts + @echo generate-versioninfo Generate Windows version info resource + @echo clean-versioninfo Remove Windows version info resource @echo fmt Format Go code @echo vet Run go vet @echo lint Run golangci-lint @@ -94,12 +96,32 @@ 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: generate-versioninfo +generate-versioninfo: ## Generate Windows version info resource +ifeq ($(OS),Windows_NT) + @echo Generating Windows version info... + @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(VERSIONINFO_JSON)" "versioninfo.tmp.json" + @cd $(SRC_DIR) && goversioninfo -o $(RESOURCE_SYSO) versioninfo.tmp.json + @del versioninfo.tmp.json +else + @echo Skipping versioninfo generation on non-Windows platform +endif + +.PHONY: clean-versioninfo +clean-versioninfo: ## Remove generated Windows version info resource + -@$(RM_FILE) $(RESOURCE_SYSO) 2>$(NULL_DEVICE) + -@$(RM_FILE) versioninfo.tmp.json 2>$(NULL_DEVICE) + .PHONY: build build: ## Build for current platform +ifeq ($(OS),Windows_NT) + @$(MAKE) generate-versioninfo +endif cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) .PHONY: clean -clean: ## Remove build artifacts +clean: clean-versioninfo ## Remove build artifacts -@$(RM_DIR) $(DIST_DIR) 2>$(NULL_DEVICE) -@$(RM_FILE) $(COVERAGE_OUT) 2>$(NULL_DEVICE) -@$(RM_FILE) $(COVERAGE_HTML) 2>$(NULL_DEVICE) diff --git a/scripts/inject-version.ps1 b/scripts/inject-version.ps1 new file mode 100644 index 00000000..d4749117 --- /dev/null +++ b/scripts/inject-version.ps1 @@ -0,0 +1,32 @@ +param( + [string]$Version, + [string]$InputJson, + [string]$OutputJson +) + +$ver = $Version.TrimStart('v') +$parts = $ver -split '[.-]' +$major = [int]$parts[0] +$minor = if ($parts.Length -gt 1) { [int]$parts[1] } else { 0 } +$patch = if ($parts.Length -gt 2) { [int]$parts[2] } else { 0 } +$build = 0 +if ($parts.Length -gt 3) { + $buildStr = $parts[3] + if ($buildStr -match '^\d+$') { + $build = [int]$buildStr + } +} + +$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/versioninfo.json b/versioninfo.json new file mode 100644 index 00000000..4310cebb --- /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 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 0000000000000000000000000000000000000000..9d7a418744b44abe034a20783e8a5531dd46b38a GIT binary patch literal 68288 zcmafZWl$Z#67Ind5a8hM?!h%c2=4Cg?yiU6?(Xgo+}$C#JHaiu>&w0O@2mH=YImx4 zdwQ#8x4Xajx&Z(P01N;G0{k0E0BKMFfEoY*z`^;S41@y!F8>cDsWt?2|;PC7=S3)pw=jVjhZU3ls+-l``Q| z7*dE-NMQnQhdL3!r zL#fYwNu*lTiVGipkrdPH^HO|fhUg@GiuvPcv*JVs%wCok4C^{)E<%iWMtV%ep_0OrCF((x2vL1Zel0+de1)e;=f^AA??g%d{Vi)7Ya$r3_T0#rG`4H17;lUDv(&vrWmB1^>y8^F&ht@|3e+gliq7;$1J`jBSMpY>1%#zf=x zh7)txytAt+z5W(VeQC}2MvU56!Uzq4EXtU_vqroHhQkpxIGv!@G1u5H5Jn3Pmvnf! zlT0UZaNHt{GOO3AwMV%p$HvpV26wE%oo@RJm{k^2m8Ha21Cuh zhqrbl>)2NMz+>2Re!RDLPYomz38j)8FrGcXEa*c30H2>HqftLXIzs<>EC3)WDkoAS zZ1D5{;+OwC9f0#+zua;E_5=XHPXEs@vy!}RFqScgFSGghI@lWI_jE67>NN4!&zAAv zq9Xqak@UqwHc~*7R$hsVNa3Mks#+=~H)Lu8ILm&#Rco~pMbkuh|Hzo_I(j`J@;!>%L9{AeA=xlKJ0>t#YF=eSmA4{u9_+uyARk%-WtY4+6dHg}VgdGl@OZbxPYhcdoRO3ltOhx9yno-B$`YYfl zcO43&u2?Be5S|Y6y$^yz*JhIorOT2Gn>Oq9R|T#bmU8*;(l|$)8Fq_{mq{Ed%MR4Y zbr&YS2UCbae?bd0K|^cH$0TtkICmS1S^tL{=Nmk!h6-oJjNE7|S)&-Okx~s|E_ol# z+i3t0Y{?U!B-ugU@_9lirY^2M57)z|w%E89nHSwOmSB;a-9?D$)KZTE`J^8yp`JGy zGQcMxZmS=Q^lJdxA^-oTyh}Y+O zj^29A+{rs)Nf~Q8nUY&yt#R!;a$Od`och>#iT1Jg}W zX;MmgzP0yPTwElwvx#l|d#TlPGJAp{PIGX#@06W(L@U1|ZlgV&mzDKyAlh61R8W>& zPOmYc%c10sA6cMAIAFIo@TYS8Rvx9^EkzH3D|jU*l$UGLGze(FGS2(AS^-*U%2t2f zri2w8FI5|Pz?>@m#=S9K?aXj{83j5xJlt!!cZq<@FWM9@GkESfl18rM{YUM)fa{-S z@gFOLV$!!-v;w)O{_3XdPj#rwn;Z2s)M#pi8M{aNfYp%b+8TkkYV%cz*dIsbdOsA5 zwKVU`gN;z3hLOMMa$>GBp9;C*c0MVX^JyPA!f-v_x9j^Kc)-U!uxf6uh$vW%ow_u^ z!}#GyP@3ZIoNW76Jq%sil|@Y|NHqhBSE?H^a-A%tDUmBGL?tKF%_z^BI7Dt>_1(nY zkYNC9g1HihRHf$gm$|(1cn4p~2MgO78@HgUF@7Z&G_VuD^A$j4%eS>4`KgYLFKU(l zqVT(MWJ9kIUBc_>eU^V64I{5f48`Y=_t=RY8EnR@|m!reP-EQjK6_=NdVES0W zm#cO}^_pmT*-a|{j|Y8Qf|GoBf$J%oD^-}IrsheHt(Jo@RYj$-&VJ(q-tel9ge=C1J zo?Ga54aSV-QPLkEQ&|-TP&}3ap6U2rb@NjeCeF&|-$W`mG6n~QZkA2~{H~x6Ps6*4 zRUZBElg{H2=B{{sFA`Y8x?$$lNZE3|qU18k!rZ?p4L(l#Ei2z4 zMB4_>Yz8UB#XXMCU@U|Ad~MzP+gx&#cU{)++PEbDM}be1>;qEv#@urQ7pd-cSIf&- zzIkJT)p0T!e?8;*mFLEwbS=TU6LUXKJS4jdmiBRn5az#lPY&3H02*M6VWA4C^h1x4 zFQtEPimq=Oha)jO7UTJoILvfGmRB=YqEV-5rPg+$(0ZSGnZ+t9IgAX)7l_nJe5?ie z>*B1aM*!jLQ%qc^rv|8al4@8Gy8Wf5(0(VR`aYj8X6t+Dp;z=_3Wr+OcMOVb6m{CEDppEq~P}_!Rg`4Z}{mMlW zJBR3uHGWfZq`9sX>ow}*$#X6M5S9EOfPe0TiZ*zC(7fyYc%@3pGmG-+jN=I*^n?d! z1tWPtlj#nnX#_$j#({Yum1cLPh9BYU`$o?IKd}LT7LF>U^77-~J^zsF=jR`0Rm|E) zW&i(A9whnqoaDcG(AmrD4gf%;`d=QbO7V73S@s>7ZmaF;aCdKF{%hqvbQ%Eiss@Z{ zfEj*45BKks>+e`9$!C;jFUg%1&3o`lCOVFU&r1@t{8(Fw)?P1yVyVk6g89NUi~}kG z=NwmyYDvEO^;~RuKMKghxjPWG#*92XPB(b>Y)`*uZ@*{%0LP;4S=ownJNDGn)T}RT z+A2=?lzUusQOct2f%+Ahcs(EXay}`35;v8(H*Lx-bNP%oEfY!o5ChX?$d3(s6vxSA zyt*fpVCAYyQPuZdzHMq6y1t!ELxQ)_E-ZUm4rgn9))zX&sfXH-Dd+@b<2m{b>{RQe zS*qhypNbup)>>?_RWLQb9H}KavEDrc7MfQ#UN5|JR(}OY5qsf=6`EVa;!jSXm&#jK zlpv93&9cMP{!~%*eYjgMG*T>B+^?5qDwgxr0-U>V5gESzeH1*FB6#MB1a_IeE+PF1 z{JgT=BS-mCq7qY$btJZtyu%<3S0ggG%Cd)*C?Xv6@jf524GAXIC>^6yqO68@3_i!N z{g8flqDi<|U7*@NoTKlxku_F(w3Qcd>dBLQ`&vlO!^HdTSrg!AjwsP!ZM*ouw;pWJ zDTH^|syG^|y;Qrny|t+6eR)B6WnTLhT3Qfr{m2}9jH$ZS+UoGmlP){yV+W|8t(Ss$ z`|f)Siw;fkc0nGPv}wjMv^T~u__c`qdSj;J(uhuM|7%gSV}4yA0Pxot3UI(sTRib) zerp#Z=p?2vR3Bwe_&bS&ur6}&kZ`=}%`;>~Xn{XvkhuP9)t?r~jWWngyc9Jufqs+0 z?*k>#g|~gjDKA#P+#iwcFH`K~osE6}yjKqBtJ6lJuxTW+X3F_sfny0oxJ0~po8yNo zN|AKmq0Ji8lAa=js^qwcUyjTnbwAu*8>V{{Ky+l~R$3nmd`GFyq7PAd#qt3jS)abl z!)Jm>BzQ+;=ktInH38IKS=>%SZ_=7q#QNrNvSs!?Zb_q%MqiB=ziBXV57V>e==%cy(X+KxVGW= zBavY!6!bAh(8O0%>Iv}U^)YAj*6=<3Rz~v5z#679Gs&E+^4P3t%9prfi7E0&8gE0X z$agB|x2#^b?6O874_1yu$c*~PN7#uP*+wB*gCN-&c5#o zo~*qnm^zL6-A`gix(e7&%L=Nhl1FPl3Rkq)3wXcvkSHul2Kt|g86Sm6-Hb{z1M9~2 zK8XQ+3vT>!wbq_Er!Fo5>O*Oji{J354J!7f71mR( zdzR}pTa(4CBM^al%(xbT55sjWK7C68361QkF8p^S!_zWlC*+|mB*ji}*y$PVFugk# zj(bZLgyqe1m-{m1Lv>`lB)CO~LPC zC2qhbg=wgH1u<_PJpG0l4{l8O$$nNwY`P_)X|iTMB)n1(FBCM;0u)+QT&w=oZsnrS z!niUiDu%z#yy0bEoe0;$Uh}BJk=MoAYO^WS@7|39LUQQ#IQ8IWgS902#tS?{0LD1< zI0h?dBHbbU2`&uES6e&qJBOE(NrI7GJYum%8qnuoD$-vbO@C=FwNVR&1WG}H#D!pj zbatYR80HI;M0J2uSp+)fOVrU0#MJ;}X@^loFLgF-cOm ztg1p$wIEg54?cv{;ZVoW0g(>ASBJHW*AUYqyN0BBH_1P}H2$`uyS{Ig-*uh2XorGj zohmlSh9bpSGRTG`e#RN0en)`&5i%okGSl)QgAT37X7r8^0F-ht?CzRM$ z@3zn%a^tr!leb^%wA)cL&r#r`<_&cW3t)tbbP)3>;N9x^h6mryV(Uq^|734phBR~* z7Eclq#laiV+m@jSR?Mj8uJh^~8&=rE1i9;FAvI5TbTqW*bGf>s*>&j5NAK_=K;Tom zbUIp}sI56^%4|a3=N8UHX%TKU{G=*tGA<=&O6X$~QN+`xy+HD3K!v^XNd`y9zOa|v zn0bZc@__#fW&f$OrB3iZb}1{E#DoPEGefAv?DQ_)OkUR2V0%Z%UipV_@{BFDp)ohGZCLdm7n_uv_A z0JN#fxR7i-HfjDc{WT?ZqrJZYUWN|sKzofJOCsKGDbvr9k23xBmP!;?+f$7n1~~7L z`q^2z5mbN5MPm4OoavCoVsWEk4H~q1-BG%wV1AO4cXe~nCP$=|mn-h2s&hmw{V{4M zDR5HkueO7nX(Tq?qj$L#6Y<7(2Vw1XqIIQEkyJ;fwQS@wM@My5V>JY2$lJA==egTy z%d2nyDg5+YQdO4yc)u0A=0oY0$RPPebo6|((~>h~Ss-EP#sxznDx67AN>xADew1T5 z;IL7A(|>pK=$F*{HLH)``m*i1oV-XBvsoi>{-rfR9{HLLTY>@ul~k;so;S8~b!LXM z%rc{-e(1Kop#R^xW%WGfWKWeaK5BqhIjprJc8cZ?(bdRUpdAn}6TWG&Jok~zJM;%@ z>mVjC4niK(({9*&tx$s|jH3ib0Z72y9QAQrTT8^$)l*`C{3(G}SG$AqTHO)&eu_dT zO9dv(Qiow3RCM}-7b~`hV7P~8eV+q~{r;H)xnAtE)mcq-_?#82|#tme`s*>?wo{?^*Vu1 z6=ZDi$#K@rT>oa22F<8xCZz4{@AGmEbe`indK&o`&6e^m+dH?1P306v)_xa|3W6_0ylzSkI?jM71opG*W zd<#)9t`Ub$3r*YgGpHVO>#xlLl49 z%-G;p6>eD}6al1PL+~rVj%;;M3axzg-FrdaP)Sy;{{wF8U{r;dV-4EG9{_9ZtDDIS zjnB{7Xf69`7@2M@tc9W1s0FF4R5%nL+$6J+DTxq*JRwn&!Zfo?fq<&hRSHx8=QTth zo5MkIqC-_zti4n|y~s*NBpKu0G7r5BF3d zB~HBt?a0ASmF51+ltpM{u#WHz%l0bIPFg^n$XQuGI7#^On~kC$I>_CFp~D}4_i9-I zGZ&o1A$!{+fVbUA4v}MFfF&=5bkB~lvi@scJWlN=Ch$+H;2Sz%w7&3@TT#1~zSFm& zkGGYtUse|PsXUxUso4B^W#fq$;ZtGJR!l53_6H!-Z!Z>FGm%TG?z#8VwZ1aZ5O`+{ z==B2_h(WEP1RdYW2Q<}1u;84U6sZ)gyxm0OuTdj^FrHX*d9W0{Lks=FCvAlec%i_~ z_PiXn|HS_fyuKsKCAp{ zPA4)mY|bg}DE0QbeDv*j)vf3@Kh*P?i3}g}9d-XWEv-DQzO7U~%G%};4eU{77rj?y z&#!?6Fa)rF!^e$E$dGA77j71o2rcX&*M0FU2j#@J5=(e+i&9w)3H&|JC0J6LuHZ(qtEnggzJ?#}E`JS~V;|g13yB zPRlcsi!Fz8Mq5f>R);6O+FxK{v>f9pbh%M()?eq(3#j?^?6~~b+RAo9on6em5#}Ji z&&KW-8%Zn*+|9tIBxWFT1ly`bWuXXnGjoM~S?V)6oT{Y@d*5Wng)m<9{LI{1?z73! zrW4i8LNKI*9b)DeMVkEg3ZiPRZ~1cadAF}7CK*^@R%{cw_>Crkk%XSZ_@1e zQ4!&U_V_0STBUfW@{C#AYb`z}1(D`*wR{}DCoSxwhu0DxOwNA4bP;Je5u()uMKA`9 z=4|4WAx13OOjH(MiN|IRE=^34wV;!h`$6 zVwiyZOq%DdV8p|cajD3|kCWh1D;*dZ_R{4*aX%M0dCFl7FwK{*F+zchB3~iu$vWg* zAEf-fj(j14gm;6X|1I7OHfNXW`&~(WZ{|8iP-EJhRhaRW!|K`ehg)NNeIu%{1ux&@ zvE=rLY0K7Gkk~rVv=P%m7^eu$kuT0kV(4l~r);nHpQR6(sOzc0DMITz4otMeSs#)E zIWDdRF{8S)n;G)6P|RdN%zWjEO2+I-6II$#DXQv{F#wjWiWY9tB|;|ZiMjD+hn}r& zr0~dMIepxmGn(=tD+h@R>a&LH=fKEEzXH&csuTnX4Ggfm!P|Q1f|Ik|ZgNioB|w6_ zAbK|yV0*C0vi~3=?Ywz{k4%>gh|&3cmo=Dr&Xdq4RfOF=FLOEh`xaG_TQZS!5C93- z^=(2OwWaP8%{|;`;VQ`$yWWF&mn=N!t4xTfe}w5#KCrNN5izWgCCJ+>d z9RzMi1v8!a!|a^vOs#uB1za5^^+lvd9HI{Eo*VKM(sC_evdi6J>-X4Pdvw&UyDVq7 zh+#9QX_qb5n?@9b=)K-{1PZ<`+8;^QM*$&TAxLT?X`ZQY@F)BZ^nzuzNrRR_kaB=v zeX1Zh@=t9*ZtW~KrydOOjm5-OwXvlj(lK>T%Uv-#15uB#~NI4tL@>8jgZ z+jW$&23Z}!f-%T&pS^DPo1H_B&6&5_ROychEBfbHEfL(M?Pn`7q(c}eAq3bAZ`?hf zc#?F>N!+aK-$HoO@IJe&&rb*KBlrmiNV0MbK0E;aq=z7J20%2e)z`DgN?Q6jv2*cS zDI2rUa}}Bj$%(icZRe#hS^k0qn(A7*;*1Q81*?s{tvR2W`wYRZ9zIXZ&lT8b8S2_` zw4S85i6l@8je<;FPbQ_+QuoeRsK-#YJ`{j19~TpmJ?&+Wa`ZcZTVueVN6<)qeduzw znGRD!hSa|T>djH{6n%}PQd9^j;41%!i|UTinY?P!ROW(@q!{J$&q={4uBN|!Kuy{1 z!pOIgt0fe-3+t*%L!;{=w4AVnYYB`HGISRjjHS_fofLfO!}#L<7?;EAn@5DTE&C^;DJ%c}hoE&xRJAig;rbThC}i~v)d(3GJ=F8} z?baHz!(!z`_|f9nk0J9;3r#i>5efi>65n=QexZyAJ6j?kZZ>t<&1y}c?0NOw-91AV zRzc}pRIf$ZzA3Hxi|l%0c~s9!{UWRGTpW}SzqLjC3OueXy6qp{UG2{u*2!E!9B+SZ zWT~eV=J`ZsZScQfp`Z8np;+`$a3=N+3vGKnUzV6>441jP0vAn4GX05L%|Y!hceQfA z9*^X}Rf<)vcs-T`7=Q^dfZ?4Gh(m|?nno=@N;5*{$u?5l<2&DkIPGo#D`!9KBUL*5 zbJNPDihbbptF`0Ba$EvW((3xuWT#4#PO}Kt-8>xfiNU+AZ^G^T*Mr?27sSY7>=5RUVkIm94usze0sq#wWo3{}}2i_5#u>&;kp z1$)UrnT5zi)UrdTaGggM#cg)3NY8@=A8uZXSQJcW3D68GB)ACJNsRM%?Dt3Y0e}piHRFpx8-r8w_fZQAvFT`G%+STEUL(@{ z-RpeZVVG@;LPR=dSRhM!3^|(GP5N0XGtc`3oWI7#*6N=BeRXyDM_+s=!^E$qVoe+Z zB4g4?6L7(J++r>yEFzik90ZBNO?X%@d!-i#j%)643eFOU^-vT(*`83{jF|X z$9z+~9c9Cjk6@BADP3p`g}(5csTJ>5s4C2S)*0&$e%ssp#$Z>-p}7esb!ut@|IDut}BN&ku-3sPgo$vl)b z-93)!!l6#U1Mc)d00fG7)O7=Enq$(KGPLnMFBJmlj@DHKTs7n<=f)k>>$LkXzo0bmJ5n;&@pC-So9c_4?xqcID-P2Y^mBwh; zw72o}H(Gy)9l;I3<-kRnd(idPwpp%z`4Xv_#lHm~?62J+jY*lE-+hyopAP}6j|)or zwM`kx_JT{OPVo|xCguNPpz^*MBAgD^ifHn%Uq7s1Q|1xq; zX2d&I=_Se#pVeY}i_sU=S)>z4W0wod#X7IzjG2el#StZ-&yw=6vS#7mh}IM4Le98X z?c`r>fIQEFsTp69i6ntWZh6h^{R3a4yqE4m=CswS9jdzR+8S=}a6iqvi2`3c1SvA+ z5E2C3(0){Vvcx-~zH%%_zJ$Dl*vQK(;Nojh3=Q~N(Qz7CXqpTm7%-;(4li~0|6&Q) zG5QHV9!#aNG)7o>7os+3P`fuf5%-h7>Yj;=+*B^XT1@Dayj<^w}d=Ar5RO@;vrCx{j%luWi4c3dmHiPHQ!~ zhLo$FxAK2~68i{dBwnNSmONSs*+$Ki0G0JZKV8}V3>SF2i41e}=(zn;-{dvdOh{s`emg?IckV@EWi&v#mQ{G0KfHjdJITT zJW&+Pto=DU{RZ`c>Tz39NeJ83Er-$Y&E9sCNYDA~KJogS>t2RG;m57CJcqp9&EY!n zd7)**hQnli1A|gHWkNR+zCK2-@Ac$!&{EXQkSl7kd=X9LEh~_8VQuwW@Z*~Us{hU4 z)=WzFWL$}@2%6x~7;vI}yddPTZ33PY{RHYVm;{O4$snl2MU+FZPS@2{>zvTl5`jL_ zqB1c7+7U2{76+3v93PL9h^*)0^w}(7>-u~?XJr4j*A;4X-=u2#Wgw0CX~=%xe*11x zGIZgf$Oi@5hPmyZrk{^61^L<#$mFi2?4g>8R&B< zts+VC-uuwoh?=(P31JUV7P$NJMCz1!3_pP|0TIeVj@u}Gsf+IW4D)2{81kF^K3aPAdJjzzH3-ax1%p`S--!JEiykF~8^X|S* z;6&4*?X6X34+oD0)gFWAL1X!kkNJP!yXs~0Qh%9*8I?f7U`r)otrg89*Jn>SP)q$jOh8rq9bFZH|oHGuxbc*Casy# z$J>i5vm;1&LV3E|$L0EGn|LI`rgM(pE;h`+_9U<0TpR4Wt9SfR5MOPG2+ltT&M9^B zCp3TEy>RG_rgxBF?E0(!HM zasx_pd2_He^d8*t^1FaSGAfW^2&6^fX>kEl2!9c^Z<>qM{iebfY??bh`N+M5Vl96)T2>U{LxgcP%HzI#45dO}HKfGSkJ1o3w9+mS zh=NaH6&GCntfgm?`?#qqJ@LMLc`$EM#k}=|} zRD_EC++LkdyE`s5zK(G(y*~(|d}}*7h@4)uj&Q8U3%D9fxK%v39ozp_BGYWhQ1Vwu z^@~2vLb{L06i?jhY^cqa78+sp*G2|80MNj1M!e&)H9EHY)eXXb|9DThY;Telpkuhl zd(>j5FXU~7wA!sGCOcBtIJp>y82vO@NW3VMtCo{HtCG6n{%ewtpH=Nny=fk|J#RRT zWNgMKY7tZ6`#=yR!}mM_h$q${@GBXPa9oFx;HbG}?=L!5KK?ZMs9Da~->Mu_U!lg8@7>XxzdhbSkPS&tl+Slu0L4CyyoI$H1w09%(bXNC@Zb^pkM6xyuzjS| z6=qPJ@=;BG)(~9$Dhs0dGo%T9<1Cf7g1S;>VL(ABdv+ke%A%oow%aMbU~pJAX&%>< ze{1lF%U~RGt3>_V=f6_3%f#W_&ojF_YIowVmJwC}At1uYqf+m=Qc?i=Jtk!^ZR~ow zfX9fN3o0xh1{Nl;zWXAoTnCzxW^u?_c^M_kzIuqPot;|Xl+x1 zJwfD6#_^Cz&Hl1FVMPKsUob3_%EdS(jwc^@m}|0jylRUMx;&q2yLj^B?y}D0Tf0AD z03n2%L)f%z``}CZtPTGWx)i?j6?_lYPf$7=;)!jTr4 zvj(AMu*`0q+GR=3tgZ2WKVniazO<8zgxEf<-9u4k{sjA}Dyv;AEXol#>UAf5*Me2d z8qO1;_V{G}@TH1Z#T)e`bWuI$`kgamBH-79TROLB)JT1|EiluFsc^@29FcB3uFxU7 zcOAC%N_2PI9dAjO>k5b>FZaDHTB%6M%ZlJ}@Zg4Gl*6mXBbN-KZd7HX7{ukKdE!WK zCa25AN&#!wAD#iYz0tYt_o4y)9h#*bX+r&U*F{MGD%S-15Gn^%^W3}Gl2n}93VEnO zd_lB;m;RC_V83e+07eL0`g?T7b0WcX{4c z>-r;~nl9xKXV1xwe6|z<{}YwOGn#+sjXtQl^cf8yz6;dP+_^0a3BVu>)o4N$jzNUj zwsB}KDw3@s1@`HJgpv5QkBw!HUZyejY~*SytALqDlNh+6ZG3IU+DWd?Dt+U{bl}yk zCq8?f6oFsz)+aVQlUUD-buTp)XOfQGaHXb8X_bew?D6%mVs+eZ`D(>8MhPL0AA zB&o*p0jh9srEuQf!*oD4Um3GiHyYXv2y6gLLLvwBfUCGdCEak`T6%!N8uZM(fCX(J z0ji+EUjUGt2;aB;bJl_Xh|>RX(WU&HZ4?u)sQ|JaYdwW`A;_|3^FZzp@5t4Di>-n z5@H8Z!cSsl{5Sn9*aw4ve5_pa#q!kc$?y(gY z<^_MO480*s(L!RB3Uy$2Y=;v~69ODb41>Ii Rm4Vh*c)NZh%BrKeEw{YT{JKCYfgOC-G)X6c%@6c z1vZq{nK8Zov*>t84^GnwR4eR)1=Wg!Z$a^yw%FrQzuoqpu?Zry>Xb^uqICg)j5aiR zr?v-_u8#`S{cLx&0*2|6W(L$Qs)(FRkC#lo!}@KYIkI^bxz+1VL)WbQ{cUF2NZz6;6(Q=eqQK1@0g4Dy7TPRfF9q9ZR=q$25S zL=2}GD~U(pYC-CL>Zbz*!Fwj3oDiO%ycqyR6fr_VI%`tQ%xY`o9GcjV$a|y4f19I% zdb8z}|2j9kd?Cxi%rZpC(%!%>fT?%a@Xd^)_4oB5TTM%Fv$9eI5fo{6FTcHgCovdcDz5`-`V8+8+;ZG|95Mvzifv(m4fl z;f8W?`(fC!E3@@=@crM_#W-$nq)=JhP91Akx9ql;&Co(@i-89GSZyY?BfEB$>&$nA z7-I(tkj6oHUFz`(g(~LQOa@Z2F1?~*o2OMpYPyzFN^*TLM2}oD^&^h!g#& zVq%pjA6Ee~WBeIk+p@#j38>T|ges4Yb6NY-`%lUMX`D@We4{(0&|kXnrkyWZCu%FA zI?f|RN|dHh+#)Ky9b$d;;SxqV_BDbNbYi}H5An(FI&33|sKD`s0y}k*>a;rEerlNd zSoJ;-Lug@!hkTorzCrNF=DpW2O`t5Veb4z6-<>gt=pndKCoFk6if#G?mI{_8Ln@SF zm^(#%Vw2ebU6Csmmx{FQ*G)BA`W~C*zqrLzI{%t$Zdn8*sIaToOEQhbnGl`cKM)e! z6Im@Kjx<4|{_yLTF)v%F?P17{jgZXriUL4S<*i$4=%v+swQ!CRWpYwjJly9dTQ0t4xqpy_;4IuPefz3qU(GAD zgDD-`(%G*dpXhpKjgWK@XN@khuV#j&a*|@NHNnkfU=9YD$kmpAt)i1s!*OA&afJTS zsP)*q<@mZw`$*`Pc8W&k2#YF*2$Ltv*oBSihf7B@a@8iwmtfb;!M5PnwSR-0-g0S= z#TdikT$e!1l(5cY<9CFMjTU@Ie)T=dI%dnExJp6-$toTcFo^5Q7iEo&afN3(XJg{f z+??e8{uBy;gH3;wiW$LsWhW+1WTu28qXvp#ohkLVM}yK)0rCi zKwhNzLrBfvlx(E(ebgJd{hh}?k6^6D4A{EIyj;i~qrI2bGKFho%A5$BD-~jPoON!G&oIo+TPVYk!8um-nttlve;ebhY{`N3SZt{Z$=qzk=ngm_g%jFr(4isPB+mcR|LgNomaz=Y`WWd&*KH}6iyk5g+gRF;TqpH9D% zIhO%XY!z*H8})?sYEeL;ft9Rd3)lJXuJT5f*k#M%cc7cEqM1zVqx#^WiABVrebdEC zJOYLDM}41_sLJlM4E}BTJ`N}G~oD-1wB%eDzg(Mo0<|*R0cTJ$Y#Pz9G>jH!4V2GVp-y>EzptG+JR^<%eJ4maUt(R zf;1*DR}(X=j4R7Mr(aEVkzSLLMSN|h6d1G>qoO{~6x&hU{QYZFG!An&jdwfaumQ=) zkXWsLZkcwYNv7+_gGU_2L6LVQ=vpgSppSF~){S72O^o{+Pca7gU&JySUa>D}wvNDF zFn{0AXa7>yMIBjSYvJsQ*v3C`q(!xAS$oa?cPiP`QzJf&k}a6**l5p%WY6O=KZWk5 zeFST7^k9K}MBU;L+gwIX4Vbr-!eqnR)5GI1b)igDpT@xVm#25dZS!px9NdL_01y(j zu~9oOPR;mza6ptxpe(mqG(&xkaUN*f@RzUuWQ?`}sZ5$)6Bx!<>h)M2dB|n<{?vC@ zuIV-$&;QfZJUP%KE}!+*idorOH*4#7 zH|m-Uo3M7ju^r>+Bl}ygW!2>}qG} z->FYcy#0m>7nkK4fi*w!EWIo|1d?yMg@tKWB6qL5^h!#+&P2X4({V8Vw}=I{$6dNq zASk#V2IbtZGmE>HtN6@!#B{=W#(rv1(%a)h#W_&Wg%M9h?-fbERio;DH9%-Kck59h z5%6+t+=gGvR#f#1sM<-wnoEqDeo-VKw5U(%`9sl{oqnGf8rlRCES{_wMp6FQE2|H` zVtHlQqR60eDi&L^4J^ej4f=4ZHy3C0T+JYYC z!p%svh@L1U)aDW^g_@0|a>;2#>>+)R=Z7tyOF}9A_kT+y=mU}{#_4r@?>o%r|M^a0Vhy0Nd?*sNZBtp<^??8330c+YW=&xk$ycFE zxw(T%MpZKfnb*yqmCS_$CauXCp0vR+eaKa&I6E#HEV7^MoeW=I&&p&7Z@kf{7SQ0S zAKc+s8Urma0HO&n6f9+PqiZ;vIPIJplF83-gd5 z0x`|9`WH(iQWhjOS%2(6#Y-AtwDy~&ebQ+IC1bnAi?l|CmTwDmFN^!m`E zPChn{PI`-j=L;o0-c%smHvwN;jL1XR<6ThL$IXEUNR`1qqll5FZuAapgHN zF#eMw9d1G>KaMB+n*Wc2F?qX8+S z^Jc%oa;um4=|(oxnJ+9es>a_A0SnZ2f}RzP}JnzBjJZ;qQS0$P2zt zjC`_890ugceK#JnXMYikk~$W*ZH(ZTL%DCt!9L%>ukCO;n)Y*$PtvRXw^=CvF;KWD zO3$o=7ZV&*kMU!mv23Vg_NHBG^@NtR0CggidCg=(K`4yjy236ct0Po8r*tAollr3V47 z3GVN>P+2Rd)zk1j6q3?Ak^us=PR#;S?p3xKV@xqvYg0uFaXomMeC`Scks=jwPh?Iz z5Qnuf8@TV*HOxlc^p>J>S!2Mdp=Dv@@c5lL;Rnc`$SvB5~HmV=Lv>+JEt z{XFlPobT3&ZJo^q41-P= z)o)L#wMzNGc^mJ2(l@L1DPWBl~sF=1HI^=9!J>G0yHgaPJlq6qwPo^_5W++BXC z-nmv|Z<)IG;?Nmej1(LICfK>>9F>bqBq$VfT62ou$0ZC36B4dA6vW#TPg_a@M$rp@ z@v(#a1r>ma6Cu<~8i#B)s!4;Mu6Wfr%9NSeDMv&htWw@3LtF`^*Jko2KoK6Tw4Ngw zm`ec~5{>y7mf-Kz9F)|AG5{~j-4Hrpf1K zbhcL@sxz9J{)Xy0|eTg>8VyPOqt^1QVh}D4`z%4(9UU7e^u$tA_Zx zx@kiOdX}GCr7R^sQugJXGEl}214LXjebo~5#SFF==M1ppgf}NBk6d3EaOyh3pWh+k za>BoSG{hzv{X;g`7b0NQ#C3!m|2jSuvD`j)I%|^_uI;yr1~(_3VHrKh!+j@gLW52+ z3t@ROJXGyU+ysKF;ROr!slX;}1_I;n-~LdMk2y_uisBdkl}qi}?j*IOPEBiKUCCSz zJ#S8(&_yD3#+HM-mO>XBa;-1JflppDFJ_u z0QdRG4q40BX`Y1zZ-#df?sx8MEFmjJ0#G$r5*FN^_bc3yp^Uj_*~Xqa_SfLNs{vvg zficf@xX}I)qm_srDl3CVGxFab34t{)9Xd>ZhnBaDUUnA!zmV-Vj&X1gD}BD!3a09M z+%LZ|!b^Wzr6098I$D0OMo~Fi`l&PztC#vIZ0aJ6xhjSbUlf<@qi|Q!XJMbg|FY_F z_pL#mgQVcHJ>2?8I506vUL5vwbqszRX687N*(}P}(@FacyGNyVY8)BQ%6fBChj~?3 zaY`)LAawAi=5`0SKC zESY<)H@1B4ugo~@HtB zY!{GE4AGiiuQBnN$+Pc!uNO6A(-Rlrb7x}JYMK29Zm*0?2qZqL^+?gb+i!uT->5er z4G5sCZmgxHezgA|0MI}$zrG5CmOPKCZ68YF;kUUVv4W}fA-x2BklR*+B>A0l-Ibr; z{lTZ(^34|8#M|>wPk(a$@=Zw(+;hkFZ{KxmAtHwK=l{+(Z(FC)sJHz1=fC%>%tHfR z8zf+5oG~$At$``kt7T2pesI|qOq@U3=VS>PJaUS~5R(L4OcKbl+yPEomu%@XlHFVy zD*5S5kPNQ^Pzxa_5a1h#7?qKpV!r81SNO@cX4AEuE$y9Ihp(YpZ%<>((cCY>>CZ7c zqs$t@fZBBmqcP}KoOjsTOfn_9_LYxK4~XN;TvFDI9(D52A-{pVpeVon$=^P_tfl&6 z_{C4YTgDiZCLP9K{O+lBS*07b|9So0G9n7~CS-TscEiT^UwyWW=Q*$6BMlrpS27Qj z1-&RWPwl z?TY%z=Qv$X?VaEG$+;P&8?=A;%RjffT<+#z{V!Wwa&AlYC(TKT@qUB&h!WU3^?c@g z(?`WlMAjjIQnm~NIvxw~AHBtIFW^xBereGd0zkmz%XlsT^}&8e0%(?dX`;ofs?qIq|bJk=18( z)NC66xvzhqbMif$vUk@^azr?DIiU(b8()+#b$+XejdO%LUvhbpoNcvZ=08i!YDUBv zz3yb&klzBA!4PH9Xw<>71LMjirRTI(f9$#I_M3v{!K>J{cm9<({C^vg3sxs@y84#Z zXa4odwzh+ZDqS8=JIAr;cVGVd(3H3CVI?rqSMGNs#`0KV)PIh^i^XXDr;w`4E%k0(uUYHpKPEIC{a z?Vm5bK6%HZZn}F%IP*E7761=S8_>wHpl)uGAdbjhx5f7}M#a{IN(ed`0_s8%{r1;a zFJBT?m~4Ekamgod{X2W}E6XC=YRg>rEXfxWkT9U*nE-7a*A}X0C>RgIV*-9h5V}1(Kp~iH~ zS?olFKeFptHF2m6FRo<{*TkH%&J8ZRKK;LpwL| z%CR>&5luYHcYge<053-bx*HOT{{Vvx_K79{$jV4{zV^-sJA3;EK63~{`ums@V1%=p z6FLQO4Rb?d0WFj84)moE9G)lp!84tEum@3R2$`OW5IYsIiCeY#CGGms<)Sh+A;7iS z;&6y__XJ^KcfM}FlHacmiIN7dfM+wcCtgn}U=H@U^%&&}gTIGs7c zYXA*i02uIVk|Y(0;-RT$awW*P8~N}X zUAo~L-=3!0G5ek-y?CWMdH#e4U|mDgJioXhg2yAlKM~;MECOPHhn+AqWSZK>cwPYj zBZwH_%F0E>l<9Jd&Si}Gz47HBar3|Zx?Dlsdyf~!=%|G+KljA;sqN@>XA13ml_blps8FxuvP&w)rbeSZ zYh|}R;QS*yiBP?_KK*|m_}Qk3ep2(xxUAxUH#s84Bg2~l`XQ1?mcBk|jUd8V&dIO< z0$fimTg#GyoykQTEK}RG*?Amdh%k6w6?DGudvpH&&;MALS#nOnCD+~Fw7s%=t1QbW zB=O~j)yvPl`mzhFr?xXmNEF*@5@kuOWh(7@F1utq5y?_l<4Rc+YA2tIBu_lYL|aP! z7ET{?E^>+`V~I*N8Q*FSq#%DgfzDrPJ!84-~sW_C<>eP+`gKakT&P2{po4 zArOixUOLPa%8*GrB$s`+<(+4jPi`xJ@1^a9c{v3iZLMhd{zH%E9qj53x~njiqWs*0 zmt45fbpD3bQr^7mXq{H0GYbdR$*{xL_T=Xga_K&RW-GZ zu?>wa*@7s9T{+kXCT5j0L(;mjAU$($wLEZW9pWTkji{JvE-q6hS@MD;h$MOeL=j2! zD9Adr07;IX+NV!mhpCRA;ztxA!WbtfEqHRtxjfGT;?VP-`DX*CR{O>BxBdK&jpX&t zCuB{A(iK7T2985A4UYcz3BMeFx8Cr@oxgtgd*fM%q|QU8>K{DvJJse7%Vx|9d{T&t ztLBpD6(B+s$#B5fDRQ67ZY{c%>J4r2xEAMumz4rceSh6*(=#)Zc!= zcy~l3I!txShysWZjFVLugz3^sI}p(?4S4L8SC%JUcfDUG-JX};EO$1wY+$y zGuGB0-TZtP+07IEDisQ^sd0xB(LbNQ%jItQ>vR9t_t+DEUlNe<_xM2Je%_?e9OSc# z1!UZ`bqj)COVlT|>`MOZci7`F)rL%-8sY5bWR(UZl_A+TzxT_p=WK3Hi@);fzR3kX zm`3fg*EYnf64M4bt!`%XUGUb5gWVG`^pY%d>sBqT%1lr7OFO1e@Nuh_FV6bkJ8zM0 zx#61HqJnuXk|h=G4I05i% za=YCfrk?(R`oM|LJN6dP&)fO#D(%8J5=Q&ZQ)jHziV=Z~vnWLo( zifk*FE|eCQ6h_6z#uNa+gk@o~+xO=zIxpl7Mz|278yIr|p$tSkkgP?ZpbCp*s&({8 zD~@;LXC5atAAlgyuZYMm5C~;21YO!U!Q2uDP3faq8m8T=7u;-o47Z{Pk#B6wkHyKTqzm7f0o zit*o(F{8_^&!;$%>=Wq`PV5oQrV5+EEdqC((6l>3RG4H;D1!CVF1 z-2n+vB1D*uldb@K=u#Ixf(*VtwcU8Zri$d-zCM-;oB*WZ532iRyDK%EK1r4bM&Ec^ z3zFcs#aF%M^~$V_w3DU;2*8?CdJboIx&o>p$wc=%O?AIIIg&*<8#(zo0Rg~4gOB*$ zfK%JeAAh*U*?eFu&prZhhE+vj&uNjdMBUm;1A=f?Z~Eer1Fh{r(}14{5Fq;Ad%h7M z*O6lnKuSSKPsa!m=9-h`2jGw0E-0gt>H**;oKgYPQoc3C`1wm3m_m`n)XH@4koAD< zbcZ!aIK3f580>8zr*)$8kSxpGGyi%eW7KTjdG7j^#4#4KO7C&NyK~px?f3rZmy0|e zZ(vFL)$O2l(7!inINm;#KWO)GhVA-ij8J2Myu^8fT_5gALBCAoF*t)1B@ z%VFg+B$7>|Z~avdUT?raXN<{TyZ)N0d+xl2Pf1Q#2mmt{9yuD^Zg1j=uv$IpgNMXFT!! zw_CQ=uM4+Z$z|Vde&^X`IL-4$N9pWWT(YV0iqBu*T39kaCdL?@d88KqY;oG1&aRfW z&cP2p-G;Bcu{pQnU{_e{7Ly@@%?_{l#Ypmw2*;CCO9A)>dh&w#3XWAFVt{|XcG04J z8TWlZ9|5$seH~S%82|{+!HoTWWHmJ2u8BYcy(! z=NQN`pxfnPqZSM6>FZZ?9_rP#9Xy!OKQNpyn|n+L5$%&C@ruZPeuU%2DW?E@Xi`e9 zVhmrMx*p&|w_?SfjC;O2p8%MpqM^mxJ2GR*uyH(H2R?CBeQ^Gq3p`5}l@_<$_{Gb6 zEMCPmZ(^&SXwW1ps5~0j2jb#9szG zrdzUXZ~DF8%k$aE=56&RVbD?-Zolq>OdP;J!n~XU zL=3PJB$;~GeC`^lz4K5&5>yz=2uy!;!svaGz5WPu$tkMuW9p;bzy%c3RU^|X6CcpW@U$(DWzNk}Toazby4kr&;_$(rPHP}JaYArdx`E9czCT`xy$9t@j#SvB=K_9Z2bQZI#u{S6z0Ijpw+KZ{)d#=efB5x#fCmx2dnOzOgxI5)?$_5pkV@ z@c&arB}FUL@%shmU}VcM!mKzQ6aXAn0t4?Tq7r@6_J9RE1VDS`&TMyQXM6k=7n@ui z{aN9fHi)QErRApaNttDyuI2 z|3VF)kg*nl!ipUjhX}0h_PQG9-m5oHhoPG<#h1g1?b{XQc61QA0HI?kBTreA-VnN#a#ofD8`xyxC* z-Q;emSsjkw0LO7sc4lf%UQYH%Qc}DpHrgOC4#Dm5;^5GTvb(2O*V5LRI5aXEHyb%J z(^7h$eC*+t)yo&J@)>Y?wA z&M%m^a=PtEr_0s+yCMR?aUP}e?Z!k+`$K0vIDbU8AGehXCl?h!}Erg8)cfv|_twXk>e3 z_5P>+@nZDW?Nx{t7p5u zFwWA&CG9s{dzoqDx>bs_)TGQ~@gN+>Y_{$D=S#0zpZmWzay!GUgBS{;v4W#JiReQ@ z{J>?Z-3< zEjK@xoT&=nFsVgxT2wrU0C!BcNC=Y^LH?qA@(K)jKALe{SRRFN_zU0^NBVX-YRb}N z^Wcni3<~pe5B~f|-|jqT?aBp+c(i!{?VptG-2JWlA1dh|7&_s(1c{)Th&Ch9+m2qj z>`bjdq)RXP6$1NSIOiU=n&jU*1c7mJRv1H0ivSxV}1rsr;CP$ zMyaW_o!_&sK6=-lx-6^BK5Hi@BC-MD2L$w%6NI;+r)hY$eVoE@+O(4Q8L|t)y?}_? zB%+(#J@pmGSF~f}tWW^sXwv4dLQp)y5N7;C5}?51QY$egB@G$NL|##&icb*~@rg?r z<7V8ihr_k`288~D`yG4B=aIt_(w%ogK8?nC^YvHlz5T1#aakE@3w=$Kj|-yM`P_@I zb^i3xCzjgnp*;i@3f_Ckrj2zsefdh;@+GB7daX7;9N&a-vE5#8M`KfK&&#jB%fIyB zcZz4L5XQ`5f~^SfDtU$fI)|EiXLJ22NqTXtj<{Wjm>cdTL?i;hy$(~|?~ku=N6%TM z0LIa!6`v2lJ&cGm<`pC&LSteTi_g$9jxQx5E2q`%QzoWGE23i;BF_975`Zkb9X0O8 zU4py4emT<2i-sDt#`&ddE~)$CE=+oih(jZz_5b(ZcWh5Q{rrN#;gMbSSSJnUIzHgaJ<`*oQTvmN9AtG~k zPhZ>XZ-3x<?@YJV91J3O(_wO6 z2hi#{e$vgJvswX+qfS}4fkV0<8D0|3`NS!ESyI*kri)pG2xD__LatsJpEROMNLj>q zKCEoQvf$b0YT0XXAK0@DB{85<&~#jK;l`RLfAf$^uh$mO=DHbWlI7un;gQa|`X>99 zZB@!ow(dx2Y46OM%X`3KrP2a`e{#5-&%#Knb*}qAp6Jp_??J@JW_ukGz3nj7U3Ds1 z0uco;30+#rLISuC0k4~B*8q;x>Sr;jyI5>W3NkkKHYWf#r&R4xB&DkqhPb7S&Fm~h zM0U|USmUhS9wQBQ&zR&iEj77sU-?@`pYEE)KpIN|9$)Y%(jlMWAYuC zEktA^fWHyCf9vXQ2`HR;s-jITeV#GAacaA|XnvzOJZumL2ZH)^9GMQ>c0z;UCbUXDN_PYwG8oabRYrt@=?*vs+7zeUag&R5pe7pQEYX0 z?C*9p?3~uGAT>3q@Ar@V@W6Q+RxLdm(_a*2)8GH~pN?NV_S90l!!hH|Lnv`|8jX`v z@DdTB%jHpvqHt8%lZeO*0MEz(kGM>A-A5_Ui6bRNt7q}M0MX9^Gj6(QXVNXVE*X7) zbLGH4pA9;-Bw7BBqo;n>7R2!&q5!5Ny*O4Yb9W-bH^U7pCZ(#2OUgZn>Zs+2hqv^` z77}^IUR6ShOOcSWoFUGbRD=Lrl6h#CV_$jfbXI|0t93l^-8=T&`js!HD-`^U>kuBV zx8pZ|_-pSU|MJ{wDU6U~M5NLMMQs;sSlz#N^-{4YFGm|=j4`TIia10x1__1sh@v=b zce;kVO?{Ti-FxNtJ}QgdzO%Ye5XBjl!&qzr(jOcS=g&{|1w&nWX(s_YW$`da16e0>BEb>&^RP|Q?*|!-&Sak@9X5o7r9Vi%)hOK|}$}5^Zwv#lZ334ab)N4u5k!EH0yx#U{-|WE1o2F{?nK z+@nlPmz1&bD;Q(ptQCw1y$AO>YPQTLmp!E0a%M(a-;ck0SMwzoZOpRU9p<0>;&JJv z|Gu+yx`7`TV;sHbu3PJF{NfeybG@(_1owf~_C5DJ@Qc{0J$s9$JCTUW1Q2icni^Y< zvrI=xu~L`DHz9(b^_BDJ?{=tCQ`h=_9@)Hk>%en?(*Yv+5r?V%hbL0nsYDdOQKC(q zzXTZn9VGZtSPO;(5{pl*V6kc0i0D`Rg8;jgG4XAxG@M4c0szF$pFY&=YHaXRtiI`vL{#AJsXuhWg+4+=0URUh zl;TXrxc^0fuZOc3M+72^&!}KANrlLmU)pa1aBy0EwK^jwi&tr9oE{JX$qq}UZRdOO z;lv+IX4SHVO;11mb6Z+U(u&z!cbt&6RqWn={nx*}+~szKT#zK9*BnmAEvMYtfT1)- zr-va#oS1eoV^Xu>bJyj?UbwtUp;xb#1)=T0Enm%=TGg6ldfL%5<|K4dh$w)$!cZEc z)xlj1;cf_0`fDl#%*m2+b}>WTGMwVIM+7n|Rl784Ia)=up>*cHKo+edJMBB($soIV z+8hU|$w~eH_w3_s^9$y!Il+H_-~7XUBo!GulH9~^FbIOsdF73Fx+-^vo)-A#&XO!{ zI@OAz=+YJ*LWBv+j+nw3cl8}wV(3Y@Y;`B&xPp=QH*cM~GcdOMAyB4cp#G#}a*il~ zxh5q=t78eb1H!!sFyo#&af*H}y|9g`v}=PNXWTw@TDGW)iJvi@83A~`{f8YG6#P2y>AX&`?YVb^tinteH@7Bpd?G@o=QqUpH{km>Xu;4wcm0e zfR2xiIuNsQQ9j7>@PV)0WB^h0+Xa$Ie!yv}4_XKFcoR_o$Bkm8CY8U9Aw3XQ<{_P- zmP^UkBV$2>O^C4uRZ5ma9hW$x$xbp2IO}(`dYbnHRVtZjDLrNHzAzf4*N04W5iX)A z_tezZxBdI&H&k!D_sM*l&3;_mk*6|NC(%pmPu81rD!R1NMnv?pTw<>I<|rbLd5Xqt zT3Vrq(ykf!&r91!UVqIm21O>??=;ngUFCW%i70^MPO(y-%6**>et?M6X8aioMNY}9 zVEWj#h#~U-G1jO`%XX+@5~j^~&c4MD;9?r2Qdjz zmV3%~RJAmj3&7!S+_lHGf9Hl^$M3l1`ih_a;GTtuIFrYtqrS1Z<`<7W74`ln z=!X2oKK4;o=R0>6vHoGWcrf5Xb zVXLx!^kO~;o&e>uplDvl`>#DGtCh-ttvIXIR`=(Bykz;))6XvrHjfX5(GX=>wy30Q z`O<}!g~bI@Rz^A>A0MO9X*GHU&uchtET@73!1rt z+GQ>K)Nd+BM1Oaf>b`!WWF9A?08SFBgyKA2$^RA+E}!;9T}%y^nwK$M+AtyzIDOO( zZC1hJ=^_zHrXFkgYopSjDPZoe(P*3>zy93bg1qdtvP{F}6}##m{Kezxb&bv8_!-c$ zMI|lQUVc&E`5V_Nvog~&c*f=|s7R7!)5lwPw0`FYzbNYM8wl!>O++6$5qQLh-inyR5xROin%7D08TR6 z^o18A(G!Rm+7X6R^mEyz1B~OR-4Y~#AyraZlPWQNU8qDQ1mJZy?y3wmCD^!rW&O6D zHH9+;_2{%3$BoxrQG4T;uB4*;+=64sYd9O+FTC=0<^Q|yp>rt6cXw=IP==2I#*&vZ z0?-1X0TJ|opdtbr61D3*S3TA-@D#VcA_qascz20u4f{m0Kx|$CO2B8)W`C?93{!jz&O$q$QnkdBbk(h zoRXpJGiwTnTd!b0)ZES+%lmXL3YWAc0?v2&^-+COQCK~1R|F)zk+F_ zf(~52a~WT&%PrFJT3tw`A`EtKwU)iM0cY_YP%0GO8?U=^&t13QfLR%7i{>aCdDa{} z)Lpsyf@_zFl5|w**vBCv3jkIS$rT8=-62Sovt3KjBrjOO@m$s9HWnS*VZ88$fN2XC zt?P*U+>#t&Xt=8F?mJgbk;UpT)rFNuVlIg&fH9USlxfavh0J9W$Fh)QpC-;Az%)Qi zLZSo!NSJLFnBxH^WNu}~0tLSq zWGFdY@PUc|Aoo-l++kx#sN|Az7GoH``@~qIZeFp9*XV-!7swvx-qCmenI9@6QBB#K zRT=3iC$t<|xIFypC$|6LFaJEQ^JXIG2Baz?+J!Py%bcgd)!ovsODicR!rxCVPKz2{ zzwwe=jKRv+qf#-N@P$<|?n7PM58ijLzb^nlowK`k+8v$a!5OUpbV&;nWga#l&{_mo z34lw&PVjN6qm9|irR5il=Ny<0B62AcQ+I1pGJ{SZWUr@o^zCQzP!h13XRf^$+OIO8NcU6`3{O-3?76IWohpG1F6DH`m zOegw=#~DtYT)K|q=~4i^7zj$|x_;j&h0d^#OD)U~M+uCfRPWRmlq_J33-H#HT#h~F zcmGj}6j;Y#@3yygrKcpH3PD2wy!Y|eEjNARI~%9A$6tSAdE8}}Edc~Al5e_%0?G2I zz+V=9Iy;>z@z?)VFrrT0Aj=PK)# zEq+#&3~;-%yY_E$A;)Yu69xybO)uSq1YZP%D`qMMHXC#+SJoKM+jP_kE3MnM>TKJ~ z13mUsYDdE`^ZU;SltE8Qj2o=4*vugum-vBW#Wrur`SR2RBg%}- zj+|dVnuUiKV?!c3U3INr|JsZJSK**pvaw8^Te=<*f=(y-*yyI1jSF*{Z@pO{x7#lO zOc30;p8n>JxsYQvoVE%;n^L+AIJg-Ju0LK|P|+LDuS)sGHx?lT&ga5K7&Y(ieCWr? z;y}NjJ1(b>8qIzD551gb=Hwfq-Ck#U{4wU*zdyL|eoXQXJ|Q!x-+u_q>dd?jr7ced`z4K3`w$DId@x2l%rvPA$S5K z(3I)v2eW?iu!Ym<79FkF1n3xj|GnM)|9tiwzil_A%9Z=*WA)1T_?ZKI5G3=KE!+D4 z^5=jrx(`grE90VKgB}aoAAz7w$=IbzOkF#ro$R|m+9h;01}yyT{P2~Eyxgo)P6=tcCRv`6c~umu6miHf$A8Nc#evuUTR!sgOMZ)Z#F&`%qo3^16cm&o zvN4N?i0tGQdWE6E0Y^iFV5_W%cQ!Q^O^xlJiXthqPE)eZ!WrY1BBEATnqAiax5qQR zhr0Z%FCzGj!&Dnq?ZLU`v`_%*)RGJi+1)UfbOXYw!0SVmo!xF&x1mSBWSK&dl$e1W z7cv-!06gCQzM8(j|1H{8UspWk7$sbP!?w6DT(VlNn{O+^v zscFYzj0W?8T_1Saltd=S5kZ#8B8r05>Gn7r4yVgvwR(q!My3A2A?P0*#@@aGMNi+L z`h!o)=X<6oa4s&T(Qw|iV{cYeD+knBNiE7mqXeQvwua7hPs@RB+t%0op51uCri$d- zzCNRp^C<;kcxb<6>sG=1{(JK!n=LNn$(+KjDOy&;8FYeu$H(VSJuJ%dI(JX~j@f=V zbHORC01g-Rqz3@lSHhX&@=3|2*h|0Au3xxFsYr;=AJ^cs#nsWiz4M_5mx9;hSDTNE zi|@_&;lmA_N_Er@Frw4#>iofXmx9OTCtJ)_H@v4?xKz(nygZgKLy|xiL{4^ldCBh5 zOLljx$G@>6K88J>* z|1cZ~P>Q|wn@$7>s8t|Ok#`S{4ZsPz2FuEndIKlSO1iRQ?m9NFaJgzQ>wyVjB+(q zm%HkB1Z?Y}F?lmSk$_y+ROu)*$h%~hSkSrh&FuT}E8s8ykklO(o` zyz#d*l!b|%l(D`1%WgBOQsVb(vJ-8Jcta}VSjfmszy63U_I-Qz_Wk9l1(L-)4lT$c*HnY>=obWMvf2bix3KTdwrR6&!>KGZ$3S5PxR`~hi$sU5ubj`NB=n} zoBIo<9%J*C*3A?*5YQiipvf+5R89&nCZeIy*Z-mhNjPZ(_*l`DtS#3REt}STS~L%C zw|?}JpVBm5b!P|TxQV$ss#OE(ti%>gMgp>EO(6peZSo(++VvanL0bq0w0&3=@PX+JJxcB~@+LBVAHNa$<4w2K{?KRuI!l*+b zS?mVU;!GlsH$Y1}_E(jA8Y}(2G=__Atl*+zj%fjm2&|)TJcS?%6DBFDb-l(*zQ#>W z5E>4$ARHKdiLZMv(7L>07Lr1B?6~Xri{0_54Dy#E8h!yaq&1p32`wa zsVONVX{kx}$S4acpe-Y;otpeYesuVr(gCZ)JB_PF1dNe$V5x|o3&DZ z=Y;Z`&Y_10ND&u(K%JA^uS$+JFg0I{1QR<2vM9C=z523g_^mhBq8#W~;olcoL8faR zz4af4{YlIrCtU&PQWjnhj2@jSTMj5y&e)4Cu8G;SNx{d*7a}mNEQx)>$VjhaPql0K zjn|4LvpFCcXY{7aK2oO5FOu!9fUG#dAR_z7>rdfi*I4a}O=aq=qGKKep5Bf!$M(0+ zo%BrRm`iURLXJP`D#5PCUFE?d``ElCdl{!#JnIfQZf{ia@tan9@VKfg|9kFxZ+g*a)O)ckS+;D;z2Fj>F%Te-gcL$T zPe_1dQy?Up#wJ-(*i9gigqEd*r7WR@-Ym)8mL*$~t=>mvr0MO|d;i~ztg%Kjl4fL+ z!2o_p%=h!y3-oEm64ui)a!K?W=cRVWO{{)YPQS4U?|4sH(z;M zConh{fHlljB`Hme)cflGFS_B?%hvDJFW;101|)-nn!L<5O<7imH5;R zR#Qq9iqH>&wwC*oeb2eVSyfY=4b+*-%NZ5^BLKQ^J~_nYC9$>h2yv! zG?ip@wuwgC^ER$K_U=awG3J#2!~r*Onx&K?f&h&J!;UaIqfcecva-gsce7v(_pAL!PF5#X+H`Wz)>-)N5ZuvrBvci9O)+ zL3B*4Q)d;}F{g@{CF z0z@)1Gfw|}>{zp^+W+oH4Kdd!p$bGcTEtY!L5iHC_*^LDb4@j6Nz3TLELLLPM3hDO zpa39FO98#XQ}(Ohb}?pCf$Vk-Prm$;;{EW$IL9OaT&dR?j!{j`XM>|Hjf=T|s`!Tr z08MUn5rdhh0ML%PPJK;9WqaNocllHq)~Qg_0)6bzlh0>n zs+7W2lv0;8(z|kc3|;m5M!>LO{e3bJx{jR*^Qg2=Rd&&${0S6MffD3Glz0&(0V*pz zw&pugPfN_}03j8X_+y~bY)bVWfRG{B-+9bXu_7V>Fw2%k zPbup7v7)3nKR%o`Pfs*Jv_NuC6v$(}F*W_HK5cOvgBY?YW<(%L)F+U8+t->oOIk$O ztJ1AGc{ksNvOafR%iu5W(}}avya7-d!f3xff5mqLw$|S-Y7QyjA0_~_xhp<}F@Aim z7&V)nJ6!nP?+vQ*@-`5)&&e4l>vWt{*hr7AWh=8GB_bu%*_*t{}fG4}_A7}lE8p-kAK z-#3$%!Bd1hfKaqvfi>uBN`LW7(e?hj_4X&8h%qszxFX?q`uv)mA9p9De}n*Fbon*+ zV}Nfb{HsbG%)9HGM~t;=!W(~HTb+irIYB-i>KpY1yKO6xC`VLkakzI6AQUxtvB}!U zS+Y|;VP(fSKl68Bm_-W!LWo58(6G!0CWznXN3s~r0FyjU>S=5Af+Cv`LRqJO<^`R{ zV+#OUFj|fxpEIWLh=zQ6WLca506?5DGSJDEE{zBPF1si$6vzvLw5Y*9MwkqSxV#Z6 zi*qK~P)bEI-07c5utI@x^Y)Q+$>-h2nk}G8&svT-E@>hZRd$IZG+~SW?rR#X^0(SF z#^t5;+KRkY(}DmHsM0UowcW6K&7r>UeP{XX0wDnSlwPM_Nwv#96C63!lS5^&2)c<=^qGOw8bUZDC50O&FT!@}KOlBtaq0vn})-kJ&(m&6nC| zcWX@s)bEMe@QTODv--KecM^uk4=bVI1PS>AN-%(kDCnsyWg&|4PtPGT4~iKV5XCZe zA6IcC6nG9RmBDBYP@gmQqR5|>ZZ7-WQS}sSb#Z=}V2GdhWc_DAt5(Ge0KZ#{4DtR& zN~u>F?d?Dz@A?_nC|kC&3jo#=K7d3%fQ0cO1+%I~Rdzn*EZJ)@o?k;{EwRBUz(@~- zs{Jj!+XLOhW7Z4S3}6)-4LPbZGB;NI@zI{%yT9j_$46rh!6~jtcE2uf#qGh7)+bYc zBJ<;869Br*>KvqI-^BY`H9Fnw-AIXYR{D9wJ`VUgIlXZy0AwPD^C9I- z4ugHtAuV09h)Bc72v)_#X>HUqv0mZS4Um>)1YoleB|Z}Jjw$1P%djHH_=#|SRXwm= z%z*?57%EF^1zSH7YyXv0Cf+=hVcEW_L6vTZ`j@au zRbKYMe>sMJabMf)s!ssW0KgNvf|}akaLd1iWpnCJCNVx10iZ2dl@FAD00<>4vt+Cp zqnz1-eTNU#$sP|VfdEE|1dP#4G-c(WEnl)ia(P=^4K15Mp+)nFTOvulfKW8)MN_eQ za51NUWL`#di~vw3N2ACgRY{#k^gc}|+L-e;E!eW~ABseWGXMZ}*tSuJZRX|5I8{Ms zZe|ST1tK%FL>e0^k;ZN9+R{qO8BB3yys~{bItd#27u$3N2xZ-SnztF&F0IoRXGJSS zF@UxB2S3t{J@)5A?hikRixOZ2cj)tLN&`0K8iY=Y^UpImoO1y%mCj2(0)RP_TNKA+ z?%;ht&YkP;>gx6T^wpcTC$+zXfN$onY+6>zb~~krN*?EN(dXkSrDSIPfjoRNhbdm^ z#sDeBcH^K@$1D_3lr*`w2{DB$4q=sg&Rq3mK?#Xl5WuMxO=dC;CE$k{;|Vh*KO$~+U%QeUC-s^*H1k2R6MaLz~#Dp_Afyj z`r^5uG*7dQJeH;BeE2G%Bj>z7wX7@Fcui-s`IuS8s-PP zRwG5Zm?^B@H}f?h3PsA`$w^8`*FxjbDU1?XJVtnbgW_}SQQY=A#XGr&^8O}DQH+)z z76PRI_(s{*)kI{eiOv(QTJYXMuXFY_&=}l{9d8;$63!IK%&NEV?HkN%I&t!!QpP$L zf}zl&wVp`jqu;rK^fs@N`cCwcfTxa9>X`HO2r;tPwbS3;+(;B9NM*U(^Y$|w5G9&a zq{=Cow}Ua?H@4C7Zlg!x#iKLE#nwwN+g|vCAJtQUIDv)%eO{ke^Yg`i&Y3u88bH|Q z2NnAfAe7wZSE+-HP8%R0UQ2~D-cK;54PZ{4bk|x+X>47*5D<`l?%Wqbh$J~D8U?$J zrING;0Oiaa0PBp~s5QSKtTIODBm|boT}LPw(wo3!^#Z415nw=6R;VBlBnDW@2h%9; zF9u4&(mqb&)A>w>fUB1Dx)5blqc}ag3-lHhaH>Lpg}duegm}sa8_9U@#@WMzA?>W4 zbd->&A3+fUap}O9eENn>fBx^E*I#+*g)0$Co~z;G!pP{@IVr0Ji7z9zjxvgn2==G42+Ru2Y6%%u}U;h)Iw z6yX3;Yes-o`w*)V05g#dO^L!FSyUs5H&Y>041l&Kyti5(CXB!2qJ-ABIonJf8KE35J}DplD(!!&sH%@5;Pp9$tB+1#== zq%l|lQHCmtmj=B&@r>tz5S+KV6A)4%&-iA=Gma>$8pG*%9bmLt5r7GZG)}y(0C5b} z0;RiVe^@3n|0Fh;lXgf;6K;+BZ;vLQ9g68=`ur7b0bA>dq~9|OXD4f)a_c*Au8Y+e&<&~@@JEc<#YxFUvq%_>UM)rFTvj)=! zBFjEuVx(Dg*sVa6YJ|{|q#SpY^a9Ju=${qhsQ+G<=@SY%2|GXRo6ZNKIKK-R}07(_|x`^|%o*tywL%kj$2? z@?m@?)C>%ZkSY@~X_<&BN~hwmO;XqB=7kufK^AkUy+8ajeJK+CN%SVr8pciAt~AG0 zYR&_~`|Znb%Tlob_?R+N{xmXs9*ON)!-{M;;*nPFi}CZkHP(Wp4J zN~2;~4glB)FGQq)Fhzt=QC4Iz7~(@NmnYOWFeDy1*22C0eqG+FuHMo_n+NpfR*do7 zRgoB*u{AeH8D<}K*v`8D0YyjxqqW(z^J)WNCMBXz#p~izy&nKn=+&w}1Q5DD)%T*a z69CmJJxl*KyD4HASvTLhM_*MH$JIU!$sK5Lesp{@&?&+VLglFEo+whvHk__@=eYmF z7tW}1vS<=c9aza0*Nh;BNf>4$l)90`w^7doRXh`wm?)9NjI%nL7|stmg9B>}KCIR9 zz-c8=sT9m`6k&Yk0GDMJNKsuad>^=<2s)s?PtJD;hHLBAy z6V@`Qtyvp|v7siU$n%n`GI~QqBjfu{#?9-->ppYvt2ca(KJbSp*_U2>XZ1o2MZ*vU zN!{MnQxg73?NcohteU431rbq(s4P0E5EQ5oP|=`29f`pLpd>qdl%4Hus7gJ^WG@*6 zjcyeHtPk{c?l)DX*64@0?J^H#hO z7-@Yz)p;*=y)*j!nim1!Q?vh&cHu<_vv0a(!(4pq!2x@nzp*n$GjP22_#}wINVS-D z<$3kmrL`%Q-x5U$c=r6Qc{!3QvW7^ao+`2qQKBZYjDgY+CCVg{L@yOWG7WPaM%~r0<@)7ZLGnR>wNiPX^qB!fz&rkGWS*ELWoQAOsd5RhrmYoI=7 z_3RppM8HKW_F;`KX8oyi3lFL@^O7#c^3?sUO_>;uIKV-xAJ6&v_fMJDF5N!8mxv&C z_5a}eszgpfO6ephp=$qF_e8SyLyHvv`n;My0*2dWU)LAS0-&h zy_4(a@)J;i5L3G9RBZkLxb&5)L`JP%P{W{C-b?YGfd^hXsQ?bPXXXZv#&E2%Xs;N4^xbB zT=fY>i2B!SiO_OHhWpPNASH zbvB#?pa223Z0#U0_@C0aAu_PU;-lm`H003)j4_-rk^IT0^}1+CGuW*q+wL z<3QwuJrA(XP_JLIe53n~Ctb5C!e4vtPkXj(STm=YHyebIal6Ag)ONBn(A;v0X*zMr z*xucnF)}iiDN53UjW;j`QnP$n&!;ZCXms}l+t|{Q!X>lm$=6@-sgb_^;h2ogu+|1? z*$0`7f;3>*nD0=LB{y=|PLP0Gg%rt*5U>b?AE^yq#+sjpwT9U8XF|G_@je&wITDtc zQv?!Iy!&~Qjvs|O8dd-6Ak_sPe^8Eers5xf2=Igd;A!XWFwMUbmvGdGlLhimF zet%Nz>x~P!6GSf!4!69u(EX;7k^s;Zth^Z`^l024I98~5r%f2Y5B81K`J1}u zbO`$PzII$18;CB#h3J^%V4x@1-Nqn96AB3u@YE4oM-n{yH~^;16WElYfLfbFr#cJ) z5IRPJ?lEE?sEXw6W5&gn)lLFVjv4lBK@nA`qO?=L+euxc`A7`SNp7mjFB~ZT*?j@5 z)s{KlZJbPn{U{87=ZSrU4#}E(M5MmngyRM%Wg@M zL$X3dg(%^F2r~>;acmem6~~27(K+e1C0QQGt=t@|8mqLuOkp*J1b!f8e6XIlM{DQn z^FTTE0bICz8N;$M<_wj@!_q)&876WZ$w^q?;QXcajKLCpW}G#*L7ShrG3zt}CmQw( zoy`${8n$G0rCo4s%v!VTT;0IUs2v~*vGv@3n^y68(`VhkH(>KFM`$1^hhoxcv%1{n zy8z&?iB6!BU;gT#D#IGz126o=_(KQ0``YIBuCl5QX{a;0ndJ#vp}M@L8M9oR4J&%w zCxX4F3IO6AHx$tTag84&qdnUf3h7XaWTt4@5p2%dj5$>?0HmKTy3hcI46N3c;Pf0o z2o9s5r;@nG5l+hvOXY>wU{eOA)JYZT6bX1ch;zh2#(GW2IZ}WEu41Ifb5`_aeD3oN z`FGs81an+osNcR%7#L5vy|n1C4^ml*XIx^~CQW{Ej^~5t^AS;EHr}4spKUi9^-1Sy zBmp(X*vxUPRjuN(G-`E@R;$U;sMXmV$7M1MleUnebl5-4mQ_XDvgUPgQ z`$^{;f6tv+PzL}O)~E_dKKtJJKHD^s5&){4>S~5z-T{bb?9IC2raEKonz@{IA?JI? zOh_T~+Rco?lE(KO=h12VC;SkF-Zs6uyrvTvHoDQJpuZ&sUNnK;P$nPvcVki$5I9Zu=>v-v}f7@or1r4@6SVn!z0lXnNp-M z*`?;$YgTdC_95?Nsbw>R&uZ+bJY{k$gQ)1M*MD?c@6Si9~-sI!wQUT+#8 zWFel3Zk(2rJ#$?FfF6t)Rz%4`#QWm~DDN*Keor@;tU-(+Lv&BtR2jJ$v)88L_Z;-R z_tzzGTF6qsl41SDdH{&!20#cx1LJz#(gHic6rTX2(d4n|*80G)#@U+iMwKq-aVan! zSm?PeykIP4X1z{-1OVdHU;Wy3%?027ZVe#BL{d^^d5i>tPRThL6vxIC!Qlxa(HNhW zmjICm@%B@(ymkPxe#6Ck)rA#nDM1Eb+mTM7G{yl&73HKd(n(Ormog1@42;$zOjfZD z7&f69BTo$ON3OAmibh%WAm~k_pwjV(Rnf3GhVcH5d=NQo@FFBj43z~nk|iA#`E(=; z*{MbyW`klZXJ2>y0n4sSqAkk88kY0DW9xy^MTK-dCk~Cl40fRiRNDQz>^$QA;JG-> zt?l63N3$~%YkU7JNRl*^TeT&I4jdWMkFt47V&+K{IiU0$69A!fP^$yw4?WVu>h%e= zCW@#y{?t>A?zi8J*z=H4u#ZX4i!+tNDnD_KHBiS;VhVADB+#4N5W|U_B|~J>bCj?Q zPy&8dv~?9yzdJ^FXlr-YY0GQoG+)_F^GVCLnyTppqwqZYesJ&LY<~cu^r4`w<&L?= znZ|+wK%Zapa-wiwggi)&{+4OL$LfaUTl`c>IkfMl79laom3A5ilHeQA=z zk*0Xu>9ae(Qclx{P3bnoX=FqQP$8tEK~D}y{Gv*X&k0oOP{vhPHCivfq6k>_^nw8? zI5587ceHc+Vo6J7zqdWudnzXBi6Z3Dl~%QT-+y-1v`2a-M%woes5tIis{dz=SKoZU z{-!&=AG2<7@rwOet&7P~QS3tv)IAzG{fmC~9}T)C74zEV6d|$i?(f>fp}|rLaEhx~ zpU@E;5dss#`>A_0rH)Lq!KCN5o6rAr_}R`Ic1+u`vR-91ML(|+47PUOe8cjYrU0c# zlx3k(81LwxZ>)J2jy|{M3&6m$seOzypn!$+Yrf8#sLVKKD$FK%<2IDKOIV)%k{+Uu|UI&SWMZ zy&(bxST;D_`6f~sMaVG1IBCzcI=g5Gd-;vWydOTFPeXx4XK3ZzbW`T+FPH)-ir3fX z-t&>i@#@Q~X)yRNv-!cGck8N__n9_s@aR_5m{?Ot+ z!9Il#Y??U?u&R3Dpl(2J{uJwen{nN5{xqL(TaGYV&If z5TzRyqP?LAsJ5!A-MDt$gm%Rm-u?bTo%e(1qo)cH_%TMSODQzPX!UE-F1^v~JNn@} z^2A6sm3T9fLV18t(qSjx(IW%86*XQeilSf}4hD`MReKNYts?%wq9pX$0@PX`XH9o< znOPn#zre39ERfXs1z44pWx#rU9%h)Zu!~!iVKr3t^T+%xeHv-h5y@>hTPi_95e@X7 z^a6r2y$>lmB@egdA+KveQ|HdR@7wX@Gym81*T??Lmzj~CQcuHkjr)K9=eUwBCOvx& z=Qd3b?+V6XNV?1m7$#Z*O%-uoreFZb=Re2xI4O+9&z2q7<3>vysR6_HI(DdpwC zlieT$lHwZ}{r!I@oura7I4;1L)0|wo#mA**gqZYnfwNd7)@r4!DGf1Z6UXXwDy-EQ zK&3JRX44R&VXY{sCLLk(gz#cZsBer9c8}B`NluyDe?IVo-E}~e8HpuyTpnoEff!0? z+ZPw+jSLKp#3a}>wVbS2ws9BQebLV5-`;mO$;!;A{YRJo%7lzQ{oLPTA`%oBH)7*M zf2Sda4|Q>9C~1aYDoGB6rm5`lf>ATzxV12gPa*=MXtB{!F(U)fjW~@l74vu|Mj!mm zdRr)vsjFGhnsLQd4qf%~3SCXjCdQgErZ^^}8XO>W3$m2`Y%Xf?c~m&KCgEj{6$ZurXX#Vagvc(BC;cQ!-MbbRj-a{jn}F700Wx zb4S$$1x{6den^#Ju647B<|3?(d@`tM)i>(rk46I-STfW z4wpM7FX65C_N;#Ey*+42S@GcAciq~z`=Xr%8nx=&@Ek6Hr~mqLmqJKPt_V)gX#qfq zoqK?72+^ugcUJ{bl=0JA*SrXIcG%Eqk{hf|KrNgA*qXmma@i*_k)oOa0041~_cH0Z z(*ht(_PU!?Y3WIsA~Qi`xs&pNrQz=|)Y`gYsI@h6UAufm8<&}hV*~(zQLi(6CE!B8 zOEgx(?WE6Ik&QSQi3?kC>R`pgk90A5eO&6Or?LB}FfbNbw5~Uv7@xM^u_e1b?Sjvy zR9-83T+RGIXEc>Jyh!EVW&(T`PIho9M{qp5Qx@FZ~P1zEb%giuhy*>vp&Q7K& z$=QG+M36%J99vpmGI-PVpXs~oqVx4R*%`}H6PC$=Qfe=* z*p+8#d;S2L^dea8R`v7mZ#izQn2;b1rEHCv?G>M+7+<1R35z2yF1@6{9*#l?mtU+94Ay~e21#dqZWBhtXBh-yZ|hSxRUn2gX$ zCjmkT^|v*4Axf5_sCTua1BJYcYof`pl72*#rvMW&XkJ#+9A$r^=LIdsP)1Gl6@rRZEla)Z_ICa=$GwTzESs{;8+(81 z`104jGxXN`^)dAx6hVk7t!Y8PYJg#KXRkR$2n~?_69oXkP)cdZFYe!`EiFx$rAiSJ z#~**J$^F55k!L`S);cb?Jo!zBs4Vxj4$owjLXmueqPfj zU9(~tDlIP1q^Ft98jU&=rov#e?P4O!WOQWQKK#nx-w_{p_=#G-KN#1kXf`OT9>VE) zz1W;lJuM7oY5`$bF8k#EjBBn*!&+S>&`5{nkT5aPGWyW(&3sp9B+N9WVg|(Prjf4;GCxfjq&zT}5vlZ*cr+ops z@@4I;-juY4Oer0AzVu+$%-omqhyQd;cDsVkzyDq9+rKwq;Sr(q`Jk=kbF;6<3xI;^ zbUkIp0U*XdQ2xlDj&kO-IJ?x}Hn1-^tICg3F1N`XVI)rG5`klv=vA%UcNy5!@`%Tv!_}RU8 ztxq+)M=1&%K6bqE`#YRb{Iy&T1`Ol2h1@EH2~dA@k#?$sdE%b@{qySPEe$IQ=uuMlQ?_H z05+$umU~Z(#_=~;au2YXg%ORJFMrAw36EQHa)x|yZfVNIs;+2@8ao??8G{Ql$p z_k1r$aXMnmWlAX@@OyKSXCQ_tI_{zB@@l?SXR0^Z1nqmpn)?rq+%P+q?(H@`pUYqt;S z%9pG|ko-wc5%N$`8j$_GTXy-W=`MkxZ)&8p?Wb3OFC&tT&ccPa}46Xr6 z=8Qrx3|7WF5AkPfue-gz6OaDof4vVs{_HBBKag^3^0NZVikc-AJ&ei33;lgrvfX|r zIfzoql++xU&JL6vBL}F%mJNs|thA&Qxsf65IFnPp0W*x2D6(JaZ5jbeqr|t2(d5)D zs~W(pitv3snbPQJ(Tw}aIH$ZBSS~QTGBwhEY{|MVV3?%{;V#q4^+{EkQiKA|*B=Xk z5Q=J-y0sU4lyUWyjIK2M3}=9%40e6}Yg*#<#F#)tK{p3SPdq;JTHNN+=hwUr0GH3a zmjA79e`H+0A+FBc+thuS?;D$o^)%#q`{{|9xz3I~1P_Qr1*-`+2 zII1Wo<({VE2r1?qU^1*Dh2OoWS6x)J8R$YqO%PH=} zVKtNQZ+|b;-k3>!js}D2vL;n|d4mP_{&1X2x2~IcC=x;J8u`=1KL5c3Yo_i;9k^^w zkn}cX#pST*)0^4+B@vTaU%7IKQEL|rm4&*R>qEyIq8pvb$ZIq0xMnE=#AIHzbpT_C z=5RI-MFhTp@_{jBv}*+rC84H3 z0Z7g*w>DUIp1)x(+de#JD$5cw!w4?aG`*LQs`i{9DLe&1;GY4F+zdltsX0+A; zFlywr$ITs=tUQEOnvI+_t4^C=I`5ohr+u&Y!!rqXh~a#eD{pZDW+DfyrabSEX;oQ_ z({|+HKOFJyt)JQWR0&Wi8;!I_o1Mf60B!z?uQGrinR!jWe#6niZ+$z?_LqG8LDzdn z7fetg0^jU@`>9eS2}wJN<^p5Q7%Tnd{S&Og5NCEoT8E2DD?c#q3Uu2lQAju^A;W)V zP(tjA&)p>qoC2Z0t$LbPZ6Rp2+Td+pxwh%q7hbQLa5!QF#S;(x=bkHeU-0oIQ7r;d!L({_-KlVBU1*bwIN|wPgeVqGpfm_n+v#^`@B2S3<~*GrJ7O*t!@&9G_U$ z=f03I{V~)(I^Q!mGp1Uz+H(0#L;fT0cT2;ai|!<;S-SL8@sEFI1%Q~%Pbswvqpp+w z*4`z=&o3@i`2PXIYCVC01&Pk$T;f4^IHeb$(0^J|x{Xj^FW zqe!!vymd&W4k1y{QZZOU5mF!pG92K2Q-rmZRUKHZTF_$;0EYA{uB@MU_UY}2kP5_V z68b`b5(BZd?;tjWTtn@HnzEWcE;EloDy<4Bk|^1SWxk`aiiZ3y_^)IFq6&?PDH17Vm#Q$I+@HkfIy{PTG<+EJ+UHkic%z?6v$nvLh{1<4^(D5k>p&=u?uRm z+NG6KUE%>SQbeGv$_BDHClM+2yNg068j3>m%>yCDwEa_ixvYYO>pj>pSgtFMb>Ucc zUDj)V^0Aqm0~a7vt(xvGe5M$LLswuFoB5ab^>1_{MDrrCQJbyF_0}7#dvE_W;8t9rnR$%_@EE4y0SX-LC|_&Hm&215}Y&U{ydSYqB zo1Z93r?u@*>6W;S`@+NBkbM67XaL ziBN#W(SW7;IajS%*8R6<9uAp|5p!wI`u&`&jI}2kUT>`3c~v#f^HHD5BTxKw(?<;l z2j6`0v3`@$m{QWoIf8QM-9KwRaJXsCkemP1fT*wa_o&hg^R7WcsDw4TNT*~P>8($- zDLql3nyJ^b;QX-ply%p-jSGdDf#YVBSpowZqYe2TNih*80+(HULce)8i&<_?Ujgxl z3W!4bF@vMYN5*yQboM77n=uun*W}>M{1~c3GX(%pdg<&kGj(B6n)mRb`q4-Kw3-G2 zu{Mk%8T|*}?lEq?Jmn7)R*_h}c_SFCqZrYMbB0Pn1GUc*01P9Q{PO+~02EP##{F#r z-NBCGE&mEJcuJ8MDYBi2f`9hsTdwWkig)wbb+Pv^|^3AA_h7M0gtK!0}cHygfs!{=+@Y+Ix! z5BiJWK78=G|NHyA1RJv5u5*3ZoU1L(sW+@D--0p3g>OZNx5Klqm55`LQ)Z6Z&;tOe z@hDJmBS2coIc6XMzSi;XvHj_vShW&!Ou~E$nU|dY){_xBE=rLEEN2C^u7mm$G#b{L zPau{RkSyq_B$<#bS^-gQBy5?M*{v>LF|01C%Em00R28e@3)*m+Au0gaw6r*lkzvq9 z2*nBjc1ksfMb~XNubNNNNuzzMi7d5amP@&nmdpojfM`@A66aXi>>K6Y{*C=utvOGy zyXrg#+SejkPTU0ZV?ub7Q!&)9_&pxkGf8CEm{xH)EX3!^L5gGwYoQB`@o~;KGt)Y` zam~u1ZJXEos+X3bPk!O6+oo?h>2PGUclGY8swhi}rN(G9EN?mR#_$zixb4*8qsOBv zH>DK*{pbI+?Y9p-?)}q$-&cRZ_ATp93$u?4^ua$pwfBLCpG=sikBe69Q^tF{G{wuQqgmLt3o?`N z>ss2SN%uKnpdpg%kpu2tCE)Wa!61(GzE@;3rPPQhQc)uJ(U8v#A-|ULfkGPch9BBL zg+E{>G#X84&C2S&ZJXCQH*Kg@mMtkaS*>Y>Fhye?O$zz!RlASA@XA}!h|TT)_T!un zUw@h;NUco>mSGCseEu=vv8P|Czvm~v-ZoQyxjkOfwKv?gJ=2=*{PoZ7IeOWyPt*X! zn<{4s<=4M|^uYbU`}3SdI-u6BVTzX9mChqF$buWJZ;PC&ri_$JQz0#jAjdEQMA^X6_lFD{F3JK#t+L&{L$1*mVdOv(|Bhab1I#uZ=)33P6oD99vOw6RCQYMD>&2H;u z-hItGjJfbUBrA({LJ>s?iXKO+?4009D40))QZ_RwhEi&$LZE}V#x>OMtOiQ|;SMj1 zG0DuZIP$WyBP*$?^IyN^OO4;S>5FEg(QtNztW)2G_xA7nsPn~F-k!4zI=q3o z%8illPjT3yHTP_8S+il|E*)ms*!J{<34Z zs*N+4&&uDzrW-rccdm+g4$k-9t+PM*c+9>5eG0ue#!@$iF3q2{mxaV+f{!os(M&Ozi7wSzT%>M zzA!(BU-52g` ze(U``QE&gXH{Mls^5EMZG)LmfT*%0FpPso8RweRSL&jqRtJp6l}mTT55%SUyvxefO?g_uhTyEsJh_tq@{+;Grja zfAi3zTNEX}JMByq<>!uk{xhHKy?odCP+nS;uj06(xwIEil6rsp(BmV&edzHy=LXjN z21v`Xkea2Y{naX2m^ml-T<+VV(#HWmU&; z;j(guQ%BZ63Hmye;Zs^bD83KC?DuMobzI@H=yxv{w7K(l z5LyUWukNw#T3+O{bfvP!`|wqF<cdpuQN2%?WBUZ0TLu85JhPv_Wsa(jUDF!T~$-v{pl-qjqcdGo+&LV%+zW% zVR4!;t$1*FA_20PVODQGPeDj{4AAaV=SE5?W@x6aPoS&OLSG`3- z{NjN}_C4^2Ct`%^(T`p^SW=W3@ar4^09~3%L_t)4R@7p$tPDT)^b5VeeBhC_fnX>p zVXx6>Ldz=4hL=?=ap&b^3M|VapWlxg_BUpqIN3gD`0jM)ru|rJj66SSptUQ!(Q)Cj z`sr)vG+zYqyZ4i^zS>0G2FR3on8_|F27ogSW@eQHhc!-hVR0Xqo*oeGW9HbR9yG=j z*HEm{Mxs@OD7<1HJcPUxrHKSV`1Lrmpl4f(R<@(Pa{EvVCb`gVd(oPv!mPc|b zH%DK;`@-$bPygw+DQPlqyt}9F%U}C$bP?S5gKyP;?Zz*pBt$5s&Se{SlSzjo>L4^3 z4c?9;?>RUwan5{d<0IeErW42h<2Mgm_Z@0HYx_TLe9SnR(v^dl;iAuu1ibac*1kPa z!w+;iX$VPTD+&37h$;xw8bE485ym)1|CPpi8o=w=cKUPB>@VNYVA-{+YT6V;Q{)2! zlP|t7?0o&z$R?%;4KU?vvN6NP^hr?oKo1JI$B4+w7-NKVW-~UV)d0kD2NFeYAw9<; zS*lR-vfS=|)suU2O4kEdaz8pHbwJyAB%=r7Tmi zeh6c{pc+0KL{)P-@x%NHzK{rl6W7 z-A7`CaI`>)RUI)^*QR`65n1kZzxhN~qFNCB#*Iw{cYHIu`_6Ar#TlE3Bq6y11E9Ei zXv>8cx6ZD#lYM+#9FsL?Y}KyXULW(ik)GAgmmkRpojkIa@S)?Bq98iWg+~;5AlP>3 zC~=N#NwoPiEO^KJf}TQ|16_qYFP z80$FTfAXRKTyydH=k2)Rx=+WO81V9GkkmMqVkz4_5pIL`v+tj35V&^a=& zn7Pu);qK|pZ`{1KaiPvY#@aPI^cyxbMScmo*AiJw`ME_X?Ix$_yO`|KNx-NuYzD-< zhWAJI8dcbRmIjY?IlR;vWzw6}X`c+_jI zPMyzU=eCXR_v`jWe?M@f$$rt!tqF74=YZ}jU%ckPqks9|XgJ_IKm4Ws#s7II<+^~Y46=*ncc=>0)a!1<>e|fMdS(@?`pfVbCD>klOg~C765JjmkZ`qc4O%KYd zY)o2~LKV3WVKzHah>c0l;}nk_5r=Inw&b1!hN(_vax~v^0dlUpUg|j< zq4QT0_e2xUNX?Hzh2SAfq-~Vb_JdltAyS0Iur5TD=v=7JT}g8gkLDx>$2&+(bje{q_7v|Hg!ypO7wj;J9;k9>FxsCZp%chwk4q)LJjz zaqD&4G-~x+jwfc-ie=H0<<+-7SdrF?8T8cuzLIho^xb#e8nZD!+Iwil_=F>6 zEg<|=KJ~|6m#$h--8J*#hYbgpUwrjfMhPKfix@K%D0TY|*OjliV8POCki~p5*t(VU zG_6p&j$}$*M*`A7YnMFUcPOkvlc28wMY3`kPguK?hflE-p?Mp1%myVSdP=fZYf^dn ztTiL*pr%2OlG6Ps4JjjCk@dzFES-#2Achab82~DqiGA>B*gPhLEXW~0H8!!i-}x({ zA%ArLQUr>5*L4TY+pelHUwCcXf`b4QVFWa8vtv#b$C^t5!n|p(oF!|U<*M5R-P#N4 zs7g0)l@T-;hZ$u7^hokxUXJvjwkH*PekF6+_GG3B}Meo$XSDRreXMw-dEV)upHn`d78 z(kgq`7)&d5mdw?0DPK)!VPYUZ1}u)iINS%k62t`0B6pTyy=` zcZ6n9cP9g&R38cNc_uc7?PoH4YMav6ycVbu5vU*pmy$`_;S`6BnpS|Br0C;fGGrAg zB|i-DOdbYV%tJ6-nJyYwS=$@&nJR~}F50a%zx*A!t0cG?qq zz)8qguk;h$T4FRBqMDFG z2-DWyy|6T%h2Q_@k7G26V=ZkJ2M#svOLZJHm2H?+_vUkk^=nqP&%8b|I-XUr=7Q?i z-u$3$F~TkpL{aMf*1bPJQnB{J)nOrXF2SK`WX>fIzh&Jw^7V^n4Gd${}NT^O!o;kez3!T?876J(uvuB3kwFx zWp8l4_GsSBlRpgomP_k7Q+m{F1O#g9?%jpO_x-%wv~7FB!xkZw%_0Ob>L#nzduMMN zickQ_@`8mgGj5K7RaN86e8gqOYFcvZk&{43Wc-LacUq#OND_l-9AGN8%8w84740ME zB?{e7vjxu=*1zhq3sUxUuUcLeBlv#)n?JxGe)H2*hryR#wBzLSFTWA>!2jxZe>Obz z$6u#3h{a-B`Nhv%aroI6UyJ4deD0=it?g<4aCkAi`eB>;trs8H-gVD?hn{`$wdmZ! z0_w&)?%A>OPgUK|KmL1PW`-r@PBrLs2=u=m`AhGwfB)zfLP$cQ&6&U^6Edu*8PYCW z?p77#Q`T%&fl9@Mbz2hRW!X8Y@csR4prtjN?`*eHDY40}*xGlXqUxC3fVlvsbc&o) zeKPrh0X5}GC1ROYO<|@}m6u^;R-e)UMu^F|<*Rv~y$uv*F)N1;Y|!V|ga9DwA!Mx9v5E&DjxhnKLb@kj z-JeuXzJ=gzKYjp-d_+=7y(fleMgzF);uFSgSFGg6hW82Mw#^9>d#2;T3!tz0qmxFX zAvGrvrJDvro(o0iTfw@w!Vtd-KeM4m#FxXoO`GheqduB*}xt zHQRH<8Dt%wIB#R?i~sXb)me1rrSI?AzvsH!zH^=|OL30fUFV;7;{Ko9%@^e7tW73- zDN547@BZ|c;otuL@r|;g%sVH>Y?0GHc}0hL+crIGG{)zr&NX2~1##Hc>v-dJy?1Xz z5mMqUA;?=&#~9M*O`;)+%q!iELRiBEtzpc3=~p!Kwd1CQdfe|k<)Z;_0i{%CN>@){ zl{(I67V{oTC*}TDA4s86oV%nR8`ERfDpizDrKd4gGtyg{C?CiHMVxo+IH)upo0=JpjW~dZe*K@v15Hh%CvOD>H$?Uj(w73vu| zCoHoTby2`ZAIk!zO8ueZ*+j5b6N!(JL zPAZ`$giVEz010giETNYlvVlN=p8z3&6c+v@B&0X1T9%8fV)foNYSY`j_k8~|BWpBw ziZqht&BE^cJbB_7%{}+ra=!D`_X|vp#*N`qzo%9nJhLyEjJ_MLxx6DI&9bamF~*o{ zXgt%}(?2+G$78S0-*)kVy~`RWD>Fm$@MFJK0l@q<;Jm!7hVl*Tl78vY996e*!}`+d z7ykBU__+STk@VEml&Wpz8> zVhf{D4-uX>vj#(Z=IuFg~x8BKG}6au9+SYt>FKF7EhP6!LKd>Z-P zxkw7=#Ho>9A%C5SF^<`hsVw(7EB<1qbF^y+sq{6hC2P?_9RL`X`dhrg#g8pHHH;}E zCd>$;uLJB>h3(E1ZYIQ>Wk49$BClh~=L!gcx@>oye*d*8j7pO*4O+V{cN#NfE|XV= zcGrn|WtSp^#GnQsMm7Oq)6z%R{Pbr^N8He4h4XlF4LX)WNVT6B31vb_BOQ&%ZC{W| z#ez5 z?qp;22R?k){)O=o47OBtsa2|F*|Xn$_!m`=MAWqFG-`Km;}Pcy+LFmPfAapeBh|+g z8Fz30^{J|@o6629Z#?*(pK0%^zU@Iiw5H0S1Dmi_I$oOqlr3 z3jj&I4Gl<^=E`+3@vBO=p5$|iWx}gi>U9SKL!ByLU+YecLY;yTVBEtLZ_6MIo6vTr zve+Q^HRoe0S#U~D6|rRRkGE0Dv*VR8Ik=F@%&LaD;_~q3N(0^+I^Mem`P?y)6Rgos zsMhW5QWcfu&$9t9hSVGO^%8 zS+{mg;e|e5peuLdzOXc8Sxy{ntG2Cx=ia;Z&~=XC;j!=oxazV)C;#xPA1{m0)a&(i z6>d3H99cYLH0bR&Tywbn`l~PZ?cBOCU87O2OQy_MlBAK1d#}_?&)UO_h7dxJzxRB7 zLGHQb_oF!>8ffe6IsUKT{IRL(=%&FDL*Ukt$HM00_)XpXj~+GdmH(IW@a4J1JzB97?9(C*ck7`^P*Tr$ZMajrZ3@ z@}&bW4045AVkBFl{(x`1@I=*)@=f7g@ahlU-Clp{beNpvKfd?XirYSN!?HD(vGIu$ zoA-S{k#Ao6`hQlhEsftuG79wQv6IywzvqkFmgSd3&e(0A^ zWyvnLLVKZ<_JPMf2#y&oct%O%46v%*(UFxXUMb^rY>~$4HXAHliL~E$f zm6V;cwhaXPGj7-yzAozbcXr->o02a9=1hQ5j2DuDBAtMe({Z}{v-ht>frb2T^_Byb z>e8(%ri=za;AlP;2tE*LatS;hrbg9-rwI;G0CmKYyN+qe75SV-};ZRa7i&4XWEayDG@>v(r~-np0U z+hXh^rp%MXl9PoA2}c+JA=z^2fxo64*nO3|y}fJbTMxyp-Jpb>V$$-?V7(=u5TeLP zq@r(9o*rvQZo7a$QX$Oxutx78mfTXp3o%Yx0GI}kqeY(RFUFF8&TP_}8<^~p^hEV9 z;dA-DN9jQG1(Dy)mjcg!Ru`>50HHu$zr*RzjT9s_u3kNudfATfW=bV#p!2p{3ZkyX zk99el&mfG$DhZdBHCXcSPx5_3gSGwt_OCmUEXAn(hO0htjMbW!ZC}sB(Ny1u5ER8@ z0%F$gwnwUf(olPIbZhWyue#*a6F>jqmS`_E0NC<29TXzjZ9jU`HOGJcqi?LFA~0~{ zt@m|R9jy;PjH@rdxZ$Z^{-5$V-f_n-pLpi<*T4Id1LxAj5}g$UkNxJMFi^}cwX z{^JeJRaf17$NuPYg~J!`YkKOJKhp6Ww`2$oV=OzI&W?)e<5Q16^;}wQeM4DPdishW z6c>*de(Sqr?1k4(dYap>n*SbTxkn!D^dPVEoccBZ0Fbo&YGTgXhVcS*hI&omyVYduQTv1*T(&LqJ2>#F zK{k8s80J+;tp&;2UyE#3g|&OAilOfB(CLyF6BDt)>s$p~frBC3m!*I)Nl{r#wAU4bvmaUIZmOIxxe8 zNE@l&S4#()6gMn0BhA`e^9o6%Ea-#wR9|ZP1YvF28tR zZ7c}C^Hh?$J8!#1;e!Ey39QZD^k+=S&C5N3dfTB2bxHY(ChO`xQ%OCpgYg6aDJrig zoN6}!_@utZnNTWa1kTjGeljD?vY128fAFf0j<$C8gjGGAM&s%__4X9Uamx;!1=afX zyDuy9`4+0FzVR=gtGwrvAIpuQ!}#(6P_4` z$V)N!ECx!rDWWs>Vx8H8)jAVq*iwwJmU^63bhu3sfhhdpj~m>5BZIT=)?6Px-tDNM zBkc*_5x%gf$8_kT5lz_!!kSE4%yJyak|a57Zf{q&Bh9i@=u3Fz&%g7OY`a*dXB}oPk4JaK&3BBy^Wq=e z7%#8WjZUj6t9j!;>vkNvVb8qU{f!?ydhi=Rcr@z z32EubxNkqAvNSaN)|-9SKmPGTOqDzOz|d$;@wQ8{Z@A|2Qy;qi3TJv+Dsehpw6mul zSJ$1;2gJp5)0eaO0g#MT+Ad;9v0}A}LA;R1&ehz%hFu%4+A0`dEfrwE93w9 z$~N%(veA4(O!rB1^bX6WrH>YV?*~(?R=aGX6)K9|{oi~D0=+#72Ptr>KDKakW_a!_ zH(mUtPgy(fxnn(&q_FfMr8@@*Cgq`i$ae@0d`u(HAe9D*ilCz;*O*GR%y{Rh>cf z3*d3gV9~Eo{eS<9A000)%u9N4tXor*-`O+J(B9P(wz1B%cjbtZboAhbyYiOH`B74s zpZdhpe{p)ezPQCld0+c_rD4zBL(w*7vLFm&HeGV5);}@U7#JKnCvNy?0ALYobRF27 z)k@OxMoD@BW-ZxSL}$(=ys8irCNoj?T+CPj? zyA|dZUtH2v{KFsX7=d3AB^hRz6w{@LRlb3NR&jJB9HtJnQe*jAqSDTtk5Ucdxl=-# z#qI0rT*zl(Ri}wcv(oIzbAmiI+=+aym_#d*l5NEd6MELFf;EV++=@Ccfh2X?-+spH z?`+s2PmksMyBl+LdvrOF#89?RmCU~S1V{b8#$+bzYde07&RP?uFJf8DKwd`-&)n9{>z7+s zPagm2cZ%1Q6e-wa9{I%+`yT%JZ)<|DO0ok50)d`OuK&dN8i8u{oE#1>ALoF83b5<92rvUNOoxjDcRo4uG=NEc^ft`=8Qu`&|DagaVJ?&+ho`w z1_;aQ^8E(yXx~Dy3zgfEb5ap?AqLY_;kUk{$7Fe$Vl*&-Huu3VtPu)}dK7cA4(yGr zo|}2E_Mq|L#R~QwIz3bdD=4QvS5Tk>Ff$f6H-NflLvsX7+SwHfC+hc{o_+Z@8Pqwe z&~9d4eo^6+_74rl91B4Dh{H1k}`>tSs7kac)yfdtAp4lVX{$TzyT#W){nzGg9S$?cX^dWYN-7001_- zxB)S2*p3Xb>a5u{$`xl{QwEG9-boOsW2QCy89?ps7@R@85GLLTZ7gD-VKwHk z9MGxZkwo-#bfW*DJUdn)q@)h5WNg{vIzIc#@5|+Yn=%VY>WQz-rxC_&bD%=hS;)} zC*st2rTxvPN4zbyE6!d2`u88rLTGWKgm_?CHvhztzdACaguw6q_~*UXeB|zaNs{8; z{D3I-ee{k8ny|eSG%0 zrz=N-5rnmt>#jc<`5m1eR^&g}8=CX@ymf7n-^sS|z-rE8DS#|@z|7bMiT)lG3@|x# zh~cb=IpMoG_`~mM072-QJ?-r=?}dy}dq^??LZ@RUFG=AYm#Day zgE7P8nXdjP!sV`2l4b<_MsLHBL-xP@cH8XpkLAw3_{%lUBY!?f9W$}EpU!fe6mM&! zgk7@Fdt-BJ>B*+n82a*Lpc43!)0HoKqgio|ojAR|Wb4H#?^V`SAcW?#B}0Fojcr9j8r&9ya-cb?sk5h(mk!rJY%T;AF+ z`A49qXN~olXHHVTzb%>(7!Y^^qs`sq?59&PlegPu=KqZ`&Osa(qk;!D0sY=9b{Ma? z^(fYwmZipEQ)Ug5U9z39+!iJ^Hz~%Pn{N5^_CP>P$QryDIF8M)eC;{Q-M4+XF7kV~ z*Qfo^?VmkVyzP>}y6L8dFZKT9EKa>%pRuqT^M&71}?&>5-TLcnj0K2E*9Y_%JRhQMev z8;Q}R+&BWhk@(*cP@mg*9z6&*54l0Dg?B8Cxp!4?I zVo7NswCT^qoiU~-q9bG8o<1*^x=={RrRw{|v6;9#BBM87V7l@XqU@Ne@wC)w#j(E4 z(HT-9o7IxpDU_6t^Eqqsyrcb+Y?}`KhUin$Wov(;YY)Ti@oF!ttPbOaD#WK38IalDLG8iEPS6FCsh{=&Ln^HU`P^YaK>cnPvwV$vS)c$mX{dcojNz|KIl*KW?7|2$D6^=R z4zz@|M{wEl?XUbcpXkj4Lg9u%VlY#XWF*_HY+$&j0Nl2q@LeCRP7qUir{Ut8Il?fZ zj9DQ&Lv6U^(t5|c?=0F)3J?mJP$2=}_DLtSst^}oc+t8V8`Oq93u6cwDK>FzCgFe> zBaStu?lbKBK&Z}`N`V0g%<-sWPS6sL&&L>rlPT2M1xBZB+K&+uFvFGs!;i&dMES@~ z*BonTYR&HK?hA!c+PZp*uKULp+O$;QereeL^sky(Rr zZhswdt-&fs2mmA{#Rh&(ppyfw zgi4AOcFJ&8HmhV!Xz>Sc3$H2Xi#FB<`pzhS=bSC@HrFhfuK<8Rv+YoYs$}y8k=2ZR zX3C`L(A^uH$Lj3hb|;LS0fmq`J$J*VJA$fZQ{)faO$~~$1ean+I(Y_TtYbJ~BdgW# z3I50M{3eW*i9089D~ z7J+K_jx7hDcfnlmU&9Ic*>xIVvki8xpB2!(k-!M`**yNenbSf`+$QhmLoMO1~AmkK=^Vo^O5_hMtJ6saR z*z(>>PfG@!HoPMqJ8^pb-pfDa35a6vIZOfWe(-B?i+F$+2ASOT#h78|ssV!!Po-0^ z$Ogv8m(BUV9EjFQ#rPol6%Sjo&zfZCbW#aCs}0FzOQU|jLSw^(#N<}UUhlaG>4b2$ z^i?_PTNUTSQFPkk^mrNgeM%usR!XI~G$@5Wd9XFM;*nSP;7FbTPB^B{E(qm~5J9!o zc;LEn(+&5wYj$3Ggh|V31;U0Mf`Eyi7*o51^}DJKm)%m6a?`!CcGCq12+PJ~7BU8H zA5jY-(hvRCE!z;!C31KkMfT>9nz$eU098i+Dlj|}&K|%lDS~KedXC_9hB6>z&MCuk z8{;*4IMd!0QgH==FRXa&*|tcZYESP#{`y^)rw$B{#)Qn0LGQruv8I;z9cqa=yDvI5 zmKZD(k)H#@LwN|{^P*D%f`ETySeZQrvSP7IZbykYH5NBH>3jqGy{R$VDZ|HXul|94 z+iIWNJ1}wF+c|PTa@dq62*)YSAz99ny>3PP8}q93qO}2llMc231{xESCmDq!^U(mn zXjEIeDJG*exS&;q8!j>)x@m*urh9ef58m%G-~3s(<%9PLrprE7s@=4AKWj|cw^#+P zVek5J9Oh=k2JO~uZSm)dUJjpk{vTct--}jp9d)(ge`ADrJ~O2yx!aLEg!F7Xnll^J zSZj{)(7sz)8`cc+va@!+@YF9A-1rWs%dq3n^?M$B{Ew9pp}b@OpL+1Ci5CwOyHjAT zS^2_A5I$1XgJk`aY&qE6+^4FX^ozL^V9Yyeh^A zF#X0`Dk{pWW~azLamsPBD|F4$#DFrnh*iaAcT!oI0K+h=t}y`uNgnU*L;-IM7elBW zxN44_8W57z`D>>cmR&OCH)uv;Ts}#GVt}K7mno7=tPtDo|2!$39F{Q`6C~v=4QySS3Xa9ve_f^0CT%Ex4ii>;b z2R}Wy=kQHa6V|Dunt2Qijnp?ZwkAwTMv@p~&tgs}jc)AdX=&C>y>d)&ob0NgGCijl zGX+GQt^L(guhvQKhK87jPYkIF_jQQS0WH7qXw?8wS)aTr#wqJQS-)lS<=R<$eaDfY zU^|!7$=<-!%$p}CF+yonrUSBVO7VVTUZuDfq10blRL&{jU)4N-;QK%)N9vGsI(~_| zO4~DEEXNvCs(GXNoJdjP&H-c5w6{#7%(uXz^Z4-??G^y4{nqBN*-WDT`c~ved3rjN z1_I%{YtE$GjyTr7nv*AlaID@OO5?<8eGH|N-RVl1wb>O;lJ%t}dpl3Q-Cr8v7(^jT z>!G}j7hJXD{x5&?xX%|@v@7qYAN==4r7$>WVzh%0dyGoc9r>N7xpp%`!1x9x4o$vv z)L}o-St0qw>BYv529>Gb)izi?_1D^dZ~O3m1R!`@>y!kJm^cxqQu4Y}sb@LoS#`*= zA~ysxOmQ@`5T>#a7#ryG4^Lh+^-3K#Q_)lzu({$od=mjEK=A%dzgAB`l0q?Ue{WN4 zhhR>n)CedYh)%?jr$?6TQ<(I;f{WiaUKNk8avoTvo6`;%=CnF*&1%$(#K5q3T`gvm z90k6?zGJe(7VEbI`dH*gg+0XRGW~?0+ateItzFkFWM<8)nH=WXV}bFRRaH9~jZPhy z9#bKNEz~wKivbk2w^pO+xNy&QWpx?HS?;{`L#$4%uCJ&$rXUh+Y3t1Sk6-*QrGI$z z#MaI0M-6&idgQeZkBrxU{rf*zoDo*?rz2g;QOt5mJ6x7q6x{8yq$V0;Eo(|G461Ch z-CgME8sWYD^Fa&%(@wj1^97#?Dx>GQzet0=H}(=tq8!vMrY!NW}(AJ!aTtHED^tb_6n=9;t!pkwq6Z zHl*}nm0qt~yG6rriD|`G3NFpuqT5)wLvb$<@(n!n?*etZ71|E~dfh$R@~?{dMUBU5 zb2eKs#^IMry{WvV;NQL)PWPm;+%^8p>oXWLglKf@S4s;O4Oe^PQ5x`FAoVx*pnzAQ zx(EXA9zIj!!x+W2*&R-I+wJ##1y$74$8E!{(`j6nU%bC#%ce57%i|?~c=q{q&M2Q^b_!LV{M07`6zBzB9DHS(%B25H(+WS0fSBaocJE1hq6Y3k16{01FhQ&iAR0 z2K9LxX8-n^EqH|lpYI0%VJ~9&NzCz18t`TiS&G?txcqe|bh|I#9`CvUAwMQ$7;}sR zVMIX&9wa#f1H?pV0D;r?_Mfa^n^e3bfQbX^Oq~b-BUw~~*Hf6NZzOQS09&*rbEW1} z(DVw~{E5{52w+Q2mA3qVinpY#gqL!0u&gWf1AAr(!HP}Feypxy?5B?$ikUN&H~Gd| z6=i`jcCI#i)2}fhcZUx|2-g4c*)dE=DC14CJ5F`qdv~ao9~jP~-Fb1nkhkuFbG1bS zc3YFDw}r^vr#8mRpZ?V&-+t%j>#n?Xu`z~6$4`9zU%#!bsE#iP&J`j1pHmHCDNZ+H zmU9}fzGHyl`Iu7XWI;uLv+b293Xv3B-Sv+E^~Swbs8VAfIj;;1}1e9o-7 zIJ4vkW3VinfCFT?LmE7z!lExJauGJO=!9Ya2Ue|GG!iL?x7V-+jbbyRlGN9E$0u`; zq$Kl|Dct8BZT>~{7%?FcR+BnGm<0iVgJ#T{(o^$hz|323bbDHwLl=*Ma^gr&VPLGk zk1s59VoX*@G3a&KAGz_GwEOP5wIeMx zrR_{dx7O{Br5#%-LX97&96$C?so?8uFmY+QHAJJ&Urz6YpoSn#k5${>db%9T@_Eag zd%qz~4j0HS#|b{WScR9FAxQnc)AqNX;izqLReDr{%a%h-nLU`0q{65G5TLHvN_p^1 zIi^y~F@TUnEIBnKy>OJ;CUV23NWd?s*KJ?*XR0dBs#6zaTo^S6I`o6@j!NU>Sy6MZ z^o%y&A8(u(0U)_2M>u`@wHPmymHPYovFY+Fj0j^o^2mSaz^`P*0iWCA>uMrg-rB}^ zTXwYDpOd zKwg*I&HDq1%X`lS7y%O)Xe$sWhB~;koNmmk(idZm1wgXwu^)HUyzKI|)$a(J2j^p6 z|D8bXQ#rowMv>^v#~8iIuow+lP%uKk+vIAf81&S?u>*>Cd?Ug6o;B{r5{VX9t+Y6#Ug;1qEdHmhyYl{laD`Vo*T+qVE{8PlgMpgt9%=+Ks!D=_(a}jsO$V%y=hOX$4km z*ljvgUd3As3T_SR^S5{2eR~O#mC0h1!dE<_%?W$cCs0$@2-+O=a3t9$V=_&0=^4|$ z&W=zDV%Fy#c-h<8skgoUYK$F4^eO$?3$7Yv^(lKI$s^-m90Jd&Bgg!G%?n8k=B#=) zEpIm!eYLbt*%!c|*V#IbziC?u#di>W{X@rhU3$}hjR5#x{9PcZiD8|&5eq6e1biy+ zI`gnBEy`d7DqTC9SEh?cuO>UED`2eWP{jCPy7rD!j7pnye@Q?nKwb7zp4K`xFxVM( zo|xdqTf{&)!|StviQ-$@r`D=&P(eJ$}`ED2F;{3Y~90U5It2A>HumfWpHyjvR z^gefUps&^A?P%D7B>&#%`A}QGHhSGo{PC%#!0@1A4FMn#N%)S%7C%ZX01%So!2e}2 z|CM<0S#9~&;{@aVNMJQYt=nn5w$a~FGAHwyD25D_&TOMBkFFmle|mkky;^KBq3* zeN$k@`kHU3_d1N{8cWJ!y*1#Rin-~9`ME=tuRi0}sMSd~(5JA=6~^z z52kcy55^>J0U6@BdR@t;Lc;T*O-Xjy>l{@t<$&83LkYf8V4Z$?&11iEG8|hdIh_q% z_uf+;*)RporIfdLM!4+=om{d|ZBhH2H>6{X@r}{nXWn_Ijc{DZ+s_&_{l32OP{aTu z#4sw&M%B6<9AirRe}R!+4MwnJm<0&iWxV|3M}*upAH@i9tXf+}#lUma=h-}W(I%eG zPWUkbh1b(&cNl;6>!*x``8ic(>q=8FUfS{Ka5_i-@9&>GZ^W7Zi~l|VOdJSff3kfE zfm4lQeM%cn$?9X$@+Wa>ZU8VoiM-BGkq>3qNj4=r#^wh=Gpek-25sTmy_jLbH5`Oj z<*G`zX!z`sI>|Zf0*~|DBLRVc#&q~_7iM{}=kxbvA%8#_kpqxHrq@ZX#-u3v7FUO} z)~4zhRZOMZ+CMzgMHuF6Aq^HMU$6IqCvJ#lEHE*2#C`Iebn15Ok0vhw!ZvEQU(~EB zD7y$_q`0wwknHYid7gURA3}4z)hg*=OTx5Ey-w@=^c^2Rece@;lANpz1Isc31)u?c z!0+|>13sTmmSvzPPB+od{qwh1?2-L@1^`SW1eZX7T0L^WgJ#44LaAqM#HqQ>Acfy$LscLX^n=N#Qy^C>o`RN2smSJmlC%GMB$ zU(z21vee@r?H~5{wpgUe;q~*Ro`4V~I@2(ll5S;FbKI;kl`wjf9x$uWSsu}+Aj|!zpYi#>+3Qg|O=A^anCGBBtPWddxNRi-^dH?6%HeGmOxC5+z zVz%C1b0#4N8H8xk;57BR`l-+3#|%dpt=@tec5fnw2%39gJKtnYb69dPP!>Xhu*O^=sfpRz!Io;=Z8rM2Mk;;PiMMrGQ|+<;qqfJTA zDT$QKSW2hy$A7hlQW~bx;xo)!utrE&C5RCs1mo?jMt>8ZlJPOlnDPFi`@M4U+%et3y;$%Sxlc_D+rUit0Lod6|RDV9s^Y?pX zKVr#E$l3~kM6bvH_G-?>HQw5a5WWcj{g*b}m`BjYXVz?MVf3bp=E*kaL1npxj&>;< z(U>w$GnVX~(f2Lk_yD8IZqk7H?4o+Z`tl1Ijyrz|TrA^Ay=r%7aPgy1j{{F)-pVLo zK+CE)Tsrj>K!f7+Hkkd}O9w1hUsKPTOd*w=OVMrTGRy6|FT1_kQgDwny{pUFd@BHYE>R1t^8r98z!YyC zAPgJx41@N)Jlw`BdKU1i2{tW%T{4Y2!k|>icOd$5RH^AJYHpe=h*}s=xqOF{FeFUD zZ>z3Mnn(o@T2?*IQV$F-M!ntl^{-{aSymW~5H{^C;}Of87mhqut=+HAFRdlKda-

A~RFa0Kp84aRM90LM z#CTbehQ$SdVS_pPCPYG>n#`Sg@r7_#A$IY z^o%q;B~$q#066a~MSvMQ(U}u23OYTk)NEDSA;Jkuws{MhNkEupMsKW8<&`w(H}5V) zI+KD~%KpxuQz?a{Rq|xP7i1haU$iBc5FAcH28{eeKlrXQkKL^q) zwbd_rfv>$?S?KczY7gz(of@=G5)I|{cuyUzZ%8gl1K~}7iM|0qXcRFlKkq=r3~SeyZ5<#iTaKtS;%h$vN}V% zkeNHH&MQe#XXfVc=F~z)t;xcK@O<9dK3``;TDS^O^clQ8Ej})@;51Qbvd@*c1rP@P z_BEBd4FypJbXXkw&96_p>+9p1soI^pPiKAV)7uHlX4or^jMK;_APoDY?6M|B$G)fl zP#LqzF-D&Z|5$IBG49{rBG+)R6Qm5_4vwQh`Y$YH{zn+*Pu=~?iLNwbEx(M3ncPB!Mf_IAbK%{N><&U0K`wJ0w;bL7`g{%L7%FhH$a!iU81 z=1^Y%*)usSI;|C=)A}f@Pdk3rOe#5FHZZ2L0l963;F>K2yETtak7z-bjuC^!t1H{) zAPgH~ot2!njzDj7o^tL;l;priWcT!{({sm#!y$$4jn4EqhZ#R0T&8rStpa$#N(`y- zi**3TV0u(RNruz%ZJ1%w=NCMuH?{GZc{A$VVoP|zt3nGtX0sqHmn{^Q9rN|Hm@v@L z0>!eZ3EpRaU+V;7*i%HUox&`yOC}L55{yyXYq@OuFmEyJi6ry{gxsT#{rrrx@<_s> zwW9BS-$fWElzTAy#%uOS0RR;KBDt)woQd(~u-G4YL)t!!@lEreh2o&n< zJSj-%gnqx)H#FF8xZuJ}9MVD%tIE%q@^p_FLKID_2-z{y?(c00of`m2Y{AANth2mH zZ4-GIW5olsPEM!%^jE)6WeBd>y>lxYO(qf$CF^6q`QxI^JpqA-B6LU^YmR4MgXpw& ziB9XYEN}h|Vwp)mI1^)ZUbLTigdACZcP^5}DRs%_u-fnIX&jKJMwM(u7(L+~YyHFd zoKIZ1rahw5`kv^tJj&~FGX!^Jj0!Nq=S<5Dav*ih55C)E+`D~PySAi@%Sau>f-nuD z1k~rJFf*#q%J|&1O+s#wZGORXCQBisQpa8J@jy5sPqlXYsAQY!p)T7(7O51_OXK~8 z{;t#6-qw0PFx)*LJ7?N~(jLt5Gnfr=LB^`@EbB5}PzDD`7PRLM+gF}NElZPYn4 zC;d5Llu5}nW0qS>3~5K`>_mDTDU7S@PUb)IizhVW6V~I|nHl{&$IbQ+4)@%6>%I97 zr)zQgEx^xuMw*{mYF?6e+9^7%HKKFkmq?fX905Fl0HmO)HqU+o0Dgqfc)X0WxTi+M zgH`%Yq|x^yUOf)7#9*M|Fc0|LseI9yl z$^oafRdibaBpUcfSedUx2oD27LG3^ntsR{0O)-NI8bS!SA%q$MAu&>@Vn9Tko*p*t z-@lzV8;jI=*#-KI>#|IHx9d|bx`0go^#y_e!tb`aWYZ*{mc1+41s@NX5VMfKHk&Wn zbX<1KOj5U9p@CqGFa*3NdDfaM4tEv$yBe~*ZO0keG26hVWR4Jy&k6POTc1~J-oMGn z>eL&e*Pm}_sHXP|4;0B$ld;;3pzUXhwoEamj8fH_QmbLhhOp)uEdUV0wW8Bnm&|o7 z6$VtQ$7d5EFu|$1ojV)c_4Nu8%+a4be39lCzpCd<=1^!*u;{meZ~TPqZ+{LI-(a%i=Gdm05NCRV{?Wcv+O2} z@WzO_01EhyN`o!s7$CH8Ty?K$42N#Ax=y@1h-4|M$O#^eCV_lkF+A+`py4+WLT`q@ zOWU9mFq4iMS@OA&3prZt(Iu?fjPi8gpbaA&R_&cNRXZnBEH&vL`}iId(I+2ctT$bB z$uZkIZ-t$No{rn4&hIFg5Y}-}c(xek*R3A>~z8E`NgspVeTOwls0lwJwW}gb`Nc z8qQT?<%)!-Vm3o9%FRw zao_0<7v`Bm3@aSN7$+t&(OZtQrMsq>wA}rK<+em8w}dze1@#3??uLf>@8s#R^|Q}E zmM#vqDHlJ$q}${9?m5r>d}FfJ<+nEXh6I(PbtYc@x_hF@3KV@I`umxeU)(qR=p#)4 zNYSAv?Gv9c0mk7jL3GBNBm2Co8l566yI#F!%SGl7+?j5=?w%&i&P$FEQ)oy{s8!4c-Gw z4n}ig5i?97F{V|~*|D7Ya^gf_8Af{g*FT;uDJt0W$&cQs?d}_BZ0qcamY(Z~Ci6Mj zJ+Tre8l%hEd^2arcoGxx*+rdUT5|)FRkA%P^MKpD8T2$$&RGdz`qBG zPHQ7)NH50-D;S>x!^2s&iX%4Tfdkz{5JK-6Vc1kQHMP=x@4Vr2IaM>k@zzs*Py1hP~+w$Fqdxw=>0CdjXUU zGR5)H!v2T8HU$6^vfG_FZ+ZSS0UwelhxPvM)5V@s?`?3^{%w=1=B3T<`ZqTFI~z*C z>r#wC43;|cHUAV8F!`#iy#1=If_jM9IWWf`#wj^RmO8H_%WUb+%eG#A(?4}KG`Ai9 z#gD(8Z8Yc=_Hyu{u`Il;$u5h_`5za?0+Tj-(-*Wkn>!K4|A{ePk_rm^?sN!vkH%|| z8jVo6i02c=q5+`RGurY*()0a0f!i;Cl2V8MN+n^~tt4-K zb@YM$p6>OXcio|KpJ@q`C-6DB{n{P7>&`mEmJgM>L4WYZ|7X1X<6AIc!rpHoE$ady zGp`N+h1EXBoRM^{_Nm4*YcIRuwgc;S9geG7VT_A0W{+#KH~&GEx#>_`%_$>8tIpnh zUGO(ryQ68~0Bbw-)ci80AYYSCjp%*TeQalbRMfr{r=s^?msZ6Jx*p`5`3BNijeZjlmsz ztr$~Z;B37=OiNRf(PhC&3xGu?Q4p8AJuo`bugc3;oRHDzFP8L{I^>*IR7Zi=qj1KV zue@r&bolTE({GPE z#odWj>Dl`*BDKD;fnA|&D2Cer0926=4p_uE{=x~u?rcJQ@7^7PW!MAF2HV1c^ zW`yuG0$_mPJPc5_l*y3{2o+Q5aN7Z}HNb&bdNOOtI6J6hcWs^^gAlgOlS0c@qrBO_MMG6pZmNv|6AWq?YjF;2Y5UN z@VbnSiWhqfhi(v-szi+jaIB3pX-D}q(=?xE1je9EAv{xxLHRxcMs)14pP{HZnH+UT zD@Gpu=|PN^85S2bao<;i6Z0lr50LPM)u!v?E{2(xU*@wvb6-rA zAd^{KD7kEQ3<(-%1Yd4J01SvvIkYur%H`Y3=M*zR)d>L_M+kZlrQVPW12e$~l!6c{ z#TX?`_a&rfUP#oc7H@y21|uj&3_F3S94?SyN_ULV@$PBlvL(&V8V_fM)rDt2%&o&E zYD_3$Cz;gTR-!ej5yJ+rjYkN4AW75Uo)PKP=$6QP2h&3b!Cu`L6Fde3O{_s`|p{$Ai2ZGMu^+H{N~_-TyM_UOO6Po3D(arn~7!boqMBpT%7Gy@lW1<##M&J#YC0S?R zN9Dqbqhrm!{zkRC@w7s4%Qjou=%YV5w337v%<^#;vp8cHRT*j0*l&Ivhn@^49d1ud z3W`Zw(POW!+?jdT-93oJc&Ia(?9%t>Nc+W!#`N^`a>o4!5MfE7kbTX&bbLo zLTzl~a@Q7NjAO_fG0z~xXCZ9Lw9(P-Q^-9Vewk(K5X;0Yn2zfJx5 zPcbd70l+B*ukUb*2u?3HcH8}rX9SFti@V-%>yv@ zA(o%Of_fOKbpuFi>_-eY2?1YvB4PiQ)7%`t`QKjV~%g)OsPFWP9c$PlT+ZC zFQfvRb?04$7@?4`rII+<```mPAO;lUP=H4~qb(1g)6M!jhv=TLicag3oHo4^VYFw_ zaH=E;vv0rS&zeCoieqZQ1 zdAtZoi9>lS1u>*`60I?Al98wBM2~D8OB6+Li%x4&reH#CTbogyjybewk`7a}^#ta4 zMFL7t`bdMV?bz>5*rAs(nWl;#{>dbxR)>BQTm;S&oin~c>#C701cl)2%fE4?^hq@9JXg?k9a3imCAq$TchMCNgKFkXmL>M!|I5p8r1^~nIXxB7y+rkeB zICUSBUuMUIU5h!cjnkW4tT{EE(dgF)HJ16`*(+ack*!0U&W__Md-l&l@Mw_bv7RqJ zz)BIJ*txmF+n$l;E0<~j@jnW=xoQoVK93RpN@T11L;!?pQ@1jcuVlKS7^QGxPc@2K~<6$613RFSx@A*%mXGZf^38 zPNgAw?gd2D=Q-nVKdwk+R&TtZo6+irsLvZfi_n#0j7`{>s--SRFO~yyAr&gCne+k{ zGwk}{OSM?3Yfvy?y~P54PZJV-q4+PRk{M>lG7Sf>9aLxJZsd&SLWUR8BJVE%l0By@ zHHgZgvDIZ88%>7}HYE-S;;z~EHVOUb-Nr_BEQ{xPB-fx4RaNV)gr6=70a?- zgwYm^5|e=hwOH``G_uoamTk5a*=|drE~g26ejTEee?JyKHdxLu#`GA%6DkI3X@B#c z=)yO}u^2T59M&h3InQEw05ZaOY|%-I-XdwZqX%Fv`6B#1uiirmYhCor0a57t$`>z~ zdF|CIKyxG;yhUH0`hmT(s>00PWG0^o0LbC2epy)p%n5c?Udax1PSGL5=3Q$wB^x`4 z)=&X}8Mu?NG{fpiY#qk(%O!k`WkeLuUH@_!goi_D*82*bMeSU{kbW&fi{!3{5?K6M)Me`MPrhW!T}m?5FcIUy^>Eaa!R2dp-3u{6nO0Dw=Lv9>#o{J#`% z%_>wsX8%%)KLkL(7oFCm$a5D}Z8W)?A0-HXHkp9{VK>QI zbCl>!7X)`t>i5*rftKXkc=Mbu@1OszUQ@Q=z=C5Di~h0cHh0HRjx^)6g!;vc{$bf= zvs0G?sLxHn=VGbHrInnvRO*~oAxdM0)*rmFl1tA`Dm+6e$RPSgX~1s>nTpgoHR(C_ z#^vE3Xid$0QMn5XLV(fe7^2eY8MQita6-m>NF}Z~N_)H$tw{-hMKjkhLdOt7NeKXqKnr7Chox~cO-$)0!Rt;* zcETY9MAgu@zj;u*ZChi`gI_WdmMshgLb6iI8I$(p`0xHQ>OT3-dhoe}J7iKcUO+gH z;nEME=8P#x-#kJvg9LRRBd9~?pc+kcWMc$y**0m{mAjM9B;$Bx9=GVjK(Jr<|LmQA zY!l}h$Dijt$BBJ$k`h9QohfBxph28&0u>mfz`71Z1zXo{|DeHO8CzRcb<%!lYPUZs zty@*?hqhH6+lmlXwUz-zXl*I#B*@l58I%$`A%upIG`=Jbj*0C%+h^bVOmR9&NaEc2 z2Tt1i{C)4`IdOh@?|HxP_xq`Ntzx}1ZYdKR5aIL6#f}yoKmOhXCGJxPIy&_}k4buD zzjqJ-{$_KzyBR@?kv;T$p$!jSW~F5vBnOlHnzRgobicnu6p9YEzpMB9v=aaac0w>H zDM#X!6r3nd(2IQj{9TI4GZuzMnanDnRT(speLsFfqz zr20DYrdRkR6ZLsc+iTrVBH%NHt_1?UtN4c}77TYE5#BN)21|PwDB5?y#|d;<3*{6| zD;nJM{Mz>){!Tpf-!muU{gQ60aaJ+!a`}dDo^mv8X=H+s^|oR%5}XjWVIWxITGacW zai)%Ud~v-KVSISQ@Uhp=OqTO@(|ul(qw1Q`e-C5Tn;1dq^E^A*X~S8Ch6jyYKJDQZ z1bzg7P3ljUZ?sUTQu^($C+yo3068Do-6A$LmZ7!CN}86?!eKcXO@!5fh*R|kSLEa@ z96?Mu`_FMdOtjhpAXuhtZW2H;eCmx}P~$hGy^b|aX9b(>hHiogg4YfoYzrLv`=l$T z)D8sUFJ;N|v($pjB;ODx?ORBKN3U($OoE6aGRLDv#!Nc_Bsjce72wIND$6lKsW?); zy6J8}xUsiuaX3<%NpWl;9Z-kEZE}0>oP@O`akNFA`jxc zH|7g;%WXXixf0V62#HDUy8oT<+yAJiXk=Vhm^tQjmv7kWyjh=3ql>O0A;O9*3M(lN zS<3C#W$%USBX1noaPwH}!iAUTZ{ImEuy5~t^-5RTSZxA23<>#RdGJblErm(m5Nq9s z5I%Cmp6QIP7E#k0A_jMdbWH%Z+NEC;Ksc0fb$JABwn*jXE$>x)>C0t8X{j5hc&5?M z21+D7v0ndmrAzuWkBpxTi1?r?UF}dVv@9kiJVAe5vUqXF{GH$ZplDw8Tvo&?fw8hw z5F?IXMG=6~xnmorc-+u=^RXJJABY~8CGP>FV@+~P>HPXCF4(;+&lc~wf{2dDKFTpmk2=ay>PK&cAz7diI%VKM`)JB@PI`lcVY{AaE&cWUNX4usPjZ z1eU){#aux|T0+wrGtq|V-es`WxW6I@c*JmZ1p>v=P{rEy?PY7%QgN+o5kodXTA8>W zJlFO3_ZvV>Oseaj|Gh^}RNS*}HNey^LI9BDs(LjpNdqG-Ekf|vYb&Q(h1^OisRn4j z#H_U=sMEDi9e_Kyt^|QYiOHMbh(cq;)YEAP+>tRUT|l(W5r5I zT)Gql(P0UlIh`DO<>gg3hnw6?<%3`Uch!Rrr5bdU`Yfj*EgJPF1A&pauP+h3)LE=t z?yOXMx)*?^84`~WflNqywS@Yc_~50hxq3>IJWyRNik9kgh_JZOrA!6^_+&Y<6oOYW z3V$&)0Tfp+zSCl{dW`z1%|?hZt+>Yd0asOPayU?B^hZ7!oTv=}{wha8`^-Z;vj@9# z$>$J-Q;7Plwr&SB3g;V@{WY2Z_@wO26k^q3bcp46)X|6$oA|#L?iXAP!H>ca_$nf7&gPG23)i$}rN8Y39hWk@^Rss` zu&g4)6#%dTfIgFLoHr3kL~sd^+JUq8P@s3@K2Mve+WYuouWf8bqCNSFg4Y8P{7dFT z8==2n$@X4ZENALUL|6IfB(O2tA~JzE|BEbnGw;q;Eh)8Gg$BlP0V3VOk;)Lz4g^;C z|K|g84ur7}n|31t;4mQhRgMEH3-&@^pXtQ&3B*>@@Ku(wBQJ!P0G)`$waqZp9?8Ys zvizP|Vzt)_dl}%?Toj`hBHAbWJk6#Lo=Kq?YYbWrAwq6qhjqY-4k*6Rc7UDK6e=MV~OlA#<@X$blmF%}zt#ybv!MiV) z5J7^7eyT{G9{`NxH1{EA9Zh?Udl@o#0THJSJTwXsB?;kqB^>&>xqW6b)37<+TNuLc z5wR}U_a>rqL|U^VwdW<+G}l`ig5Bl*HUd0>h*P=(>t#$Li!UbQ+OOh6ZC$x3(ZnPl z)Rl-%$8JFUVJ@u2h^U8%o>C<5zI-;7^R=g0B-S)OL=2h{Xr0lHDG{9qqL+}z_D2TZ zAIejWCMJ2HuEg#t{RTs}g9w_8=uwDBBfyhL{LixFJ#;%_-tsSMs97{uMC-i-uo95D z5ovT~FelY*2T%cFm Date: Sun, 23 Nov 2025 21:54:30 +0100 Subject: [PATCH 036/298] Update Readme.me --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 19936b28..79fbd9e7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE.txt) [![Client SDKs: MIT](https://img.shields.io/badge/Client_SDKs-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) +[![Downloads](https://img.shields.io/github/downloads/alia5/VIIPER/total?logo=github)](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) [![npm version](https://img.shields.io/npm/v/viiperclient?logo=npm)](https://www.npmjs.com/package/viiperclient) From df984ab8d2c11e2624ec202184d54d9144c0280c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 29 Nov 2025 15:11:34 +0100 Subject: [PATCH 037/298] Improve about / help text && add ascii art for funsies --- Makefile | 2 +- cmd/viiper/ascii_braille_colored.txt | 31 ++++ cmd/viiper/ascii_braille_colored_sm.txt | 19 +++ cmd/viiper/meta.go | 72 +++++++++ cmd/viiper/viiper.go | 192 +++++++++++++++++++++--- go.mod | 3 +- go.sum | 4 + vsc.code-workspace | 3 + 8 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 cmd/viiper/ascii_braille_colored.txt create mode 100644 cmd/viiper/ascii_braille_colored_sm.txt create mode 100644 cmd/viiper/meta.go diff --git a/Makefile b/Makefile index 0ede4388..bfc4b8bc 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ COMMIT := $(shell git rev-parse --short HEAD 2>$(NULL_DEVICE) || echo unknown) BUILD_TIME := $(shell $(DATE_CMD)) # Go build flags -LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) -X github.com/Alia5/VIIPER/internal/codegen/common.Version=$(VERSION) +LDFLAGS := -s -w -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.Date=$(BUILD_TIME) -X github.com/Alia5/VIIPER/internal/codegen/common.Version=$(VERSION) BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" # Windows resource embedding 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..20fd9443 --- /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/viiper.go b/cmd/viiper/viiper.go index ae172800..87b619f9 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -1,6 +1,10 @@ package main import ( + "bytes" + "fmt" + "io" + "log/slog" "os" "strings" @@ -13,18 +17,21 @@ import ( "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("github.com/Alia5/viiper"), - kong.Description("Virtual Input over IP EmulatoR"), + 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...), @@ -33,7 +40,7 @@ func main() { logger, closeFiles, err := log.SetupLogger(cli.Log.Level, cli.Log.File) if err != nil { - _, _ = os.Stderr.WriteString("failed to setup logger: " + err.Error() + "\n") + fmt.Fprintln(os.Stderr, "failed to setup logger:", err) os.Exit(2) } defer func() { @@ -42,21 +49,7 @@ func main() { } }() - 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) - } + rawLogger := setupRawLogger(&cli, logger, &closeFiles) ctx.Bind(logger) ctx.BindTo(rawLogger, (*log.RawLogger)(nil)) @@ -65,9 +58,18 @@ func main() { ctx.FatalIfErrorf(err) } +func handlePlainHelpFlag() { + for i, arg := range os.Args[1:] { + if arg == "-p" { + os.Setenv("VIIPER_HELP_STYLE", "plain") + os.Args[i+1] = "-h" + return + } + } +} + func findUserConfig(args []string) string { - for i := 0; i < len(args); i++ { - a := args[i] + for i, a := range args { if strings.HasPrefix(a, "--config=") { return a[len("--config="):] } @@ -75,8 +77,152 @@ func findUserConfig(args []string) string { return args[i+1] } } - if v := os.Getenv("VIIPER_CONFIG"); v != "" { - return v + return os.Getenv("VIIPER_CONFIG") +} + +func setupRawLogger(cli *config.CLI, logger *slog.Logger, closeFiles *[]io.Closer) 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) + return log.NewRaw(nil) + } + *closeFiles = append(*closeFiles, f) + return log.NewRaw(f) + } + if cli.Log.Level == "trace" { + 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" } - return "" } diff --git a/go.mod b/go.mod index 5d8866a9..e686a09e 100644 --- a/go.mod +++ b/go.mod @@ -18,5 +18,6 @@ require ( github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index f0b1b92c..80b60bff 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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= diff --git a/vsc.code-workspace b/vsc.code-workspace index a5c758fb..5237a91e 100644 --- a/vsc.code-workspace +++ b/vsc.code-workspace @@ -21,5 +21,8 @@ "recommendations": [ "golang.go" ] + }, + "settings": { + "powershell.cwd": "root" } } \ No newline at end of file From 03dfc2c3118be7a7e93cbab69940ecd6a6975218 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 15:40:39 +0100 Subject: [PATCH 038/298] Fix examples --- examples/csharp/virtual_keyboard/Program.cs | 24 ++++++--------------- examples/csharp/virtual_mouse/Program.cs | 17 +++++++++------ examples/csharp/virtual_x360_pad/Program.cs | 17 +++++++++------ examples/go/virtual_keyboard/main.go | 17 +++++---------- examples/go/virtual_mouse/main.go | 17 +++++---------- examples/go/virtual_x360_pad/main.go | 17 +++++---------- examples/typescript/virtual_keyboard.ts | 23 ++++++-------------- examples/typescript/virtual_mouse.ts | 23 ++++++-------------- examples/typescript/virtual_x360_pad.ts | 23 ++++++-------------- 9 files changed, 63 insertions(+), 115 deletions(-) diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs index e07001c7..e2cdd50d 100644 --- a/examples/csharp/virtual_keyboard/Program.cs +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -23,28 +23,18 @@ var list = await client.BusListAsync(); if (list.Buses.Length == 0) { - Exception? lastErr = null; - busId = 0; - for (uint i = 1; i <= 100; i++) + try { - try - { - var resp = await client.BusCreateAsync(i); - busId = resp.BusID; - createdBus = true; - break; - } - catch (Exception ex) - { - lastErr = ex; - } + var resp = await client.BusCreateAsync(null); + busId = resp.BusID; + createdBus = true; + Console.WriteLine($"Created bus {busId}"); } - if (busId == 0) + catch (Exception ex) { - Console.WriteLine($"BusCreate failed: {lastErr}"); + Console.WriteLine($"BusCreate failed: {ex}"); return; } - Console.WriteLine($"Created bus {busId}"); } else { diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs index b8afa25b..3d21670d 100644 --- a/examples/csharp/virtual_mouse/Program.cs +++ b/examples/csharp/virtual_mouse/Program.cs @@ -19,15 +19,18 @@ var list = await client.BusListAsync(); if (list.Buses.Length == 0) { - Exception? lastErr = null; - busId = 0; - for (uint i = 1; i <= 100; i++) + try { - try { var r = await client.BusCreateAsync(i); busId = r.BusID; createdBus = true; break; } - catch (Exception ex) { lastErr = ex; } + 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; } - if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } - Console.WriteLine($"Created bus {busId}"); } else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } } diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs index e1b2fe2c..d3ddcf94 100644 --- a/examples/csharp/virtual_x360_pad/Program.cs +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -19,15 +19,18 @@ var list = await client.BusListAsync(); if (list.Buses.Length == 0) { - Exception? lastErr = null; - busId = 0; - for (uint i = 1; i <= 100; i++) + try { - try { var r = await client.BusCreateAsync(i); busId = r.BusID; createdBus = true; break; } - catch (Exception ex) { lastErr = ex; } + 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; } - if (busId == 0) { Console.WriteLine($"BusCreate failed: {lastErr}"); return; } - Console.WriteLine($"Created bus {busId}"); } else { busId = list.Buses.Min(); Console.WriteLine($"Using existing bus {busId}"); } } diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 164c0980..67e802f5 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -15,7 +15,6 @@ import ( "github.com/Alia5/VIIPER/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 ") @@ -36,19 +35,13 @@ func main() { 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) + 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] diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 1150d957..7feeedeb 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -12,7 +12,6 @@ import ( "github.com/Alia5/VIIPER/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 ") @@ -33,19 +32,13 @@ func main() { 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) + 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] diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index dd6724c4..6e3f3d62 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -15,7 +15,6 @@ import ( "github.com/Alia5/VIIPER/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 ") @@ -36,19 +35,13 @@ func main() { 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) + 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] diff --git a/examples/typescript/virtual_keyboard.ts b/examples/typescript/virtual_keyboard.ts index 8de71360..b3452922 100644 --- a/examples/typescript/virtual_keyboard.ts +++ b/examples/typescript/virtual_keyboard.ts @@ -4,7 +4,6 @@ const { KeyboardInput, Key, Mod, CharToKeyGet, ShiftCharsHas } = Keyboard; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Minimal example: create a keyboard device, type "Hello!" + Enter every 5 seconds, monitor LEDs. async function main() { if (process.argv.length < 3) { console.log("Usage: node virtual_keyboard.js "); @@ -23,23 +22,15 @@ async function main() { let createdBus = false; if (busesResp.buses.length === 0) { - let createErr: any; - busID = 0; - for (let tryBus = 1; tryBus <= 100; tryBus++) { - try { - const r = await client.buscreate(tryBus); - busID = r.busId; - createdBus = true; - break; - } catch (err) { - createErr = err; - } - } - if (busID === 0) { - console.error(`BusCreate failed: ${createErr}`); + 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); } - console.log(`Created bus ${busID}`); } else { busID = Math.min(...busesResp.buses); console.log(`Using existing bus ${busID}`); diff --git a/examples/typescript/virtual_mouse.ts b/examples/typescript/virtual_mouse.ts index 6be0fde5..c76b5a80 100644 --- a/examples/typescript/virtual_mouse.ts +++ b/examples/typescript/virtual_mouse.ts @@ -4,7 +4,6 @@ const { MouseInput, Btn_ } = Mouse; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Minimal example: ensure a bus, create a mouse device, stream inputs, clean up on exit. async function main() { if (process.argv.length < 3) { console.log("Usage: node virtual_mouse.js "); @@ -23,23 +22,15 @@ async function main() { let createdBus = false; if (busesResp.buses.length === 0) { - let createErr: any; - busID = 0; - for (let tryBus = 1; tryBus <= 100; tryBus++) { - try { - const r = await client.buscreate(tryBus); - busID = r.busId; - createdBus = true; - break; - } catch (err) { - createErr = err; - } - } - if (busID === 0) { - console.error(`BusCreate failed: ${createErr}`); + 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); } - console.log(`Created bus ${busID}`); } else { busID = Math.min(...busesResp.buses); console.log(`Using existing bus ${busID}`); diff --git a/examples/typescript/virtual_x360_pad.ts b/examples/typescript/virtual_x360_pad.ts index 4d271bbc..7e753fae 100644 --- a/examples/typescript/virtual_x360_pad.ts +++ b/examples/typescript/virtual_x360_pad.ts @@ -4,7 +4,6 @@ const { Xbox360Input, Button } = Xbox360; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Minimal example: ensure a bus, create an xbox360 device, stream inputs, read rumble, clean up on exit. async function main() { if (process.argv.length < 3) { console.log("Usage: node virtual_x360_pad.js "); @@ -23,23 +22,15 @@ async function main() { let createdBus = false; if (busesResp.buses.length === 0) { - let createErr: any; - busID = 0; - for (let tryBus = 1; tryBus <= 100; tryBus++) { - try { - const r = await client.buscreate(tryBus); - busID = r.busId; - createdBus = true; - break; - } catch (err) { - createErr = err; - } - } - if (busID === 0) { - console.error(`BusCreate failed: ${createErr}`); + 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); } - console.log(`Created bus ${busID}`); } else { busID = Math.min(...busesResp.buses); console.log(`Using existing bus ${busID}`); From b07ca3dff0c56c721d24e5465299f2306b369d67 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 17:13:20 +0100 Subject: [PATCH 039/298] Breaking: SDKs: Eliminate (most) fixed size buffers - Let user allocate device specific sized buffers Changelog(misc) --- examples/c/virtual_keyboard/main.c | 21 ++++----- examples/c/virtual_x360_pad/main.c | 18 ++++---- examples/csharp/virtual_keyboard/Program.cs | 9 ++-- examples/csharp/virtual_x360_pad/Program.cs | 10 +++-- internal/codegen/common/wiresize.go | 43 +++++++++++++++++++ internal/codegen/generator/c/header_common.go | 9 ++-- internal/codegen/generator/c/header_device.go | 24 +++++++++-- internal/codegen/generator/c/source_common.go | 26 +++++++---- internal/codegen/generator/csharp/client.go | 6 +-- .../codegen/generator/csharp/constants.go | 36 ++++++++++++++-- internal/codegen/generator/csharp/device.go | 37 +++++++++------- 11 files changed, 176 insertions(+), 63 deletions(-) create mode 100644 internal/codegen/common/wiresize.go diff --git a/examples/c/virtual_keyboard/main.c b/examples/c/virtual_keyboard/main.c index cc7cb338..a46c0d91 100644 --- a/examples/c/virtual_keyboard/main.c +++ b/examples/c/virtual_keyboard/main.c @@ -1,5 +1,5 @@ -#include "viiper/viiper.h" -#include "viiper/viiper_keyboard.h" +#include "viiper.h" +#include "viiper_keyboard.h" #include #include @@ -42,14 +42,14 @@ static uint32_t choose_or_create_bus(viiper_client_t* client) return 0; } -static void on_leds(const void* output, size_t output_size, void* user) +static void on_leds(void* buffer, size_t bytes_read, void* user) { - (void)user; - if (!output || output_size == 0) return; - const uint8_t* p = (const uint8_t*)output; + const uint8_t* led_data = (const uint8_t*)buffer; + (void)user; /* unused */ + if (bytes_read == 0) return; /* 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]; + for (size_t i = 0; i < bytes_read; ++i) { + uint8_t b = led_data[i]; printf("→ LEDs: Num=%u Caps=%u Scroll=%u Compose=%u Kana=%u\n", (b & VIIPER_KEYBOARD_LED_NUM_LOCK) ? 1u : 0u, (b & VIIPER_KEYBOARD_LED_CAPS_LOCK) ? 1u : 0u, @@ -135,8 +135,9 @@ int main(int argc, char** argv) const char* devId = addResp.DevId ? addResp.DevId : "(none)"; printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); - /* Register LED callback */ - viiper_device_on_output(dev, on_leds, NULL); + /* Register LED callback with user-allocated buffer */ + static uint8_t led_buffer[VIIPER_KEYBOARD_OUTPUT_SIZE]; + viiper_device_on_output(dev, led_buffer, sizeof(led_buffer), on_leds, NULL); printf("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.\n"); for (;;) { diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c index 6b387847..33b4223a 100644 --- a/examples/c/virtual_x360_pad/main.c +++ b/examples/c/virtual_x360_pad/main.c @@ -1,5 +1,5 @@ -#include "viiper/viiper.h" -#include "viiper/viiper_xbox360.h" +#include "viiper.h" +#include "viiper_xbox360.h" #include #include @@ -55,11 +55,12 @@ static uint32_t choose_or_create_bus(viiper_client_t* client) return 0; } -static void on_rumble(const void* output, size_t output_size, void* user) +static void on_rumble(void* buffer, size_t bytes_read, void* user) { - (void)user; - if (output_size >= 2) { - const viiper_xbox360_output_t* out = (const viiper_xbox360_output_t*)output; + (void)user; /* unused */ + const uint8_t* rumble_data = (const uint8_t*)buffer; + if (bytes_read >= 2) { + const viiper_xbox360_output_t* out = (const viiper_xbox360_output_t*)rumble_data; printf("← Rumble: Left=%u, Right=%u\n", out->left, out->right); } } @@ -120,8 +121,9 @@ int main(int argc, char** argv) const char* devId = addResp.DevId ? addResp.DevId : "(none)"; printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); - /* Register async backchannel callback */ - viiper_device_on_output(dev, on_rumble, NULL); + /* Register async backchannel callback with user-allocated buffer */ + static uint8_t rumble_buffer[VIIPER_XBOX360_OUTPUT_SIZE]; + viiper_device_on_output(dev, rumble_buffer, sizeof(rumble_buffer), on_rumble, NULL); printf("Connected to device stream\n"); unsigned long long frame = 0; diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs index e2cdd50d..a67f96fa 100644 --- a/examples/csharp/virtual_keyboard/Program.cs +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -75,11 +75,12 @@ async Task Cleanup() } } -// Subscribe to LED output (1 byte frames) -device.OnOutput += data => +// Subscribe to LED output using callback with stream +device.OnOutput = async stream => { - if (data.Length < 1) return; - byte leds = data[0]; + 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}"); }; diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs index d3ddcf94..0f00933d 100644 --- a/examples/csharp/virtual_x360_pad/Program.cs +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -59,11 +59,13 @@ async Task Cleanup() if (createdBus) { try { await client.BusRemoveAsync(busId); Console.WriteLine($"Removed bus {busId}"); } catch { } } } -// Read rumble output (2 bytes) and log -device.OnOutput += data => +// Read rumble output using callback with stream +device.OnOutput = async stream => { - if (data.Length < 2) return; - byte left = data[0]; byte right = data[1]; + 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}"); }; diff --git a/internal/codegen/common/wiresize.go b/internal/codegen/common/wiresize.go new file mode 100644 index 00000000..8afdec24 --- /dev/null +++ b/internal/codegen/common/wiresize.go @@ -0,0 +1,43 @@ +package common + +import ( + "strings" + + "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 { + baseType := field.Type + if strings.Contains(baseType, "*") { + return 0 + } + total += WireTypeSize(baseType) + } + + return total +} diff --git a/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go index 2fcb564b..50f8dab9 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -119,8 +119,8 @@ VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( * 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); +/* Callback type for receiving device output */ +typedef void (*viiper_output_cb)(void* buffer, size_t bytes_read, void* user_data); /* Create a device stream connection (opens stream socket to bus/busId/devId) */ VIIPER_API viiper_error_t viiper_device_create( @@ -137,9 +137,12 @@ VIIPER_API viiper_error_t viiper_device_send( size_t input_size ); -/* Register callback for device output (device → client, async) */ +/* Register callback for device output (device → client, async) + * User provides buffer - SDK reads directly into it and calls callback with byte count */ VIIPER_API void viiper_device_on_output( viiper_device_t* device, + void* buffer, + size_t buffer_size, viiper_output_cb callback, void* user_data ); diff --git a/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go index 6ca028bf..04499459 100644 --- a/internal/codegen/generator/c/header_device.go +++ b/internal/codegen/generator/c/header_device.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -22,6 +23,9 @@ const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H /* ======================================================================== * {{.Device}} Constants * ======================================================================== */ +/* Device output size (bytes to read from socket in callback) */ +#define VIIPER_{{upper .Device}}_OUTPUT_SIZE {{.OutputSize}} + {{- if gt (len .Pkg.Constants) 0 }} /* {{.Device}} constants */ {{range .Pkg.Constants -}} @@ -60,13 +64,27 @@ typedef struct { ` type deviceHeaderData struct { - Device string - Pkg *scanner.DeviceConstants + Device string + Pkg *scanner.DeviceConstants + OutputSize int } func generateDeviceHeader(logger *slog.Logger, includeDir, device string, md *meta.Metadata) error { pkg := md.DevicePackages[device] - data := deviceHeaderData{Device: device, Pkg: pkg} + + // Calculate OUTPUT_SIZE from s2c wire tag + outputSize := 0 + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(device, "s2c"); s2cTag != nil { + outputSize = common.CalculateOutputSize(s2cTag) + } + } + + data := deviceHeaderData{ + Device: device, + Pkg: pkg, + OutputSize: outputSize, + } 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) diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go index 810d43d7..18192119 100644 --- a/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -46,6 +46,8 @@ struct viiper_device { uint32_t bus_id; char* dev_id; /* Async output handling */ + void* output_buffer; + size_t output_buffer_size; viiper_output_cb callback; void* callback_user; int running; @@ -252,7 +254,8 @@ static int viiper_read_line(int fd, char** out) { #else ssize_t rd = recv(fd, &ch, 1, 0); #endif - if (rd <= 0) { free(buf); return -1; } + if (rd < 0) { free(buf); return -1; } + if (rd == 0) break; if (ch == '\0') break; if (len + 1 >= cap) { cap *= 2; @@ -402,7 +405,6 @@ static DWORD WINAPI viiper_device_receiver_thread(LPVOID arg) { 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; @@ -411,19 +413,23 @@ static void* viiper_device_receiver_thread(void* arg) { 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); + if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { + int rd = recv(dev->socket_fd, (char*)dev->output_buffer, (int)dev->output_buffer_size, 0); + if (rd <= 0) break; + dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); + } #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 (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { + ssize_t rd = recv(dev->socket_fd, dev->output_buffer, dev->output_buffer_size, 0); + if (rd <= 0) break; + dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); } +#endif } #if defined(_WIN32) || defined(_WIN64) return 0; @@ -514,10 +520,14 @@ VIIPER_API viiper_error_t viiper_device_send( VIIPER_API void viiper_device_on_output( viiper_device_t* device, + void* buffer, + size_t buffer_size, viiper_output_cb callback, void* user_data ) { if (!device) return; + device->output_buffer = buffer; + device->output_buffer_size = buffer_size; device->callback = callback; device->callback_user = user_data; } diff --git a/internal/codegen/generator/csharp/client.go b/internal/codegen/generator/csharp/client.go index ba803b38..18dc8c81 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -59,7 +59,7 @@ public class ViiperClient : IDisposable using var stream = client.GetStream(); - // Build command line: "path[ optional-payload]\0" (management protocol uses null terminator) + // Build command line: "path[ optional-payload]\0" string commandLine = path.ToLowerInvariant(); if (!string.IsNullOrEmpty(payload)) { @@ -70,15 +70,13 @@ public class ViiperClient : IDisposable var requestBytes = Encoding.UTF8.GetBytes(commandLine); await stream.WriteAsync(requestBytes, cancellationToken); - var buffer = new byte[8192]; 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)); - if (responseBuilder.ToString().Contains('\n')) - break; } var responseJson = responseBuilder.ToString().TrimEnd('\n'); diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index 9c277084..d4470373 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -23,7 +23,16 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, return nil } - if len(deviceConsts.Constants) == 0 && len(deviceConsts.Maps) == 0 { + 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 } @@ -34,13 +43,23 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, 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 Maps []mapData }{ - Device: pascalDevice, - Maps: maps, + Device: pascalDevice, + OutputSize: outputSize, + Maps: maps, } for _, eg := range enumGroups { @@ -358,6 +377,17 @@ using System.Collections.Generic; namespace Viiper.Client.Devices.{{.Device}}; +{{if gt .OutputSize 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}} +{ + public const int OutputSize = {{.OutputSize}}; +} + +{{end}} {{range .EnumGroups}} /// /// {{.Name}} constants for {{$.Device}} device. diff --git a/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go index 0ebe1d20..1fbc6c91 100644 --- a/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -32,19 +32,31 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable private readonly TcpClient _client; private readonly NetworkStream _stream; private readonly CancellationTokenSource _cts = new(); - private readonly Task _readLoop; + private Task? _readLoop; private bool _disposed; + private Func? _onOutput; /// - /// Raised when output data is received from the device (raw binary frame). + /// 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 event Action? OnOutput; + public Func? OnOutput + { + get => _onOutput; + set + { + _onOutput = value; + if (_onOutput != null && _readLoop == null) + { + _readLoop = Task.Run(ReadLoopAsync); + } + } + } internal ViiperDevice(TcpClient client, NetworkStream stream) { _client = client; _stream = stream; - _readLoop = Task.Run(ReadLoopAsync); } /// @@ -73,19 +85,11 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable private async Task ReadLoopAsync() { - var buffer = new byte[4096]; try { - while (!_cts.IsCancellationRequested) + while (!_cts.IsCancellationRequested && _onOutput != null) { - var read = await _stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false); - if (read <= 0) - { - break; // connection closed - } - var frame = new byte[read]; - Buffer.BlockCopy(buffer, 0, frame, 0, read); - OnOutput?.Invoke(frame); + await _onOutput(_stream).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -112,7 +116,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable if (_disposed) return; _disposed = true; _cts.Cancel(); - try { _readLoop.Wait(); } catch { /* ignore */ } + try { _readLoop?.Wait(); } catch { /* ignore */ } _stream.Dispose(); _client.Dispose(); _cts.Dispose(); @@ -127,7 +131,8 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable if (_disposed) return; _disposed = true; _cts.Cancel(); - try { await _readLoop.ConfigureAwait(false); } catch { /* ignore */ } + if (_readLoop != null) + try { await _readLoop.ConfigureAwait(false); } catch { /* ignore */ } _stream.Dispose(); _client.Dispose(); _cts.Dispose(); From c51009432b51f9a0aab1b6f246e20934f3a57e92 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 19:26:13 +0100 Subject: [PATCH 040/298] Exclude C sdk build folder from artifact Changelog(fix) --- .github/workflows/clients_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index eecccc60..73a1dd5c 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -126,7 +126,7 @@ jobs: - name: Archive C SDK source if: ${{ inputs.upload_artifacts }} - run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c . + run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . - name: Upload C SDK source if: ${{ inputs.upload_artifacts }} From 48839ba18179ffeb54de83718d3fc0745a230323 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 19:26:35 +0100 Subject: [PATCH 041/298] CI: Smokebuilds: Include C examples --- .github/workflows/clients_ci.yml | 4 ++++ examples/c/virtual_x360_pad/CMakeLists.txt | 22 ++++++++++++++-------- internal/codegen/generator/c/cmake.go | 16 ++++++++++++++-- internal/codegen/generator/c/helpers.go | 7 +++---- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 73a1dd5c..4c3ea09b 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -98,6 +98,10 @@ jobs: cmake -S clients/c -B clients/c/build cmake --build clients/c/build --config Release --parallel + - name: Build C examples (smoke) + run: | + cmake -S examples/c -B examples/c/build + cmake --build examples/c/build --config Release --parallel - name: Rename TypeScript SDK tarball if: ${{ inputs.upload_artifacts }} working-directory: clients/typescript diff --git a/examples/c/virtual_x360_pad/CMakeLists.txt b/examples/c/virtual_x360_pad/CMakeLists.txt index 4d954658..11566f62 100644 --- a/examples/c/virtual_x360_pad/CMakeLists.txt +++ b/examples/c/virtual_x360_pad/CMakeLists.txt @@ -3,19 +3,25 @@ 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") +if (WIN32) + set(VIIPER_LIB "${VIIPER_SDK_ROOT}/build/Release/viiper.lib") +else() + set(VIIPER_LIB "${VIIPER_SDK_ROOT}/build/libviiper.a") +endif() -add_custom_command(TARGET virtual_x360_pad POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VIIPER_LIB_DIR}/viiper.dll" - "$" -) +target_link_libraries(virtual_x360_pad PRIVATE "${VIIPER_LIB}") + +if (WIN32) + add_custom_command(TARGET virtual_x360_pad POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${VIIPER_SDK_ROOT}/build/Release/viiper.dll" + "$" + ) +endif() diff --git a/internal/codegen/generator/c/cmake.go b/internal/codegen/generator/c/cmake.go index 4d171f6b..52fe8381 100644 --- a/internal/codegen/generator/c/cmake.go +++ b/internal/codegen/generator/c/cmake.go @@ -18,26 +18,38 @@ project(viiper C) set(CMAKE_C_STANDARD 99) # Library source files -add_library(viiper SHARED +set(VIIPER_SOURCES src/viiper.c {{range .Devices}} src/viiper_{{.}}.c {{end}}) +add_library(viiper SHARED ${VIIPER_SOURCES}) +add_library(viiper_static STATIC ${VIIPER_SOURCES}) + +if(NOT WIN32) + set_target_properties(viiper_static PROPERTIES OUTPUT_NAME viiper) +endif() + # Include directories target_include_directories(viiper PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) +target_include_directories(viiper_static PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) # Platform-specific settings if(WIN32) target_compile_definitions(viiper PRIVATE VIIPER_BUILD) + target_compile_definitions(viiper_static PRIVATE VIIPER_BUILD) target_link_libraries(viiper ws2_32) + target_link_libraries(viiper_static ws2_32) else() set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden") endif() # Installation -install(TARGETS viiper +install(TARGETS viiper viiper_static LIBRARY DESTINATION lib ARCHIVE DESTINATION lib RUNTIME DESTINATION bin diff --git a/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go index 272345b3..966dda71 100644 --- a/internal/codegen/generator/c/helpers.go +++ b/internal/codegen/generator/c/helpers.go @@ -115,7 +115,7 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":\\\"%%s\\\"\", *%s->%s);", condition, f.JSONName, varName, f.Name) } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat_s(payload, sizeof(payload), tmp, sizeof(payload) - strlen(payload) - 1); }", + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", condition, f.JSONName, varName, f.Name) } case "uint16", "uint32": @@ -123,7 +123,7 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":%%u\", (unsigned)*%s->%s);", condition, f.JSONName, varName, f.Name) } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat_s(payload, sizeof(payload), tmp, sizeof(payload) - strlen(payload) - 1); }", + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", condition, f.JSONName, varName, f.Name) } default: @@ -138,8 +138,7 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { lines = append(lines, " "+fieldCode) } - // Close JSON object - lines = append(lines, " strncat_s(payload, sizeof(payload), \"}\", sizeof(payload) - strlen(payload) - 1);") + lines = append(lines, " strncat(payload, \"}\", sizeof(payload) - strlen(payload) - 1);") lines = append(lines, " }") return strings.Join(lines, "\n") From 75cff77ff65b8d1809a58f8b88205ef6d14e760c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 19:41:09 +0100 Subject: [PATCH 042/298] Update docs --- docs/clients/c.md | 4 +++- docs/clients/csharp.md | 3 ++- docs/clients/go.md | 10 ++++++---- docs/clients/typescript.md | 7 +------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/clients/c.md b/docs/clients/c.md index c82f6618..4d1222d3 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -68,7 +68,7 @@ endif() #include int main(void) { - // Connect to management API + // Create new Viiper client viiper_client_t* client = NULL; int err = viiper_client_create("127.0.0.1", 3242, &client); if (err != 0) { @@ -289,4 +289,6 @@ cmake --build build --config Release - [Generator Documentation](generator.md): How the C SDK is generated - [Go Client Documentation](go.md): Reference implementation +- [C# SDK Documentation](csharp.md): .NET SDK +- [TypeScript SDK Documentation](typescript.md): Node.js SDK - [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index d4727d45..a9847319 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -76,7 +76,7 @@ dotnet build -c Release Viiper.Client using Viiper.Client; using Viiper.Client.Devices.Keyboard; -// Connect to management API +// Create new Viiper client var client = new ViiperClient("localhost", 3242); // Find or create a bus @@ -504,6 +504,7 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C - [Generator Documentation](generator.md): How generated SDKs work - [Go SDK Documentation](go.md): Reference implementation patterns +- [TypeScript SDK Documentation](typescript.md): Node.js SDK - [C SDK Documentation](c.md): Alternative SDK for native integration - [API Overview](../api/overview.md): Management API reference - [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/clients/go.md b/docs/clients/go.md index 29c576bb..5cb58de1 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -27,7 +27,7 @@ import ( ) func main() { - // Connect to management API + // Create new Viiper client client := apiclient.New("127.0.0.1:3242") ctx := context.Background() @@ -223,12 +223,14 @@ 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` ## See Also - [Generator Documentation](generator.md): How generated SDKs work - [C SDK Documentation](c.md): Generated C SDK usage +- [C# SDK Documentation](csharp.md): .NET SDK +- [TypeScript SDK Documentation](typescript.md): Node.js SDK - [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index e26e9ab7..9d023a9b 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -87,7 +87,7 @@ import { ViiperClient, Keyboard } from "viiperclient"; const { KeyboardInput, Key, Mod } = Keyboard; -// Connect to management API +// Create new Viiper client const client = new ViiperClient("localhost", 3242); // Find or create a bus @@ -289,10 +289,6 @@ const mods = Mod.LeftShift | Mod.LeftCtrl; // 0x03 ```typescript import { Keyboard } from "viiperclient"; -const { LED } = Keyboard; - -const { LED } = Keyboard; - const numLock = (leds & LED.NumLock) !== 0; const capsLock = (leds & LED.CapsLock) !== 0; ``` @@ -512,7 +508,6 @@ Some quick troubleshooting tips for the TypeScript SDK and device streams: - Connection refused / timeout: Verify VIIPER server is running and listening on the expected API port (default 3242). Ensure firewall/ACLs allow TCP connections. - Unexpected response or parse errors: The VIIPER API uses null-byte (\x00) terminated requests. Use the provided SDK helper methods or ensure raw sockets append a null terminator when calling the server. - Stream closed unexpectedly: Confirm the device stream was opened (device added and connected) and that the device handler did not time out (default 5s reconnect window). Check server logs for reasons. -- Device not appearing to OS: Remember you must attach the virtual device via USBIP (USB-IP server default port :3241) AFTER you create the device via the API (or enable auto-attach on local host). - Use examples: See the repository examples in `examples/typescript/` for working end-to-end samples that demonstrate bus creation, device streams, and cleanup. ## See Also From ccb87f3e648f1717fc61a59c551c8008b0074a46 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 14:19:44 +0100 Subject: [PATCH 043/298] SDKs: add rust client Changelog(misc) --- .github/workflows/clients_ci.yml | 38 ++ .github/workflows/release.yml | 35 ++ docs/api/overview.md | 1 + docs/clients/c.md | 1 + docs/clients/csharp.md | 1 + docs/clients/go.md | 1 + docs/clients/rust.md | 580 ++++++++++++++++++ docs/clients/typescript.md | 1 + examples/.gitignore | 3 +- examples/rust/Cargo.lock | 444 ++++++++++++++ examples/rust/Cargo.toml | 15 + .../rust/async/virtual_keyboard/Cargo.toml | 8 + .../rust/async/virtual_keyboard/src/main.rs | 175 ++++++ examples/rust/async/virtual_mouse/Cargo.toml | 8 + examples/rust/async/virtual_mouse/src/main.rs | 163 +++++ .../rust/async/virtual_x360_pad/Cargo.toml | 8 + .../rust/async/virtual_x360_pad/src/main.rs | 133 ++++ .../rust/sync/virtual_keyboard/Cargo.toml | 8 + .../rust/sync/virtual_keyboard/src/main.rs | 185 ++++++ examples/rust/sync/virtual_mouse/Cargo.toml | 7 + examples/rust/sync/virtual_mouse/src/main.rs | 168 +++++ .../rust/sync/virtual_x360_pad/Cargo.toml | 7 + .../rust/sync/virtual_x360_pad/src/main.rs | 138 +++++ internal/cmd/codegen.go | 2 +- internal/codegen/generator/c/header_common.go | 2 +- internal/codegen/generator/csharp/types.go | 2 +- internal/codegen/generator/generator.go | 2 + .../codegen/generator/rust/async_client.go | 234 +++++++ internal/codegen/generator/rust/client.go | 191 ++++++ internal/codegen/generator/rust/constants.go | 231 +++++++ .../codegen/generator/rust/device_types.go | 180 ++++++ internal/codegen/generator/rust/error.go | 67 ++ internal/codegen/generator/rust/gen.go | 189 ++++++ internal/codegen/generator/rust/helpers.go | 165 +++++ internal/codegen/generator/rust/project.go | 101 +++ internal/codegen/generator/rust/types.go | 139 +++++ internal/codegen/generator/rust/wire.go | 45 ++ .../codegen/generator/typescript/types.go | 2 +- mkdocs.yml | 1 + 39 files changed, 3676 insertions(+), 5 deletions(-) create mode 100644 docs/clients/rust.md create mode 100644 examples/rust/Cargo.lock create mode 100644 examples/rust/Cargo.toml create mode 100644 examples/rust/async/virtual_keyboard/Cargo.toml create mode 100644 examples/rust/async/virtual_keyboard/src/main.rs create mode 100644 examples/rust/async/virtual_mouse/Cargo.toml create mode 100644 examples/rust/async/virtual_mouse/src/main.rs create mode 100644 examples/rust/async/virtual_x360_pad/Cargo.toml create mode 100644 examples/rust/async/virtual_x360_pad/src/main.rs create mode 100644 examples/rust/sync/virtual_keyboard/Cargo.toml create mode 100644 examples/rust/sync/virtual_keyboard/src/main.rs create mode 100644 examples/rust/sync/virtual_mouse/Cargo.toml create mode 100644 examples/rust/sync/virtual_mouse/src/main.rs create mode 100644 examples/rust/sync/virtual_x360_pad/Cargo.toml create mode 100644 examples/rust/sync/virtual_x360_pad/src/main.rs create mode 100644 internal/codegen/generator/rust/async_client.go create mode 100644 internal/codegen/generator/rust/client.go create mode 100644 internal/codegen/generator/rust/constants.go create mode 100644 internal/codegen/generator/rust/device_types.go create mode 100644 internal/codegen/generator/rust/error.go create mode 100644 internal/codegen/generator/rust/gen.go create mode 100644 internal/codegen/generator/rust/helpers.go create mode 100644 internal/codegen/generator/rust/project.go create mode 100644 internal/codegen/generator/rust/types.go create mode 100644 internal/codegen/generator/rust/wire.go diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 4c3ea09b..f5fcc45d 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -102,6 +102,26 @@ jobs: run: | cmake -S examples/c -B examples/c/build cmake --build examples/c/build --config Release --parallel + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build Rust SDK (smoke) + working-directory: clients/rust + run: | + cargo build --release + cargo build --release --features async + + - name: Package Rust SDK + 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 TypeScript SDK tarball if: ${{ inputs.upload_artifacts }} working-directory: clients/typescript @@ -139,3 +159,21 @@ jobs: name: c-sdk-source${{ inputs.artifact_suffix }} path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz if-no-files-found: error + + - name: Rename Rust SDK 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-sdk${{ inputs.artifact_suffix }}.crate" + fi + done + + - name: Upload Rust SDK crate + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: rust-sdk${{ inputs.artifact_suffix }} + path: clients/rust/target/package/viiper-client-rust-sdk${{ inputs.artifact_suffix }}.crate + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 397e2a76..870f8113 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,6 +151,41 @@ jobs: 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: Publish Rust SDK to crates.io + working-directory: release_files + shell: bash + run: | + set -euo pipefail + CRATE="viiper-rust-sdk.crate" + if [ ! -f "$CRATE" ]; then + echo "ERROR: Missing Rust SDK 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" + 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 + - name: Extract build info id: build_info shell: bash diff --git a/docs/api/overview.md b/docs/api/overview.md index 9975be51..efd7db9b 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -10,6 +10,7 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de - [C SDK](../clients/c.md): Generated C library with type-safe device streams - [C# SDK](../clients/csharp.md): Generated .NET library with async/await support - [TypeScript SDK](../clients/typescript.md): Generated Node.js library with EventEmitter streams + - [Rust SDK](../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. diff --git a/docs/clients/c.md b/docs/clients/c.md index 4d1222d3..f0a99460 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -290,5 +290,6 @@ cmake --build build --config Release - [Generator Documentation](generator.md): How the C SDK is generated - [Go Client Documentation](go.md): Reference implementation - [C# SDK Documentation](csharp.md): .NET SDK +- [Rust SDK Documentation](rust.md): Rust SDK - [TypeScript SDK Documentation](typescript.md): Node.js SDK - [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index a9847319..319836d1 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -504,6 +504,7 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C - [Generator Documentation](generator.md): How generated SDKs work - [Go SDK Documentation](go.md): Reference implementation patterns +- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support - [TypeScript SDK Documentation](typescript.md): Node.js SDK - [C SDK Documentation](c.md): Alternative SDK for native integration - [API Overview](../api/overview.md): Management API reference diff --git a/docs/clients/go.md b/docs/clients/go.md index 5cb58de1..2c2547a2 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -232,5 +232,6 @@ Full working examples are available in the repository: - [Generator Documentation](generator.md): How generated SDKs work - [C SDK Documentation](c.md): Generated C SDK usage - [C# SDK Documentation](csharp.md): .NET SDK +- [Rust SDK Documentation](rust.md): Rust SDK - [TypeScript SDK Documentation](typescript.md): Node.js SDK - [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..f3d353ca --- /dev/null +++ b/docs/clients/rust.md @@ -0,0 +1,580 @@ +# Rust SDK Documentation + +The VIIPER Rust SDK provides a type-safe, zero-cost abstraction client library for interacting with VIIPER servers and controlling virtual devices. + +## Overview + +The Rust SDK 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 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 + +### 1. Using the Published Crate (Recommended) + +Install the SDK 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" } +``` + +### 3. Generating from Source (Advanced / Contributors) + +Only required when enhancing VIIPER itself: + +```bash +go run ./cmd/viiper codegen --lang=rust +cd clients/rust +cargo build --release +``` + +## Quick Start (Sync) + +```rust +use viiper_client::{ViiperClient, devices::keyboard::*}; + +fn main() { + // Create new Viiper client + let client = ViiperClient::new("localhost", 3242); + + // 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, + }, + ).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)); +} +``` + +## Quick Start (Async) + +```rust +use tokio::time::{sleep, Duration}; +use viiper_client::{AsyncViiperClient, devices::keyboard::*}; + +#[tokio::main] +async fn main() { + // Create new Viiper client + let client = AsyncViiperClient::new("localhost", 3242); + + // 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, + }, + ).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 Stream API + +### Creating a Device Stream (Sync) + +```rust +use viiper_client::{ViiperClient, types::DeviceCreateRequest}; + +let client = ViiperClient::new("localhost", 3242); + +// 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"); +``` + +With custom VID/PID: + +```rust +let device_info = client.bus_device_add( + bus_id, + &DeviceCreateRequest { + r#type: Some("keyboard".to_string()), + id_vendor: Some(0x1234), + id_product: Some(0x5678), + }, +).expect("Failed to add device"); +``` + +### 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 Output (Callbacks) + +For devices that send feedback (rumble, LEDs), register a callback with `on_output`: + +**Sync API:** + +```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"); +``` + +**Async API:** + +```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| { + 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 SDK 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 SDK 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 +``` + +## Practical Example: Typing Text + +Using the generated maps to type a string: + +```rust +use std::thread; +use std::time::Duration; +use viiper_client::{DeviceStream, devices::keyboard::*}; + +fn type_string(stream: &mut DeviceStream, text: &str) -> Result<(), viiper_client::ViiperError> { + for ch in text.chars() { + let byte = ch as u8; + let key = match CHAR_TO_KEY.get(&byte) { + Some(&k) => k, + None => continue, + }; + + let mods = if SHIFT_CHARS.contains(&byte) { + MOD_LEFT_SHIFT + } else { + 0 + }; + + // Press + let down = KeyboardInput { + modifiers: mods, + count: 1, + keys: vec![key], + }; + stream.send(&down)?; + thread::sleep(Duration::from_millis(50)); + + // Release + let up = KeyboardInput { + modifiers: 0, + count: 0, + keys: vec![], + }; + stream.send(&up)?; + thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +// Usage +type_string(&mut stream, "Hello, World!")?; +``` + +## Device-Specific Wire Formats + +### Keyboard Input + +```rust +pub struct KeyboardInput { + pub modifiers: u8, // Modifier flags (Ctrl, Shift, Alt, GUI) + pub count: u8, // Number of keys in keys vec + pub keys: Vec, // Key codes (max 6 for HID compliance) +} +``` + +**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) + +### Keyboard Output (LEDs) + +```rust +// Single byte with LED flags +let leds = buf[0]; +let num_lock = (leds & LED_NUM_LOCK) != 0; +``` + +### Xbox360 Input + +```rust +pub struct Xbox360Input { + pub buttons: u32, // Button flags + pub lt: u8, // Left trigger (0-255) + pub rt: u8, // Right trigger (0-255) + pub lx: i16, // Left stick X (-32768 to 32767) + pub ly: i16, // Left stick Y (-32768 to 32767) + pub rx: i16, // Right stick X (-32768 to 32767) + pub ry: i16, // Right stick Y (-32768 to 32767) +} +``` + +**Wire format:** Fixed 14 bytes, packed structure (little-endian) + +### Xbox360 Output (Rumble) + +```rust +// Two bytes: left motor + right motor (0-255 each) +let left_motor = buf[0]; +let right_motor = buf[1]; +``` + +### Mouse Input + +```rust +pub struct MouseInput { + pub buttons: u8, // Button flags + pub dx: i8, // Relative X movement (-128 to 127) + pub dy: i8, // Relative Y movement (-128 to 127) + pub wheel: i8, // Vertical scroll + pub pan: i8, // Horizontal scroll +} +``` + +**Wire format:** Fixed 5 bytes, packed structure + +## Error Handling + +The SDK 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 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 SDK 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 + +### Running Examples + +```bash +cd examples/rust + +# Sync examples +cargo run --release -p virtual_keyboard_sync -- localhost:3242 +cargo run --release -p virtual_mouse_sync -- localhost:3242 +cargo run --release -p virtual_x360_pad_sync -- localhost:3242 + +# Async examples +cargo run --release -p virtual_keyboard_async -- localhost:3242 +cargo run --release -p virtual_mouse_async -- localhost:3242 +cargo run --release -p virtual_x360_pad_async -- localhost:3242 +``` + +## Project Structure + +Generated SDK layout: + +```text +clients/rust/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Re-exports +│ ├── client.rs # Sync ViiperClient + DeviceStream +│ ├── async_client.rs # Async ViiperClient (feature = "async") +│ ├── error.rs # ViiperError, ProblemJson +│ ├── types.rs # API request/response types +│ ├── wire.rs # DeviceInput/DeviceOutput traits +│ └── devices/ +│ ├── mod.rs +│ ├── keyboard/ +│ │ ├── mod.rs +│ │ ├── input.rs # KeyboardInput struct +│ │ ├── output.rs # Output parsing +│ │ └── constants.rs # Keys, mods, LEDs, maps +│ ├── mouse/ +│ │ └── ... +│ └── xbox360/ +│ └── ... +``` + +## Troubleshooting + +**Connection refused:** + +Verify VIIPER server is running and listening on the expected API port (default 3242). + +```rust +let client = ViiperClient::new("127.0.0.1", 3242); +``` + +**Feature not found errors:** + +Make sure to enable the `async` feature if using `AsyncViiperClient`: + +```toml +viiper-client = { version = "0.1", features = ["async"] } +``` + +## See Also + +- [Generator Documentation](generator.md): How generated SDKs work +- [Go SDK Documentation](go.md): Reference implementation patterns +- [C# SDK Documentation](csharp.md): Alternative managed language SDK +- [TypeScript SDK Documentation](typescript.md): Node.js SDK +- [C SDK Documentation](c.md): Native C SDK +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details + +--- + +For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index 9d023a9b..5c23e8fe 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -515,6 +515,7 @@ Some quick troubleshooting tips for the TypeScript SDK and device streams: - [Generator Documentation](generator.md): How generated SDKs work - [Go SDK Documentation](go.md): Reference implementation patterns - [C# SDK Documentation](csharp.md): Alternative managed language SDK +- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support - [C SDK Documentation](c.md): Alternative SDK for native integration - [API Overview](../api/overview.md): Management API reference - [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/examples/.gitignore b/examples/.gitignore index d1638636..a3969ec2 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1 +1,2 @@ -build/ \ No newline at end of file +build/ +target/ \ No newline at end of file diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock new file mode 100644 index 00000000..dca00c89 --- /dev/null +++ b/examples/rust/Cargo.lock @@ -0,0 +1,444 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[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 = "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 = "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.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[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 = "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 = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[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 = "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 = "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 = "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 = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "viiper-client" +version = "0.0.1-dev" +dependencies = [ + "lazy_static", + "serde", + "serde_json", + "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" 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..ffaea35b --- /dev/null +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -0,0 +1,175 @@ +use tokio::time::{sleep, Duration}; +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = AsyncViiperClient::new(host, port); + + // 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, + }).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_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..95912978 --- /dev/null +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -0,0 +1,163 @@ +use tokio::time::{sleep, Duration}; +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = AsyncViiperClient::new(host, port); + + // 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, + }).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 + 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..37b05176 --- /dev/null +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -0,0 +1,133 @@ +use tokio::time::Duration; +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = AsyncViiperClient::new(host, port); + + // 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, + }).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_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, + }; + + 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..88e31350 --- /dev/null +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -0,0 +1,185 @@ +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = ViiperClient::new(host, port); + + // 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, + }, + ) { + 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_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..3135381b --- /dev/null +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -0,0 +1,168 @@ +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = ViiperClient::new(host, port); + + // 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, + }, + ) { + 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 + + 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..1c0fd4b2 --- /dev/null +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -0,0 +1,138 @@ +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 = &args[1]; + let parts: Vec<&str> = addr.split(':').collect(); + let host = parts[0]; + let port = if parts.len() > 1 { + parts[1].parse().unwrap_or(3242) + } else { + 3242 + }; + + let client = ViiperClient::new(host, port); + + // 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, + }, + ) { + 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_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, + }; + + 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/internal/cmd/codegen.go b/internal/cmd/codegen.go index 7eb80b14..09a1ce43 100644 --- a/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -8,7 +8,7 @@ import ( 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"` + Lang string `help:"Target language: c, csharp, rust, typescript, or 'all'" default:"all" enum:"c,csharp,rust,typescript,all" env:"VIIPER_CODEGEN_LANG"` } // Run is called by Kong when the codegen command is executed. diff --git a/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go index 50f8dab9..1c1a6e36 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -89,7 +89,7 @@ VIIPER_API void viiper_free_{{snakecase .Name}}(viiper_{{snakecase .Name}}_t* v) * Client API * ======================================================================== */ -/* Create a VIIPER client handle (no persistent connection for management API) */ +/* Create a VIIPER client handle */ VIIPER_API viiper_error_t viiper_client_create( const char* host, uint16_t port, diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go index abbc768c..3428aa1c 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -18,7 +18,7 @@ namespace Viiper.Client.Types; {{range .DTOs}} /// -/// {{.Name}} DTO for management API +/// {{.Name}} DTO /// public class {{.Name}} { diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index f092d67a..b1bfd675 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -8,6 +8,7 @@ import ( cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" "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" @@ -25,6 +26,7 @@ type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Meta var generators = map[string]LanguageGenerator{ "c": cgen.Generate, "csharp": csharp.Generate, + "rust": rust.Generate, "typescript": typescript.Generate, } diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go new file mode 100644 index 00000000..c805ee5a --- /dev/null +++ b/internal/codegen/generator/rust/async_client.go @@ -0,0 +1,234 @@ +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 tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; + +/// VIIPER management API client (asynchronous). +#[cfg(feature = "async")] +pub struct AsyncViiperClient { + addr: String, +} + +#[cfg(feature = "async")] +impl AsyncViiperClient { + /// Create a new async VIIPER client connecting to the specified host and port. + pub fn new(host: impl Into, port: u16) -> Self { + Self { + addr: format!("{}:{}", host.into(), port), + } + } + + async fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let mut stream = TcpStream::connect(&self.addr).await?; + + 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).await + } +} + +/// An async connected device stream for bidirectional communication. +/// Uses split read/write halves to allow simultaneous sending and receiving. +#[cfg(feature = "async")] +pub struct AsyncDeviceStream { + reader: std::sync::Arc>, + writer: std::sync::Arc>, + cancel_token: Option, +} + +#[cfg(feature = "async")] +impl AsyncDeviceStream { + pub async fn connect(addr: &str, bus_id: u32, dev_id: &str) -> Result { + let mut stream = TcpStream::connect(addr).await?; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.write_all(handshake.as_bytes()).await?; + let (reader, writer) = stream.into_split(); + Ok(Self { + reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), + writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), + cancel_token: None, + }) + } + + /// Send a device input to the device. + pub async fn send( + &self, + input: &T, + ) -> Result<(), ViiperError> { + let bytes = input.to_bytes(); + let mut writer = self.writer.lock().await; + writer.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 writer = self.writer.lock().await; + tokio::time::timeout(timeout, writer.write_all(&bytes)) + .await + .map_err(|_| ViiperError::Timeout)? + .map_err(Into::into) + } + + /// Register a callback to receive device output asynchronously. + /// The callback receives the read half of the stream 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 reader = self.reader.clone(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let cancel_clone = cancel_token.clone(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel_clone.cancelled() => break, + result = callback(reader.clone()) => { + match result { + Ok(()) => continue, + Err(_) => break, + } + } + } + } + }); + self.cancel_token = Some(cancel_token); + Ok(()) + } + + /// Send raw bytes to the device. + pub async fn send_raw(&self, data: &[u8]) -> Result<(), ViiperError> { + let mut writer = self.writer.lock().await; + writer.write_all(data).await?; + Ok(()) + } + + /// Read raw bytes from the device. + pub async fn read_raw(&self, buf: &mut [u8]) -> Result { + let mut reader = self.reader.lock().await; + reader.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 reader = self.reader.lock().await; + reader.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() + + 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/client.go b/internal/codegen/generator/rust/client.go new file mode 100644 index 00000000..0d797e1b --- /dev/null +++ b/internal/codegen/generator/rust/client.go @@ -0,0 +1,191 @@ +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::TcpStream; + +/// VIIPER management API client (synchronous). +pub struct ViiperClient { + addr: String, +} + +impl ViiperClient { + /// Create a new VIIPER client connecting to the specified host and port. + pub fn new(host: impl Into, port: u16) -> Self { + Self { + addr: format!("{}:{}", host.into(), port), + } + } + + fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let mut stream = TcpStream::connect(&self.addr)?; + + 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) + } +} + +/// A connected device stream for bidirectional communication. +pub struct DeviceStream { + stream: TcpStream, + output_thread: Option>, +} + +impl DeviceStream { + pub fn connect(addr: &str, bus_id: u32, dev_id: &str) -> Result { + let mut stream = TcpStream::connect(addr)?; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.write_all(handshake.as_bytes())?; + Ok(Self { + stream, + output_thread: 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 handle = std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(stream); + loop { + match callback(&mut reader) { + Ok(()) => continue, + Err(_) => break, + } + } + }); + self.output_thread = Some(handle); + 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() + + 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..9d9564ae --- /dev/null +++ b/internal/codegen/generator/rust/constants.go @@ -0,0 +1,231 @@ +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" +) + +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 { + rustType := goTypeToRust(c.Type) + value := formatConstValue(c.Value, c.Type) + constants = append(constants, rustConstant{ + Name: c.Name, + RustType: rustType, + Value: value, + }) + } + + // Generate maps + 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) + } + } + + // Determine which collections to import + 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() + + 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 formatConstValue(val interface{}, goType string) string { + base, _, _ := common.NormalizeGoType(goType) + + switch base { + case "string": + return fmt.Sprintf(`"%v"`, val) + default: + return fmt.Sprintf("%v", val) + } +} + +func formatMapKeyRust(key string, goType string) string { + switch goType { + case "byte", "uint8": + // Handle escape sequences + 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" + } + } + // Single character + 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": + // Check if it's a constant reference + if str, ok := value.(string); ok { + // Use the constant directly + 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_types.go b/internal/codegen/generator/rust/device_types.go new file mode 100644 index 00000000..e22b3c58 --- /dev/null +++ b/internal/codegen/generator/rust/device_types.go @@ -0,0 +1,180 @@ +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" +) + +const deviceInputTemplate = `{{.Header}} +use crate::wire::DeviceInput; + +#[derive(Debug, Clone, Default)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{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, Default)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{end}}} + +impl DeviceOutput for {{.StructName}} { + fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; +{{range .Fields}}{{if .IsArray}} // Read array (size determined by remaining bytes) + 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; + } +{{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 +} + +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 + + isArray := isWireArray(field.Spec) + var countName string + var elemType string + + if isArray { + countName = extractArrayCount(field.Spec) + idx := strings.Index(wireType, "*") + if idx > 0 { + baseType = wireType[:idx] + } + elemType = wireTypeToRust(baseType) + } + + rustType := wireTypeToRust(baseType) + if isArray { + rustType = fmt.Sprintf("Vec<%s>", elemType) + } + + fields = append(fields, rustWireField{ + Name: field.Name, + RustName: rustName, + RustType: rustType, + ElementType: elemType, + IsArray: isArray, + CountName: countName, + }) + } + + data := deviceTypeData{ + Header: writeFileHeaderRust(), + DeviceName: deviceName, + StructName: deviceName + className, + Fields: fields, + } + + funcMap := template.FuncMap{} + 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() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/internal/codegen/generator/rust/error.go b/internal/codegen/generator/rust/error.go new file mode 100644 index 00000000..83b08893 --- /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..458b9f24 --- /dev/null +++ b/internal/codegen/generator/rust/gen.go @@ -0,0 +1,189 @@ +// Package rust provides code generation for the Rust SDK. +package rust + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +// Generate produces the Rust SDK layout under outputDir. +// It creates a Cargo workspace with the following structure: +// - Cargo.toml +// - src/lib.rs (public exports) +// - src/error.rs +// - src/types.rs (management API DTOs) +// - src/client.rs (sync API) +// - src/async_client.rs (async API, feature-gated) +// - src/devices//{mod.rs, input.rs, output.rs, constants.rs} +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 + } + + // Generate device modules + 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 := generateDeviceModFile(logger, deviceDir, deviceName, md); err != nil { + return err + } + } + + // Generate devices/mod.rs + if err := generateDevicesModFile(logger, devicesDir, md); err != nil { + return err + } + + // Generate lib.rs + 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 SDK", "dir", projectDir) + return nil +} + +func generateLibFile(logger *slog.Logger, srcDir string, md *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; + +#[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() + + // Check what files exist + 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) + + 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 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..4d595574 --- /dev/null +++ b/internal/codegen/generator/rust/helpers.go @@ -0,0 +1,165 @@ +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) + + 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.ToPascalCase(base) + } + + if isSlice { + rustType = fmt.Sprintf("Vec<%s>", rustType) + } + if isPointer { + rustType = fmt.Sprintf("Option<%s>", rustType) + } + + return rustType +} + +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 isWireArray(spec string) bool { + return strings.Contains(spec, "*") +} + +func extractArrayCount(spec string) string { + parts := strings.Split(spec, "*") + if len(parts) == 2 { + return parts[1] + } + return "" +} + +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"`, 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"`, 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..7bb3c498 --- /dev/null +++ b/internal/codegen/generator/rust/project.go @@ -0,0 +1,101 @@ +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 SDK 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" + +[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() + + 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..61de01ca --- /dev/null +++ b/internal/codegen/generator/rust/types.go @@ -0,0 +1,139 @@ +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) + // Escape Rust keywords + 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() + + 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 == "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..21413165 --- /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() + + 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/types.go b/internal/codegen/generator/typescript/types.go index 2130368e..9571fc94 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -17,7 +17,7 @@ const dtoTemplateTS = `{{writeFileHeaderTS}} // Management API DTO interfaces {{range .DTOs}} -// {{.Name}} DTO for management API +// {{.Name}} DTO export interface {{.Name}} { {{- range .Fields}} {{.JSONName}}{{if .Optional}}?{{end}}: {{fieldTypeToTS .}}; diff --git a/mkdocs.yml b/mkdocs.yml index 1b4e3675..3db3da28 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Generator Documentation: clients/generator.md - C SDK: clients/c.md - C# SDK: clients/csharp.md + - Rust SDK: clients/rust.md - TypeScript SDK: clients/typescript.md - Devices: - Xbox 360 Controller: devices/xbox360.md From b0386de38698317368aee4dab5d7fcf77e0d9a42 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 19:49:29 +0100 Subject: [PATCH 044/298] CI: Parallelize sdk gen/builds --- .github/workflows/clients_ci.yml | 158 +++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 49 deletions(-) diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index f5fcc45d..ca6b4e84 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -25,8 +25,8 @@ permissions: contents: read jobs: - sdk: - name: Codegen and smoke builds + codegen: + name: Code generation runs-on: ubuntu-latest steps: - name: Checkout @@ -37,8 +37,7 @@ jobs: with: go-version: stable cache: true - cache-dependency-path: | - go.sum + cache-dependency-path: go.sum - name: Run code generation shell: bash @@ -52,6 +51,27 @@ jobs: go run ./cmd/viiper codegen fi + - name: Upload generated clients + uses: actions/upload-artifact@v4 + with: + name: generated-clients + path: clients/ + retention-days: 1 + + typescript: + name: TypeScript SDK + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -74,12 +94,44 @@ jobs: npm install npm run build + - name: Rename TypeScript SDK tarball + if: ${{ inputs.upload_artifacts }} + working-directory: clients/typescript + run: | + for file in viiperclient-*.tgz; do + if [ -f "$file" ]; then + mv "$file" "viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz" + fi + done + + - name: Upload TypeScript SDK tarball + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: typescript-sdk${{ inputs.artifact_suffix }} + path: clients/typescript/viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz + if-no-files-found: error + + csharp: + name: C# SDK + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + - name: Set up .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - - name: Pack C# SDK (smoke) + - name: Pack C# SDK run: dotnet pack clients/csharp/Viiper.Client/Viiper.Client.csproj -c Release -o artifacts/nuget - name: Build C# examples (smoke) @@ -88,12 +140,34 @@ jobs: dotnet build examples/csharp/virtual_mouse/VirtualMouse.csproj -c Release dotnet build examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj -c Release + - name: Upload C# SDK nupkg + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: csharp-sdk-nupkg${{ inputs.artifact_suffix }} + path: artifacts/nuget/*.nupkg + if-no-files-found: error + + c-sdk: + name: C SDK + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + - name: Set up CMake uses: jwlawson/actions-setup-cmake@v2 with: cmake-version: '3.26.x' - - name: Build C SDK (smoke) + - name: Build C SDK run: | cmake -S clients/c -B clients/c/build cmake --build clients/c/build --config Release --parallel @@ -103,10 +177,36 @@ jobs: cmake -S examples/c -B examples/c/build cmake --build examples/c/build --config Release --parallel + - name: Archive C SDK source + if: ${{ inputs.upload_artifacts }} + run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . + + - name: Upload C SDK source + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: c-sdk-source${{ inputs.artifact_suffix }} + path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz + if-no-files-found: error + + rust: + name: Rust SDK + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + - name: Set up Rust uses: dtolnay/rust-toolchain@stable - - name: Build Rust SDK (smoke) + - name: Build Rust SDK working-directory: clients/rust run: | cargo build --release @@ -114,51 +214,11 @@ jobs: - name: Package Rust SDK working-directory: clients/rust - run: | - cargo package --allow-dirty --no-verify + run: cargo package --allow-dirty --no-verify - name: Build Rust examples (smoke) working-directory: examples/rust - run: | - cargo build --release - - - name: Rename TypeScript SDK tarball - if: ${{ inputs.upload_artifacts }} - working-directory: clients/typescript - run: | - for file in viiperclient-*.tgz; do - if [ -f "$file" ]; then - mv "$file" "viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz" - fi - done - - - name: Upload TypeScript SDK tarball - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: typescript-sdk${{ inputs.artifact_suffix }} - path: clients/typescript/viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz - if-no-files-found: error - - - name: Upload C# SDK nupkg - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: csharp-sdk-nupkg${{ inputs.artifact_suffix }} - path: artifacts/nuget/*.nupkg - if-no-files-found: error - - - name: Archive C SDK source - if: ${{ inputs.upload_artifacts }} - run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . - - - name: Upload C SDK source - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: c-sdk-source${{ inputs.artifact_suffix }} - path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz - if-no-files-found: error + run: cargo build --release - name: Rename Rust SDK crate if: ${{ inputs.upload_artifacts }} From 64fe23d0cfa7bb4516e0bb023a00e50754ba7452 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 21:54:00 +0100 Subject: [PATCH 045/298] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 79fbd9e7..a7f7022a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ [![npm downloads](https://img.shields.io/npm/dm/viiperclient?logo=npm&label=downloads)](https://www.npmjs.com/package/viiperclient) [![NuGet version](https://img.shields.io/nuget/v/Viiper.Client?logo=nuget)](https://www.nuget.org/packages/Viiper.Client/) [![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) +[![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) +[![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) [![C SDK](https://img.shields.io/badge/C_SDK-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) From cfce67f739e905f053ee13c3b69efc6f562b92aa Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 30 Nov 2025 22:39:07 +0100 Subject: [PATCH 046/298] Rust-Client: Use std::net::SocketAddr --- .../rust/async/virtual_keyboard/src/main.rs | 14 ++++------- examples/rust/async/virtual_mouse/src/main.rs | 14 ++++------- .../rust/async/virtual_x360_pad/src/main.rs | 14 ++++------- .../rust/sync/virtual_keyboard/src/main.rs | 14 ++++------- examples/rust/sync/virtual_mouse/src/main.rs | 14 ++++------- .../rust/sync/virtual_x360_pad/src/main.rs | 14 ++++------- .../codegen/generator/rust/async_client.go | 17 ++++++------- internal/codegen/generator/rust/client.go | 25 +++++++------------ internal/codegen/generator/rust/helpers.go | 4 +-- 9 files changed, 49 insertions(+), 81 deletions(-) diff --git a/examples/rust/async/virtual_keyboard/src/main.rs b/examples/rust/async/virtual_keyboard/src/main.rs index ffaea35b..a58f703c 100644 --- a/examples/rust/async/virtual_keyboard/src/main.rs +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -10,16 +10,12 @@ async fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = AsyncViiperClient::new(host, port); + let client = AsyncViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list().await { diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs index 95912978..b59e30de 100644 --- a/examples/rust/async/virtual_mouse/src/main.rs +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -10,16 +10,12 @@ async fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = AsyncViiperClient::new(host, port); + let client = AsyncViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list().await { diff --git a/examples/rust/async/virtual_x360_pad/src/main.rs b/examples/rust/async/virtual_x360_pad/src/main.rs index 37b05176..3e5057db 100644 --- a/examples/rust/async/virtual_x360_pad/src/main.rs +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -10,16 +10,12 @@ async fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = AsyncViiperClient::new(host, port); + let client = AsyncViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list().await { diff --git a/examples/rust/sync/virtual_keyboard/src/main.rs b/examples/rust/sync/virtual_keyboard/src/main.rs index 88e31350..bb9840de 100644 --- a/examples/rust/sync/virtual_keyboard/src/main.rs +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -10,16 +10,12 @@ fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = ViiperClient::new(host, port); + let client = ViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list() { diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs index 3135381b..23a13549 100644 --- a/examples/rust/sync/virtual_mouse/src/main.rs +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -10,16 +10,12 @@ fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = ViiperClient::new(host, port); + let client = ViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list() { diff --git a/examples/rust/sync/virtual_x360_pad/src/main.rs b/examples/rust/sync/virtual_x360_pad/src/main.rs index 1c0fd4b2..bf030ade 100644 --- a/examples/rust/sync/virtual_x360_pad/src/main.rs +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -10,16 +10,12 @@ fn main() { std::process::exit(1); } - let addr = &args[1]; - let parts: Vec<&str> = addr.split(':').collect(); - let host = parts[0]; - let port = if parts.len() > 1 { - parts[1].parse().unwrap_or(3242) - } else { - 3242 - }; + let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { + eprintln!("Invalid address '{}': {}", args[1], e); + std::process::exit(1); + }); - let client = ViiperClient::new(host, port); + let client = ViiperClient::new(addr); // Find or create a bus let (bus_id, created_bus) = match client.bus_list() { diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index c805ee5a..9e0650c1 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -15,6 +15,7 @@ import ( const asyncClientTemplate = `{{.Header}} use crate::error::{ProblemJson, ViiperError}; use crate::types::*; +use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; @@ -22,16 +23,14 @@ use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; /// VIIPER management API client (asynchronous). #[cfg(feature = "async")] pub struct AsyncViiperClient { - addr: String, + addr: SocketAddr, } #[cfg(feature = "async")] impl AsyncViiperClient { - /// Create a new async VIIPER client connecting to the specified host and port. - pub fn new(host: impl Into, port: u16) -> Self { - Self { - addr: format!("{}:{}", host.into(), port), - } + /// Create a new async VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr } } async fn do_request serde::Deserialize<'de>>( @@ -39,7 +38,7 @@ impl AsyncViiperClient { path: &str, payload: Option<&str>, ) -> Result { - let mut stream = TcpStream::connect(&self.addr).await?; + let mut stream = TcpStream::connect(self.addr).await?; stream.write_all(path.as_bytes()).await?; if let Some(p) = payload { @@ -74,7 +73,7 @@ impl AsyncViiperClient { {{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).await + AsyncDeviceStream::connect(self.addr, bus_id, dev_id).await } } @@ -89,7 +88,7 @@ pub struct AsyncDeviceStream { #[cfg(feature = "async")] impl AsyncDeviceStream { - pub async fn connect(addr: &str, bus_id: u32, dev_id: &str) -> Result { + pub async fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { let mut stream = TcpStream::connect(addr).await?; let handshake = format!("bus/{}/{}\0", bus_id, dev_id); stream.write_all(handshake.as_bytes()).await?; diff --git a/internal/codegen/generator/rust/client.go b/internal/codegen/generator/rust/client.go index 0d797e1b..787590ee 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -16,19 +16,17 @@ const clientTemplate = `{{.Header}} use crate::error::{ProblemJson, ViiperError}; use crate::types::*; use std::io::{Read, Write}; -use std::net::TcpStream; +use std::net::{SocketAddr, TcpStream}; /// VIIPER management API client (synchronous). pub struct ViiperClient { - addr: String, + addr: SocketAddr, } impl ViiperClient { - /// Create a new VIIPER client connecting to the specified host and port. - pub fn new(host: impl Into, port: u16) -> Self { - Self { - addr: format!("{}:{}", host.into(), port), - } + /// Create a new VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr } } fn do_request serde::Deserialize<'de>>( @@ -36,7 +34,7 @@ impl ViiperClient { path: &str, payload: Option<&str>, ) -> Result { - let mut stream = TcpStream::connect(&self.addr)?; + let mut stream = TcpStream::connect(self.addr)?; stream.write_all(path.as_bytes())?; if let Some(p) = payload { @@ -71,7 +69,7 @@ impl ViiperClient { {{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) + DeviceStream::connect(self.addr, bus_id, dev_id) } } @@ -82,7 +80,7 @@ pub struct DeviceStream { } impl DeviceStream { - pub fn connect(addr: &str, bus_id: u32, dev_id: &str) -> Result { + pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { let mut stream = TcpStream::connect(addr)?; let handshake = format!("bus/{}/{}\0", bus_id, dev_id); stream.write_all(handshake.as_bytes())?; @@ -114,12 +112,7 @@ impl DeviceStream { let stream = self.stream.try_clone()?; let handle = std::thread::spawn(move || { let mut reader = std::io::BufReader::new(stream); - loop { - match callback(&mut reader) { - Ok(()) => continue, - Err(_) => break, - } - } + while callback(&mut reader).is_ok() {} }); self.output_thread = Some(handle); Ok(()) diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go index 4d595574..dc4853ab 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -127,7 +127,7 @@ func generateMethodParamsRust(route scanner.RouteInfo) string { func generatePathRust(route scanner.RouteInfo) string { path := route.Path if len(route.PathParams) == 0 { - return fmt.Sprintf(`"%s"`, path) + return fmt.Sprintf(`"%s".to_string()`, path) } var formatStr string @@ -143,7 +143,7 @@ func generatePathRust(route scanner.RouteInfo) string { if len(args) > 0 { return fmt.Sprintf(`format!("%s", %s)`, formatStr, strings.Join(args, ", ")) } - return fmt.Sprintf(`"%s"`, path) + return fmt.Sprintf(`"%s".to_string()`, path) } func generatePayloadRust(route scanner.RouteInfo) string { From 9e7344ec0299a31ebcb4b988c83e5faf8631cdab Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Dec 2025 03:03:20 +0100 Subject: [PATCH 047/298] SDKs: Add disconnect callback Changelog(feat) --- README.md | 2 +- examples/c/virtual_keyboard/main.c | 9 +++++++++ examples/c/virtual_x360_pad/main.c | 9 +++++++++ examples/csharp/virtual_keyboard/Program.cs | 3 +++ examples/csharp/virtual_mouse/Program.cs | 3 +++ examples/csharp/virtual_x360_pad/Program.cs | 3 +++ .../rust/async/virtual_keyboard/src/main.rs | 5 +++++ .../rust/async/virtual_x360_pad/src/main.rs | 5 +++++ examples/rust/sync/virtual_keyboard/src/main.rs | 7 +++++++ examples/rust/sync/virtual_x360_pad/src/main.rs | 7 +++++++ internal/codegen/generator/c/header_common.go | 10 ++++++++++ internal/codegen/generator/c/source_common.go | 17 +++++++++++++++++ internal/codegen/generator/csharp/device.go | 12 ++++++++++-- internal/codegen/generator/rust/async_client.go | 14 ++++++++++++++ internal/codegen/generator/rust/client.go | 14 ++++++++++++++ 15 files changed, 117 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a7f7022a..8ea34005 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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 (C, C#, Typescript/Javascript) for easy integration; see [Client SDKs](docs/api/overview.md) +- ✅ Multiple client SDKs for easy integration; see [Client SDKs](docs/api/overview.md) MIT Licensed ## 🔌 Requirements diff --git a/examples/c/virtual_keyboard/main.c b/examples/c/virtual_keyboard/main.c index a46c0d91..821786bf 100644 --- a/examples/c/virtual_keyboard/main.c +++ b/examples/c/virtual_keyboard/main.c @@ -42,6 +42,12 @@ static uint32_t choose_or_create_bus(viiper_client_t* client) return 0; } +static void on_disconnect(void* user) +{ + (void)user; /* unused */ + printf("!!! Server disconnected\n"); +} + static void on_leds(void* buffer, size_t bytes_read, void* user) { const uint8_t* led_data = (const uint8_t*)buffer; @@ -139,6 +145,9 @@ int main(int argc, char** argv) static uint8_t led_buffer[VIIPER_KEYBOARD_OUTPUT_SIZE]; viiper_device_on_output(dev, led_buffer, sizeof(led_buffer), on_leds, NULL); + /* Register disconnect callback */ + viiper_device_on_disconnect(dev, on_disconnect, NULL); + printf("Every 5s: type 'Hello!' + Enter. Press Ctrl+C to stop.\n"); for (;;) { type_string_hello(dev); diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c index 33b4223a..560cb08b 100644 --- a/examples/c/virtual_x360_pad/main.c +++ b/examples/c/virtual_x360_pad/main.c @@ -55,6 +55,12 @@ static uint32_t choose_or_create_bus(viiper_client_t* client) return 0; } +static void on_disconnect(void* user) +{ + (void)user; /* unused */ + printf("!!! Server disconnected\n"); +} + static void on_rumble(void* buffer, size_t bytes_read, void* user) { (void)user; /* unused */ @@ -124,6 +130,9 @@ int main(int argc, char** argv) /* Register async backchannel callback with user-allocated buffer */ static uint8_t rumble_buffer[VIIPER_XBOX360_OUTPUT_SIZE]; viiper_device_on_output(dev, rumble_buffer, sizeof(rumble_buffer), on_rumble, NULL); + + /* Register disconnect callback */ + viiper_device_on_disconnect(dev, on_disconnect, NULL); printf("Connected to device stream\n"); unsigned long long frame = 0; diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs index a67f96fa..fcdd75c5 100644 --- a/examples/csharp/virtual_keyboard/Program.cs +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -84,6 +84,9 @@ async Task Cleanup() 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)); diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs index 3d21670d..7fd699c6 100644 --- a/examples/csharp/virtual_mouse/Program.cs +++ b/examples/csharp/virtual_mouse/Program.cs @@ -59,6 +59,9 @@ async Task Cleanup() 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)); sbyte dir = 1; const sbyte step = 50; diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs index 0f00933d..b27ebdff 100644 --- a/examples/csharp/virtual_x360_pad/Program.cs +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -69,6 +69,9 @@ async Task Cleanup() 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; diff --git a/examples/rust/async/virtual_keyboard/src/main.rs b/examples/rust/async/virtual_keyboard/src/main.rs index a58f703c..43bad797 100644 --- a/examples/rust/async/virtual_keyboard/src/main.rs +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -73,6 +73,11 @@ async fn main() { 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]; diff --git a/examples/rust/async/virtual_x360_pad/src/main.rs b/examples/rust/async/virtual_x360_pad/src/main.rs index 3e5057db..ad235b3d 100644 --- a/examples/rust/async/virtual_x360_pad/src/main.rs +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -73,6 +73,11 @@ async fn main() { 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]; diff --git a/examples/rust/sync/virtual_keyboard/src/main.rs b/examples/rust/sync/virtual_keyboard/src/main.rs index bb9840de..934efc9e 100644 --- a/examples/rust/sync/virtual_keyboard/src/main.rs +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -77,6 +77,13 @@ fn main() { 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]; diff --git a/examples/rust/sync/virtual_x360_pad/src/main.rs b/examples/rust/sync/virtual_x360_pad/src/main.rs index bf030ade..30c733e4 100644 --- a/examples/rust/sync/virtual_x360_pad/src/main.rs +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -77,6 +77,13 @@ fn main() { 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]; diff --git a/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go index 1c1a6e36..4f308634 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -122,6 +122,9 @@ VIIPER_API viiper_error_t viiper_{{snakecase .Handler}}( /* Callback type for receiving device output */ typedef void (*viiper_output_cb)(void* buffer, size_t bytes_read, void* user_data); +/* Callback type for disconnect notification */ +typedef void (*viiper_disconnect_cb)(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, @@ -147,6 +150,13 @@ VIIPER_API void viiper_device_on_output( void* user_data ); +/* Register callback for disconnect notification (called when connection is lost) */ +VIIPER_API void viiper_device_on_disconnect( + viiper_device_t* device, + viiper_disconnect_cb callback, + void* user_data +); + /* Close device stream and free resources */ VIIPER_API void viiper_device_close(viiper_device_t* device); diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go index 18192119..77a7de0f 100644 --- a/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -50,6 +50,9 @@ struct viiper_device { size_t output_buffer_size; viiper_output_cb callback; void* callback_user; + /* Disconnect callback */ + viiper_disconnect_cb disconnect_callback; + void* disconnect_user; int running; #if defined(_WIN32) || defined(_WIN64) HANDLE recv_thread; @@ -431,6 +434,10 @@ static void* viiper_device_receiver_thread(void* arg) { } #endif } + /* Call disconnect callback if registered */ + if (dev->disconnect_callback) { + dev->disconnect_callback(dev->disconnect_user); + } #if defined(_WIN32) || defined(_WIN64) return 0; #else @@ -532,6 +539,16 @@ VIIPER_API void viiper_device_on_output( device->callback_user = user_data; } +VIIPER_API void viiper_device_on_disconnect( + viiper_device_t* device, + viiper_disconnect_cb callback, + void* user_data +) { + if (!device) return; + device->disconnect_callback = callback; + device->disconnect_user = user_data; +} + VIIPER_API void viiper_device_close(viiper_device_t* device) { if (!device) return; device->running = 0; diff --git a/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go index 1fbc6c91..0cd4d1bf 100644 --- a/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -35,6 +35,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable private Task? _readLoop; private bool _disposed; private Func? _onOutput; + private Action? _onDisconnect; /// /// Callback invoked when output data is available from the device. @@ -53,6 +54,12 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable } } + public Action? OnDisconnect + { + get => _onDisconnect; + set => _onDisconnect = value; + } + internal ViiperDevice(TcpClient client, NetworkStream stream) { _client = client; @@ -100,6 +107,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable { // swallow; user can detect via absence of further events } + _onDisconnect?.Invoke(); } private void ThrowIfDisposed() @@ -116,7 +124,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable if (_disposed) return; _disposed = true; _cts.Cancel(); - try { _readLoop?.Wait(); } catch { /* ignore */ } + try { _readLoop?.Wait(); } catch { } _stream.Dispose(); _client.Dispose(); _cts.Dispose(); @@ -132,7 +140,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable _disposed = true; _cts.Cancel(); if (_readLoop != null) - try { await _readLoop.ConfigureAwait(false); } catch { /* ignore */ } + try { await _readLoop.ConfigureAwait(false); } catch { } _stream.Dispose(); _client.Dispose(); _cts.Dispose(); diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index 9e0650c1..e1f60e50 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -84,6 +84,7 @@ pub struct AsyncDeviceStream { reader: std::sync::Arc>, writer: std::sync::Arc>, cancel_token: Option, + disconnect_callback: Option>, } #[cfg(feature = "async")] @@ -97,6 +98,7 @@ impl AsyncDeviceStream { reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), cancel_token: None, + disconnect_callback: None, }) } @@ -145,6 +147,7 @@ impl AsyncDeviceStream { let reader = self.reader.clone(); let cancel_token = tokio_util::sync::CancellationToken::new(); let cancel_clone = cancel_token.clone(); + let disconnect = self.disconnect_callback.take(); tokio::spawn(async move { loop { @@ -158,11 +161,22 @@ impl AsyncDeviceStream { } } } + if let Some(on_disconnect) = disconnect { + on_disconnect(); + } }); self.cancel_token = Some(cancel_token); 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 async fn send_raw(&self, data: &[u8]) -> Result<(), ViiperError> { let mut writer = self.writer.lock().await; diff --git a/internal/codegen/generator/rust/client.go b/internal/codegen/generator/rust/client.go index 787590ee..cf8b7ef2 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -77,6 +77,7 @@ impl ViiperClient { pub struct DeviceStream { stream: TcpStream, output_thread: Option>, + disconnect_callback: Option>, } impl DeviceStream { @@ -87,6 +88,7 @@ impl DeviceStream { Ok(Self { stream, output_thread: None, + disconnect_callback: None, }) } @@ -110,14 +112,26 @@ impl DeviceStream { } 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)?; From a7e09e0165e9a7f238c9fd7a343d17d87b3a1bdf Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Dec 2025 19:45:43 +0100 Subject: [PATCH 048/298] Add CPP header only SDK / Examples --- .github/workflows/clients_ci.yml | 38 ++ README.md | 1 + docs/api/overview.md | 1 + docs/clients/c.md | 1 + docs/clients/cpp.md | 560 ++++++++++++++++++ docs/clients/csharp.md | 1 + docs/clients/go.md | 1 + docs/clients/rust.md | 29 +- docs/clients/typescript.md | 1 + examples/.gitignore | 3 +- examples/cpp/CMakeLists.txt | 55 ++ examples/cpp/virtual_keyboard.cpp | 169 ++++++ examples/cpp/virtual_mouse.cpp | 146 +++++ examples/cpp/virtual_x360_pad.cpp | 150 +++++ internal/cmd/codegen.go | 2 +- internal/codegen/common/wiresize.go | 64 ++ internal/codegen/generator/c/gen.go | 7 - internal/codegen/generator/c/helpers.go | 59 +- internal/codegen/generator/c/source_device.go | 3 +- internal/codegen/generator/cpp/client.go | 168 ++++++ internal/codegen/generator/cpp/config.go | 71 +++ internal/codegen/generator/cpp/device.go | 169 ++++++ .../codegen/generator/cpp/device_header.go | 247 ++++++++ internal/codegen/generator/cpp/error.go | 117 ++++ internal/codegen/generator/cpp/gen.go | 79 +++ internal/codegen/generator/cpp/helpers.go | 213 +++++++ internal/codegen/generator/cpp/json.go | 116 ++++ internal/codegen/generator/cpp/main_header.go | 101 ++++ internal/codegen/generator/cpp/socket.go | 367 ++++++++++++ internal/codegen/generator/cpp/types.go | 119 ++++ internal/codegen/generator/csharp/client.go | 3 - .../codegen/generator/csharp/constants.go | 2 - internal/codegen/generator/csharp/gen.go | 7 - internal/codegen/generator/generator.go | 10 +- internal/codegen/generator/rust/constants.go | 6 - internal/codegen/generator/rust/gen.go | 14 - internal/codegen/generator/rust/types.go | 1 - .../codegen/generator/typescript/client.go | 6 - .../codegen/generator/typescript/constants.go | 2 - internal/codegen/generator/typescript/gen.go | 8 - .../codegen/generator/typescript/helpers.go | 1 - mkdocs.yml | 1 + 42 files changed, 2994 insertions(+), 125 deletions(-) create mode 100644 docs/clients/cpp.md create mode 100644 examples/cpp/CMakeLists.txt create mode 100644 examples/cpp/virtual_keyboard.cpp create mode 100644 examples/cpp/virtual_mouse.cpp create mode 100644 examples/cpp/virtual_x360_pad.cpp create mode 100644 internal/codegen/generator/cpp/client.go create mode 100644 internal/codegen/generator/cpp/config.go create mode 100644 internal/codegen/generator/cpp/device.go create mode 100644 internal/codegen/generator/cpp/device_header.go create mode 100644 internal/codegen/generator/cpp/error.go create mode 100644 internal/codegen/generator/cpp/gen.go create mode 100644 internal/codegen/generator/cpp/helpers.go create mode 100644 internal/codegen/generator/cpp/json.go create mode 100644 internal/codegen/generator/cpp/main_header.go create mode 100644 internal/codegen/generator/cpp/socket.go create mode 100644 internal/codegen/generator/cpp/types.go diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index ca6b4e84..05eee669 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -189,6 +189,44 @@ jobs: path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz if-no-files-found: error + cpp-sdk: + name: C++ SDK + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + + - name: Set up CMake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: '3.26.x' + + - 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++ SDK headers + if: ${{ inputs.upload_artifacts }} + run: | + cd clients/cpp + zip -r ../../cpp-sdk-headers${{ inputs.artifact_suffix }}.zip include/ + + - name: Upload C++ SDK headers + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: cpp-sdk-headers${{ inputs.artifact_suffix }} + path: cpp-sdk-headers${{ inputs.artifact_suffix }}.zip + if-no-files-found: error + rust: name: Rust SDK needs: codegen diff --git a/README.md b/README.md index 8ea34005..87ab917b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ [![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) [![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) [![C SDK](https://img.shields.io/badge/C_SDK-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) +[![C++ SDK](https://img.shields.io/badge/C++_Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) diff --git a/docs/api/overview.md b/docs/api/overview.md index efd7db9b..184567ec 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -8,6 +8,7 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de - [Go Client](../clients/go.md): Reference implementation included in the repository - [Generator Documentation](../clients/generator.md): Information about code generation - [C SDK](../clients/c.md): Generated C library with type-safe device streams + - [C++ SDK](../clients/cpp.md): Header-only C++20 library (requires external JSON parser) - [C# SDK](../clients/csharp.md): Generated .NET library with async/await support - [TypeScript SDK](../clients/typescript.md): Generated Node.js library with EventEmitter streams - [Rust SDK](../clients/rust.md): Generated Rust library with sync/async support diff --git a/docs/clients/c.md b/docs/clients/c.md index f0a99460..249d9a0f 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -289,6 +289,7 @@ cmake --build build --config Release - [Generator Documentation](generator.md): How the C SDK is generated - [Go Client Documentation](go.md): Reference implementation +- [C++ SDK Documentation](cpp.md): Header-only C++ SDK - [C# SDK Documentation](csharp.md): .NET SDK - [Rust SDK Documentation](rust.md): Rust SDK - [TypeScript SDK Documentation](typescript.md): Node.js SDK diff --git a/docs/clients/cpp.md b/docs/clients/cpp.md new file mode 100644 index 00000000..55951e66 --- /dev/null +++ b/docs/clients/cpp.md @@ -0,0 +1,560 @@ +# C++ SDK Documentation + +The VIIPER C++ SDK provides a modern, header-only C++20 client library for interacting with VIIPER servers and controlling virtual devices. + +## Overview + +The C++ SDK features: + +- **Header-only**: No separate compilation required, just include and use +- **Modern 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++ SDK 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 SDK headers. + + **Recommended**: [nlohmann/json](https://github.com/nlohmann/json) - a header-only JSON library that can be easily integrated. + +!!! 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 + +### 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) +``` + +### 3. Generating from Source + +```bash +go run ./cmd/viiper codegen --lang=cpp +``` + +The SDK will be generated in `clients/cpp/include/viiper/`. + +## JSON Parser Configuration + +Before including the VIIPER SDK, you must configure a JSON parser. The SDK 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 +``` + +## Quick Start + +```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 Stream API + +### Creating a Device Stream + +The simplest way to add a device and connect: + +```cpp +auto [device_info, stream] = client.addDeviceAndConnect(bus_id, {.type = "xbox360"}).value(); +``` + +With custom VID/PID: + +```cpp +viiper::Devicecreaterequest req = { + .type = "keyboard", + .idvendor = 0x1234, + .idproduct = 0x5678 +}; +auto [device_info, stream] = client.addDeviceAndConnect(bus_id, req).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 Output (Callbacks) + +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. + +## Generated Constants and Maps + +The C++ SDK 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 SDK 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; +``` + +**All Button Constants:** + +```cpp +viiper::xbox360::ButtonDPadUp +viiper::xbox360::ButtonDPadDown +viiper::xbox360::ButtonDPadLeft +viiper::xbox360::ButtonDPadRight +viiper::xbox360::ButtonStart +viiper::xbox360::ButtonBack +viiper::xbox360::ButtonLThumb +viiper::xbox360::ButtonRThumb +viiper::xbox360::ButtonLShoulder +viiper::xbox360::ButtonRShoulder +viiper::xbox360::ButtonGuide +viiper::xbox360::ButtonA +viiper::xbox360::ButtonB +viiper::xbox360::ButtonX +viiper::xbox360::ButtonY +``` + +## Practical Example: Typing Text + +Using the generated maps to type a string: + +```cpp +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; + } + + // Press key + viiper::keyboard::Input down = { + .modifiers = mods, + .keys = {key}, + }; + stream.send(down); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + // Release key + viiper::keyboard::Input up = { + .modifiers = 0, + .keys = {}, + }; + stream.send(up); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +// Usage +type_string(*stream, "Hello, World!"); +``` + +## 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 +``` + +## Project Structure + +Generated SDK layout: + +```text +clients/cpp/include/viiper/ +├── viiper.hpp # Main include (includes all others) +├── config.hpp # JSON configuration macros +├── error.hpp # Result and Error types +├── types.hpp # API request/response types +├── client.hpp # ViiperClient management API +├── device.hpp # ViiperDevice stream wrapper +├── detail/ +│ ├── socket.hpp # Cross-platform socket wrapper +│ └── json.hpp # JSON parsing helpers +└── devices/ + ├── keyboard.hpp # Keyboard constants, Input, Output + ├── mouse.hpp # Mouse constants, Input + └── xbox360.hpp # Xbox360 constants, Input, Output +``` + +## Requirements + +- **C++20** or later +- **JSON library** (nlohmann/json recommended) +- **Platform**: Windows (MSVC 2019+) or POSIX (GCC 10+, Clang 10+) + +### Windows-specific + +The SDK uses Winsock2 for networking. Link against `Ws2_32.lib` (done automatically via `#pragma comment`). + +### POSIX-specific + +Standard POSIX sockets are used. No additional libraries required. + +## 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 SDKs work +- [C SDK Documentation](c.md): Pure C alternative +- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support +- [C# SDK Documentation](csharp.md): .NET SDK +- [TypeScript SDK Documentation](typescript.md): Node.js SDK +- [API Overview](../api/overview.md): Management API reference +- [Device Documentation](../devices/): Wire formats and device-specific details + +--- + +For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 319836d1..1bb57394 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -507,6 +507,7 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C - [Rust SDK Documentation](rust.md): Rust SDK with sync/async support - [TypeScript SDK Documentation](typescript.md): Node.js SDK - [C SDK Documentation](c.md): Alternative SDK for native integration +- [C++ SDK Documentation](cpp.md): Header-only C++ SDK - [API Overview](../api/overview.md): Management API reference - [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/docs/clients/go.md b/docs/clients/go.md index 2c2547a2..bbadcc5a 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -231,6 +231,7 @@ Full working examples are available in the repository: - [Generator Documentation](generator.md): How generated SDKs work - [C SDK Documentation](c.md): Generated C SDK usage +- [C++ SDK Documentation](cpp.md): Header-only C++ SDK - [C# SDK Documentation](csharp.md): .NET SDK - [Rust SDK Documentation](rust.md): Rust SDK - [TypeScript SDK Documentation](typescript.md): Node.js SDK diff --git a/docs/clients/rust.md b/docs/clients/rust.md index f3d353ca..b1a10404 100644 --- a/docs/clients/rust.md +++ b/docs/clients/rust.md @@ -60,10 +60,16 @@ cargo build --release ```rust use viiper_client::{ViiperClient, devices::keyboard::*}; +use std::net::ToSocketAddrs; fn main() { // Create new Viiper client - let client = ViiperClient::new("localhost", 3242); + 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() { @@ -109,11 +115,17 @@ fn main() { ```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 client = AsyncViiperClient::new("localhost", 3242); + 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 { @@ -161,8 +173,14 @@ async fn main() { ```rust use viiper_client::{ViiperClient, types::DeviceCreateRequest}; +use std::net::ToSocketAddrs; -let client = ViiperClient::new("localhost", 3242); +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( @@ -554,7 +572,9 @@ clients/rust/ Verify VIIPER server is running and listening on the expected API port (default 3242). ```rust -let client = ViiperClient::new("127.0.0.1", 3242); +use std::net::SocketAddr; +let addr: SocketAddr = "127.0.0.1:3242".parse().expect("Invalid address"); +let client = ViiperClient::new(addr); ``` **Feature not found errors:** @@ -572,6 +592,7 @@ viiper-client = { version = "0.1", features = ["async"] } - [C# SDK Documentation](csharp.md): Alternative managed language SDK - [TypeScript SDK Documentation](typescript.md): Node.js SDK - [C SDK Documentation](c.md): Native C SDK +- [C++ SDK Documentation](cpp.md): Header-only C++ SDK - [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 index 5c23e8fe..222062ae 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -517,6 +517,7 @@ Some quick troubleshooting tips for the TypeScript SDK and device streams: - [C# SDK Documentation](csharp.md): Alternative managed language SDK - [Rust SDK Documentation](rust.md): Rust SDK with sync/async support - [C SDK Documentation](c.md): Alternative SDK for native integration +- [C++ SDK Documentation](cpp.md): Header-only C++ SDK - [API Overview](../api/overview.md): Management API reference - [Device Documentation](../devices/): Wire formats and device-specific details diff --git a/examples/.gitignore b/examples/.gitignore index a3969ec2..cbe46e51 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,2 +1,3 @@ build/ -target/ \ No newline at end of file +target/ +deps/ \ No newline at end of file diff --git a/examples/cpp/CMakeLists.txt b/examples/cpp/CMakeLists.txt new file mode 100644 index 00000000..5aaa9088 --- /dev/null +++ b/examples/cpp/CMakeLists.txt @@ -0,0 +1,55 @@ +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) + +# 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 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..517e787b --- /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::int8_t dx = static_cast(step * dir); + std::int8_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::Btn_Left, + .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/internal/cmd/codegen.go b/internal/cmd/codegen.go index 09a1ce43..37bd7223 100644 --- a/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -8,7 +8,7 @@ import ( 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, rust, typescript, or 'all'" default:"all" enum:"c,csharp,rust,typescript,all" env:"VIIPER_CODEGEN_LANG"` + 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. diff --git a/internal/codegen/common/wiresize.go b/internal/codegen/common/wiresize.go index 8afdec24..919fa635 100644 --- a/internal/codegen/common/wiresize.go +++ b/internal/codegen/common/wiresize.go @@ -1,8 +1,10 @@ package common import ( + "sort" "strings" + "github.com/Alia5/VIIPER/internal/codegen/meta" "github.com/Alia5/VIIPER/internal/codegen/scanner" ) @@ -41,3 +43,65 @@ func CalculateOutputSize(tag *scanner.WireTag) int { 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 + if direction == "input" { + dir = "c2s" + } else if direction == "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/c/gen.go b/internal/codegen/generator/c/gen.go index d29350c6..7083214e 100644 --- a/internal/codegen/generator/c/gen.go +++ b/internal/codegen/generator/c/gen.go @@ -10,13 +10,6 @@ import ( "github.com/Alia5/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 { version, err := common.GetVersion() if err != nil { diff --git a/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go index 966dda71..952617ba 100644 --- a/internal/codegen/generator/c/helpers.go +++ b/internal/codegen/generator/c/helpers.go @@ -15,11 +15,11 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { "ctype": cType, "snakecase": common.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) }, + "hasWireTag": func(device, direction string) bool { return common.HasWireTag(md, device, direction) }, + "wireFields": func(device, direction string) string { return wireFieldsString(md, device, direction) }, "indent": indent, "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, - "pathParams": orderedPathParams, + "pathParams": common.ExtractPathParams, "join": strings.Join, "mapFuncDecl": mapFuncDecl, "mapFuncImpl": mapFuncImpl, @@ -57,25 +57,20 @@ func responseCType(md *meta.Metadata, dtoName string) string { return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, dtoName)) } -// dtoToCTypeName converts a DTO name to its C typedef name using metadata mappings func dtoToCTypeName(md *meta.Metadata, dtoName string) string { - // Check if there's an explicit mapping for this DTO if md.CTypeNames != nil { if mapped, ok := md.CTypeNames[dtoName]; ok { return mapped } } - // Default: just convert to snake_case return common.ToSnakeCase(dtoName) } -// marshalPayload generates C code to marshal a struct to JSON string func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { if pi.Kind != scanner.PayloadJSON || pi.RawType == "" { return "" } - // Find the DTO in metadata var dto *scanner.DTOSchema for i := range md.DTOs { if md.DTOs[i].Name == pi.RawType { @@ -92,7 +87,6 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { fmt.Sprintf("if (%s) {", varName), } - // Start JSON object firstField := true for _, f := range dto.Fields { isPointer := strings.HasPrefix(f.Type, "*") @@ -104,7 +98,6 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { } else if isPointer { condition = fmt.Sprintf("if (%s->%s) ", varName, f.Name) } else { - // Non-pointer optional fields shouldn't exist for JSON payloads condition = "" } @@ -127,7 +120,6 @@ func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { condition, f.JSONName, varName, f.Name) } default: - // Unsupported type, skip continue } @@ -184,19 +176,8 @@ func cType(goType, kind string) 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) +func wireFieldsString(md *meta.Metadata, device, direction string) string { + tag := common.GetWireTag(md, device, direction) if tag == nil { return "/* no wire tag for this device/direction */" } @@ -256,20 +237,6 @@ func indent(spaces int, s string) string { return strings.Join(parts, "\n") } -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(md *meta.Metadata, f scanner.FieldInfo) string { if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { elem := strings.TrimPrefix(f.Type, "[]") @@ -286,9 +253,7 @@ func fieldDecl(md *meta.Metadata, f scanner.FieldInfo) string { return fmt.Sprintf("%s %s;%s", fieldTypeToCType(md, f.Type), f.Name, optComment(f)) } -// fieldTypeToCType converts a field type to C type, using metadata for struct type name mapping func fieldTypeToCType(md *meta.Metadata, goType string) string { - // Check if it's a primitive type first switch goType { case "string": return "const char*" @@ -316,14 +281,12 @@ func fieldTypeToCType(md *meta.Metadata, goType string) string { return "double" } - // For struct types, check if it's a DTO and use the type name mapping for _, dto := range md.DTOs { if dto.Name == goType { return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, goType)) } } - // Default: assume it's a struct and use snake_case return fmt.Sprintf("viiper_%s_t", common.ToSnakeCase(goType)) } @@ -398,7 +361,6 @@ func formatCMapKey(key string, goType string, device string) string { switch goType { case "byte", "uint8": if len(key) == 1 { - // Escape special characters switch key[0] { case '\n': return "'\\n'" @@ -480,7 +442,6 @@ func formatCMapValue(value interface{}, goType string, device string) string { } } -// generateFreeFunction creates a free function for a DTO func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { snakeName := dtoToCTypeName(md, dto.Name) lines := []string{ @@ -507,11 +468,8 @@ func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { baseType = strings.TrimPrefix(baseType, "[]") if strings.HasPrefix(f.Type, "[]") { - // Array type - need to free elements and array itself if baseType != "string" && baseType != "uint8" && baseType != "uint16" && baseType != "uint32" && baseType != "uint64" { - // Complex type array - need to find the DTO and free each field lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ ", f.Name, common.ToSnakeCase(f.JSONName))) - // Look up the DTO to find string fields that need freeing for _, dtoSchema := range md.DTOs { if dtoSchema.Name == baseType { for _, field := range dtoSchema.Fields { @@ -527,11 +485,9 @@ func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { } else if baseType == "string" { lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ if (v->%s[i]) free((void*)v->%s[i]); } free(v->%s);}", f.Name, common.ToSnakeCase(f.JSONName), f.Name, f.Name, f.Name)) } else { - // Primitive array - just free the array lines = append(lines, fmt.Sprintf(" if (v->%s) free(v->%s);", f.Name, f.Name)) } } else if strings.HasPrefix(f.Type, "*") && baseType == "string" { - // Pointer to string lines = append(lines, fmt.Sprintf(" if (v->%s) free((void*)v->%s);", f.Name, f.Name)) } } @@ -540,11 +496,8 @@ func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { return strings.Join(lines, "") } -// generateParser creates a parser function for a DTO func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { snakeName := dtoToCTypeName(md, dto.Name) - - // For parsing object responses, use _obj suffix parserName := fmt.Sprintf("viiper_parse_%s", snakeName) if strings.HasSuffix(snakeName, "_info") { parserName = fmt.Sprintf("viiper_parse_%s_obj", snakeName) @@ -565,7 +518,6 @@ func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { if baseType == "uint32" { parserCalls = append(parserCalls, fmt.Sprintf("json_parse_array_uint32(json, \"%s\", &out->%s, &out->%s_count)", f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) } else { - // Complex type - use appropriate parser based on type name mapping parserFuncName := fmt.Sprintf("json_parse_array_%s", dtoToCTypeName(md, baseType)) parserCalls = append(parserCalls, fmt.Sprintf("%s(json, \"%s\", &out->%s, &out->%s_count)", parserFuncName, f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) } @@ -573,7 +525,6 @@ func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { if required { parserCalls = append(parserCalls, fmt.Sprintf("json_parse_string_alloc(json, \"%s\", (char**)&out->%s)", f.JSONName, f.Name)) } else { - // Optional string - don't fail if missing lines = append(lines, fmt.Sprintf(" json_parse_string_alloc(json, \"%s\", (char**)&out->%s);", f.JSONName, f.Name)) } } else if baseType == "uint32" { diff --git a/internal/codegen/generator/c/source_device.go b/internal/codegen/generator/c/source_device.go index 51344152..98b63d7d 100644 --- a/internal/codegen/generator/c/source_device.go +++ b/internal/codegen/generator/c/source_device.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -35,7 +36,7 @@ func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.M pkg := md.DevicePackages[device] data := deviceSourceData{ Device: device, - HasS2C: hasWireTag(md, device, "s2c"), + HasS2C: common.HasWireTag(md, device, "s2c"), Pkg: pkg, } out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) diff --git a/internal/codegen/generator/cpp/client.go b/internal/codegen/generator/cpp/client.go new file mode 100644 index 00000000..316293b4 --- /dev/null +++ b/internal/codegen/generator/cpp/client.go @@ -0,0 +1,168 @@ +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 +#include +#include +#include + +namespace viiper { + +// ============================================================================ +// VIIPER Management API Client (thread-safe) +// ============================================================================ + +class ViiperClient { +public: + ViiperClient(std::string host, std::uint16_t port = 3242) + : host_(std::move(host)), port_(port) {} + + ~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_; } + + // ======================================================================== + // 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'; + 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'; + + 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_; + 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() + + 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..825873e7 --- /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++ SDK +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +// ============================================================================ +// JSON Parser Configuration +// ============================================================================ +// +// VIIPER C++ SDK 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..4eac0cc2 --- /dev/null +++ b/internal/codegen/generator/cpp/device.go @@ -0,0 +1,169 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const deviceTemplate = `// Auto-generated VIIPER C++ SDK +// 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 +#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 socket_.send(bytes.data(), bytes.size()); + } + + Result send_raw(const std::uint8_t* data, std::size_t size) { + std::lock_guard lock(send_mutex_); + return socket_.send(data, size); + } + + // ======================================================================== + // 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 = socket_.recv(buffer.get(), output_buffer_size_); + 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; + socket_.force_close(); + if (output_thread_.joinable()) { + output_thread_.join(); + } + } + + [[nodiscard]] bool is_connected() const noexcept { + return running_.load() && socket_.is_valid(); + } + + explicit ViiperDevice(detail::Socket socket) + : socket_(std::move(socket)), running_(false), output_buffer_size_(0) {} + +private: + detail::Socket 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, md *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..5a04ad08 --- /dev/null +++ b/internal/codegen/generator/cpp/device_header.go @@ -0,0 +1,247 @@ +package cpp + +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 deviceHeaderTemplate = `{{.Header}} +#pragma once + +#include "../error.hpp" +#include +#include +{{- if .HasMaps}} +#include +#include +#include +#include +#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}} +{{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}} + std::vector<{{cpptype (baseType .Type)}}> {{camelcase .Name}}; +{{- 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}} + buf.push_back(static_cast({{camelcase .Name}}.size())); + for (const auto& v : {{camelcase .Name}}) { + buf.push_back(static_cast(v)); + } +{{- 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}} + {{cpptype .Type}} {{camelcase .Name}} = 0; +{{- end}} + + static Result from_bytes(const std::uint8_t* data, std::size_t len) { + Output result; + std::size_t offset = 0; +{{- range $fields}} +{{- 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() + + 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) + } + } + + data := struct { + Header string + DeviceName string + Constants []scanner.ConstantInfo + Maps []scanner.MapInfo + HasInput bool + HasOutput bool + HasMaps bool + OutputSize int + }{ + Header: writeFileHeader(), + DeviceName: deviceName, + Constants: devicePkg.Constants, + Maps: devicePkg.Maps, + HasInput: hasInput, + HasOutput: hasOutput, + HasMaps: hasMaps, + 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..2601d044 --- /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++ SDK +// DO NOT EDIT - This file is generated from the VIIPER server codebase + +#pragma once + +#include +#include +#include + +namespace viiper { + +// ============================================================================ +// Error (simplified, generic like Rust SDK) +// ============================================================================ + +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..2ac1889b --- /dev/null +++ b/internal/codegen/generator/cpp/gen.go @@ -0,0 +1,79 @@ +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 := 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++ SDK", "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..b6d99e05 --- /dev/null +++ b/internal/codegen/generator/cpp/helpers.go @@ -0,0 +1,213 @@ +package cpp + +import ( + "fmt" + "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.ToPascalCase, + "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, + "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, "*") }, + "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.ToPascalCase(name) }, + "isByteKeyMap": isByteKeyMap, + "hasCharLiteralKeys": hasCharLiteralKeys, + "isNumericMapVal": func(vt string) bool { return vt != "string" && vt != "bool" }, + "sortedEntries": common.SortedMapEntries, + "formatKey": formatKey, + "formatValue": formatValue, + } +} + +func cppType(goType string) string { + base, isSlice, isPointer := common.NormalizeGoType(goType) + + 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.ToPascalCase(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.ToPascalCase(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) + 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": + 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..cb089161 --- /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++ SDK +// 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..46cbebef --- /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++ SDK - 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() + + 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..6a40e78f --- /dev/null +++ b/internal/codegen/generator/cpp/socket.go @@ -0,0 +1,367 @@ +package cpp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const socketTemplate = `// Auto-generated VIIPER C++ SDK +// 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 +#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; + 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..ece0d270 --- /dev/null +++ b/internal/codegen/generator/cpp/types.go @@ -0,0 +1,119 @@ +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}} + {{cpptype .Type}} {{camelcase .Name}}; +{{- end}} + + static {{pascalcase .Name}} from_json(const json_type& j) { + {{pascalcase .Name}} result; +{{- range .Fields}} +{{- if .Optional}} + result.{{camelcase .Name}} = detail::get_optional_field<{{cpptype .Type | unwrapOptional}}>(j, "{{.JSONName}}"); +{{- else if eq .TypeKind "slice"}} + result.{{camelcase .Name}} = detail::get_array<{{cpptype .Type | sliceElementType}}>(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 .Optional}} + if ({{camelcase .Name}}.has_value()) { + j["{{.JSONName}}"] = {{camelcase .Name}}.value(); + } +{{- 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() + + 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/client.go b/internal/codegen/generator/csharp/client.go index 18dc8c81..78a01afa 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -119,7 +119,6 @@ public class ViiperClient : IDisposable } ` -// generateClient creates the ViiperClient management API class func generateClient(logger *slog.Logger, projectDir string, md *meta.Metadata) error { logger.Debug("Generating ViiperClient management API") @@ -164,7 +163,6 @@ func generateMethodParams(route scanner.RouteInfo) string { for key := range route.PathParams { params = append(params, fmt.Sprintf("uint %s", toCamelCase(key))) } - // Add payload parameter if needed switch route.Payload.Kind { case scanner.PayloadJSON: name := payloadParamNameCS(route) @@ -176,7 +174,6 @@ func generateMethodParams(route scanner.RouteInfo) string { case scanner.PayloadNumeric: name := payloadParamNameCS(route) typeName := "uint" - // crude width mapping if strings.HasPrefix(route.Payload.RawType, "int") && !strings.HasPrefix(route.Payload.RawType, "uint") { typeName = "int" } else if strings.HasPrefix(route.Payload.RawType, "uint") { diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index d4470373..03596819 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -215,8 +215,6 @@ func mapGoConstTypeToCSharp(goType string) string { } } -// inferValueTypeFromEntries scans map values to detect if they're enum constants -// Returns the inferred C# type name (e.g., "Key") or empty string if not detected func inferValueTypeFromEntries(entries map[string]interface{}) string { if len(entries) == 0 { return "" diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go index 8422f1e4..73727480 100644 --- a/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -10,13 +10,6 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -// Generate produces the C# SDK layout under outputDir. -// It creates: -// - Viiper.Client/Viiper.Client.csproj -// - Viiper.Client/ViiperClient.cs (management API) -// - Viiper.Client/ViiperDevice.cs (device stream wrapper) -// - Viiper.Client/Types/*.cs (DTOs) -// - Viiper.Client/Devices/*/*.cs (per-device types and constants) func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { projectDir := filepath.Join(outputDir, "Viiper.Client") typesDir := filepath.Join(projectDir, "Types") diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index b1bfd675..774de30b 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -7,6 +7,7 @@ import ( "path/filepath" cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" + "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" @@ -14,17 +15,16 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/scanner" ) -// Generator orchestrates SDK generation for all target languages type Generator struct { outputDir string logger *slog.Logger } -// LanguageGenerator defines a function that generates an SDK for a language into outputDir. type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Metadata) error var generators = map[string]LanguageGenerator{ "c": cgen.Generate, + "cpp": cpp.Generate, "csharp": csharp.Generate, "rust": rust.Generate, "typescript": typescript.Generate, @@ -46,8 +46,6 @@ func (g *Generator) GenAll() error { return nil } -// GenerateLang runs the SDK generator for the provided language key. -// It scans metadata once per invocation and passes it to the language-specific generator. func (g *Generator) GenerateLang(lang string) error { gen, ok := generators[lang] if !ok { @@ -78,7 +76,6 @@ func (g *Generator) GenerateLang(lang string) error { return nil } -// ScanAll runs all scanners to collect metadata func (g *Generator) ScanAll() (*meta.Metadata, error) { requiredPaths := []string{"internal/cmd", "apitypes", "device"} for _, path := range requiredPaths { @@ -110,9 +107,8 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { md.DTOs = dtos g.logger.Info("Found DTOs", "count", len(dtos)) - // Build C type name mappings for types that would conflict + md.CTypeNames = make(map[string]string) for _, dto := range dtos { - // Device conflicts with viiper_device_t (streaming handle), so rename to device_info if dto.Name == "Device" { md.CTypeNames["Device"] = "device_info" } diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go index 9d9564ae..b9951e0d 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -95,7 +95,6 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, }) } - // Generate maps var maps []rustMapInfo for _, m := range devicePkg.Maps { mapInfo := rustMapInfo{ @@ -118,7 +117,6 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, } } - // Determine which collections to import hasHashMap := false hasHashSet := false for _, m := range maps { @@ -176,7 +174,6 @@ func formatConstValue(val interface{}, goType string) string { func formatMapKeyRust(key string, goType string) string { switch goType { case "byte", "uint8": - // Handle escape sequences if len(key) == 2 && key[0] == '\\' { switch key[1] { case 'n': @@ -191,7 +188,6 @@ func formatMapKeyRust(key string, goType string) string { return "0x27" } } - // Single character if len(key) >= 1 { return fmt.Sprintf("%d", key[0]) } @@ -206,9 +202,7 @@ func formatMapKeyRust(key string, goType string) string { func formatMapValueRust(value interface{}, goType string) string { switch goType { case "byte", "uint8": - // Check if it's a constant reference if str, ok := value.(string); ok { - // Use the constant directly return toScreamingSnakeCase(str) } return fmt.Sprintf("%v", value) diff --git a/internal/codegen/generator/rust/gen.go b/internal/codegen/generator/rust/gen.go index 458b9f24..321ed9ec 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -1,4 +1,3 @@ -// Package rust provides code generation for the Rust SDK. package rust import ( @@ -11,15 +10,6 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -// Generate produces the Rust SDK layout under outputDir. -// It creates a Cargo workspace with the following structure: -// - Cargo.toml -// - src/lib.rs (public exports) -// - src/error.rs -// - src/types.rs (management API DTOs) -// - src/client.rs (sync API) -// - src/async_client.rs (async API, feature-gated) -// - src/devices//{mod.rs, input.rs, output.rs, constants.rs} func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { projectDir := outputDir srcDir := filepath.Join(projectDir, "src") @@ -64,7 +54,6 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - // Generate device modules for deviceName := range md.DevicePackages { deviceDir := filepath.Join(devicesDir, deviceName) if err := os.MkdirAll(deviceDir, 0o755); err != nil { @@ -84,12 +73,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { } } - // Generate devices/mod.rs if err := generateDevicesModFile(logger, devicesDir, md); err != nil { return err } - // Generate lib.rs if err := generateLibFile(logger, srcDir, md); err != nil { return err } @@ -161,7 +148,6 @@ func generateDeviceModFile(logger *slog.Logger, deviceDir string, deviceName str content := writeFileHeaderRust() - // Check what files exist 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 && diff --git a/internal/codegen/generator/rust/types.go b/internal/codegen/generator/rust/types.go index 61de01ca..70da2ef0 100644 --- a/internal/codegen/generator/rust/types.go +++ b/internal/codegen/generator/rust/types.go @@ -56,7 +56,6 @@ func generateTypes(logger *slog.Logger, srcDir string, md *meta.Metadata) error for _, field := range dto.Fields { rustName := common.ToSnakeCase(field.Name) - // Escape Rust keywords if isRustKeyword(rustName) { rustName = "r#" + rustName } diff --git a/internal/codegen/generator/typescript/client.go b/internal/codegen/generator/typescript/client.go index e0f97388..6249c57b 100644 --- a/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -146,7 +146,6 @@ func generateMethodParamsTS(route scanner.RouteInfo) string { for key := range route.PathParams { params = append(params, fmt.Sprintf("%s: number", common.ToCamelCase(key))) } - // Add payload parameter based on classification switch route.Payload.Kind { case scanner.PayloadJSON: name := payloadParamNameTS(route) @@ -173,26 +172,21 @@ func generateMethodParamsTS(route scanner.RouteInfo) string { return strings.Join(params, ", ") } -// payloadParamNameTS chooses a descriptive parameter name for the payload. func payloadParamNameTS(route scanner.RouteInfo) string { if route.Payload.Kind == scanner.PayloadNone { return "" } - // Use ParserHint to derive parameter name (e.g., uint32 -> "busId", DeviceCreateRequest -> "request") hint := route.Payload.ParserHint if hint == "" { return "payload" } - // Heuristic: numeric hints map to "id", JSON DTOs map to "request" switch route.Payload.Kind { case scanner.PayloadNumeric: - // uint32 / uint64 / int likely represent IDs if strings.Contains(strings.ToLower(hint), "id") || strings.HasPrefix(hint, "uint") || strings.HasPrefix(hint, "int") { return "id" } return "value" case scanner.PayloadJSON: - // Use raw type name (e.g., DeviceCreateRequest -> request) if route.Payload.RawType != "" { return common.ToCamelCase(route.Payload.RawType) } diff --git a/internal/codegen/generator/typescript/constants.go b/internal/codegen/generator/typescript/constants.go index d07a6405..6ae2bf09 100644 --- a/internal/codegen/generator/typescript/constants.go +++ b/internal/codegen/generator/typescript/constants.go @@ -144,7 +144,6 @@ func mapGoConstTypeToTS(goType string) string { func formatMapKeyTS(key string, goType string) string { switch goType { case "byte", "uint8": - // Prefer enum symbolic key if provided (e.g., Key.A) if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { if pfx := common.ExtractPrefix(key); pfx != "" { _, member := common.TrimPrefixAndSanitize(key) @@ -153,7 +152,6 @@ func formatMapKeyTS(key string, goType string) string { } } } - // Otherwise, emit numeric key code for literal chars/escapes if len(key) == 2 && key[0] == '\\' { switch key[1] { case 'n': diff --git a/internal/codegen/generator/typescript/gen.go b/internal/codegen/generator/typescript/gen.go index bf4ffeee..8a02c39d 100644 --- a/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -10,14 +10,6 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -// Generate produces the TypeScript SDK layout under outputDir. -// It creates a Node package with the following structure: -// - package.json, tsconfig.json -// - src/index.ts -// - src/ViiperClient.ts (management API) -// - src/ViiperDevice.ts (device stream wrapper) -// - src/types/ManagementDtos.ts (DTOs) -// - src/devices//{Input.ts,Output.ts,Constants.ts} func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { projectDir := outputDir srcDir := filepath.Join(projectDir, "src") diff --git a/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go index 98507c6e..4c9520cc 100644 --- a/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -4,7 +4,6 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/common" ) -// goTypeToTS maps Go types to TypeScript types func goTypeToTS(goType string) string { base, _, _ := common.NormalizeGoType(goType) switch base { diff --git a/mkdocs.yml b/mkdocs.yml index 3db3da28..593be0de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Go Client: clients/go.md - Generator Documentation: clients/generator.md - C SDK: clients/c.md + - C++ SDK: clients/cpp.md - C# SDK: clients/csharp.md - Rust SDK: clients/rust.md - TypeScript SDK: clients/typescript.md From d15d4e1b262270c197790acf0adb3208931b779e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Dec 2025 19:46:12 +0100 Subject: [PATCH 049/298] Add SDKs example to makefile --- Makefile | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index bfc4b8bc..523ee640 100644 --- a/Makefile +++ b/Makefile @@ -55,16 +55,47 @@ help: ## Show this help message @echo. @echo Usage: make [target] @echo. - @echo Targets: + @echo Build Targets: + @echo build Build VIIPER for current platform + @echo clean Remove build artifacts + @echo test Run tests + @echo test-coverage Run tests with coverage + @echo. + @echo SDK Code Generation: + @echo codegen-all Generate all SDK client libraries + @echo codegen-c Generate C SDK + @echo codegen-cpp Generate C++ SDK + @echo codegen-csharp Generate C# SDK + @echo codegen-rust Generate Rust SDK + @echo codegen-typescript Generate TypeScript SDK + @echo. + @echo SDK Building: + @echo build-sdks Build all SDK client libraries + @echo build-sdk-c Build C SDK + @echo build-sdk-cpp Build C++ SDK + @echo build-sdk-csharp Build C# SDK + @echo build-sdk-rust Build Rust SDK + @echo build-sdk-typescript Build TypeScript SDK + @echo. + @echo Example Building: + @echo build-examples Build all examples for all SDKs + @echo build-examples-c Build C examples + @echo build-examples-cpp Build C++ examples + @echo build-examples-csharp Build C# examples + @echo build-examples-rust Build Rust examples + @echo build-examples-typescript Build TypeScript examples + @echo. + @echo Cleaning: + @echo clean-sdks Clean SDK build artifacts + @echo clean-examples Clean example build artifacts + @echo. + @echo Complete Rebuild: + @echo rebuild-all Clean, regenerate, and build all SDKs and examples + @echo. + @echo Other 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 generate-versioninfo Generate Windows version info resource - @echo clean-versioninfo Remove Windows version info resource @echo fmt Format Go code @echo vet Run go vet @echo lint Run golangci-lint @@ -159,3 +190,142 @@ version: ## Show version information @echo Version: $(VERSION) @echo Commit: $(COMMIT) @echo Built: $(BUILD_TIME) + +############################################################ +# SDK Code Generation +############################################################ + +CLIENTS_DIR := clients + +.PHONY: codegen-all +codegen-all: ## Generate all SDK client libraries (C, C++, C#, Rust, TypeScript) + @echo Generating all SDK clients... + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang all --output $(CLIENTS_DIR) + +.PHONY: codegen-c +codegen-c: ## Generate C SDK client library + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang c --output $(CLIENTS_DIR) + +.PHONY: codegen-cpp +codegen-cpp: ## Generate C++ SDK client library + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang cpp --output $(CLIENTS_DIR) + +.PHONY: codegen-csharp +codegen-csharp: ## Generate C# SDK client library + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang csharp --output $(CLIENTS_DIR) + +.PHONY: codegen-rust +codegen-rust: ## Generate Rust SDK client library + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang rust --output $(CLIENTS_DIR) + +.PHONY: codegen-typescript +codegen-typescript: ## Generate TypeScript SDK client library + cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang typescript --output $(CLIENTS_DIR) + +############################################################ +# SDK Building +############################################################ + +.PHONY: build-sdks +build-sdks: build-sdk-c build-sdk-cpp build-sdk-csharp build-sdk-rust build-sdk-typescript ## Build all SDK client libraries + +.PHONY: build-sdk-c +build-sdk-c: ## Build C SDK + @echo Building C SDK... + @if exist $(CLIENTS_DIR)\c (cd $(CLIENTS_DIR)\c && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release) else (echo C SDK not generated yet. Run 'make codegen-c' first.) + +.PHONY: build-sdk-cpp +build-sdk-cpp: ## Build C++ SDK (header-only, no build needed) + @echo C++ SDK is header-only - no build needed. + +.PHONY: build-sdk-csharp +build-sdk-csharp: ## Build C# SDK + @echo Building C# SDK... + @if exist $(CLIENTS_DIR)\csharp (cd $(CLIENTS_DIR)\csharp\Viiper.Client && dotnet build) else (echo C# SDK not generated yet. Run 'make codegen-csharp' first.) + +.PHONY: build-sdk-rust +build-sdk-rust: ## Build Rust SDK + @echo Building Rust SDK... + @if exist $(CLIENTS_DIR)\rust (cd $(CLIENTS_DIR)\rust && cargo build) else (echo Rust SDK not generated yet. Run 'make codegen-rust' first.) + +.PHONY: build-sdk-typescript +build-sdk-typescript: ## Build TypeScript SDK + @echo Building TypeScript SDK... + cd $(CLIENTS_DIR)\typescript && npm install && npm run build + +############################################################ +# Example Building +############################################################ + +.PHONY: build-examples +build-examples: build-examples-c build-examples-cpp build-examples-csharp build-examples-rust build-examples-typescript ## Build all examples for all SDKs + +.PHONY: build-examples-c +build-examples-c: ## Build C examples + @echo Building C examples... + cd examples\c && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release + +.PHONY: build-examples-cpp +build-examples-cpp: ## Build C++ examples + @echo Building C++ examples... + cd examples\cpp && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release + +.PHONY: build-examples-csharp +build-examples-csharp: ## Build C# examples + @echo Building C# examples... + cd examples\csharp\virtual_keyboard && dotnet build + cd examples\csharp\virtual_mouse && dotnet build + cd examples\csharp\virtual_x360_pad && dotnet build + +.PHONY: build-examples-rust +build-examples-rust: ## Build Rust examples + @echo Building Rust examples... + cd examples\rust && cargo build --workspace + +.PHONY: build-examples-typescript +build-examples-typescript: build-sdk-typescript ## Build TypeScript examples + @echo Building TypeScript examples... + cd examples\typescript && npm install && npm run build + +############################################################ +# Cleaning +############################################################ + +.PHONY: clean-sdks +clean-sdks: ## Clean all SDK build artifacts + @echo Cleaning SDK build artifacts... + -@$(RM_DIR) $(CLIENTS_DIR)\c\build 2>$(NULL_DEVICE) + -@$(RM_DIR) $(CLIENTS_DIR)\csharp\Viiper.Client\bin 2>$(NULL_DEVICE) + -@$(RM_DIR) $(CLIENTS_DIR)\csharp\Viiper.Client\obj 2>$(NULL_DEVICE) + -@$(RM_DIR) $(CLIENTS_DIR)\rust\target 2>$(NULL_DEVICE) + -@$(RM_DIR) $(CLIENTS_DIR)\typescript\node_modules 2>$(NULL_DEVICE) + -@$(RM_DIR) $(CLIENTS_DIR)\typescript\dist 2>$(NULL_DEVICE) + +.PHONY: clean-examples +clean-examples: ## Clean all example build artifacts + @echo Cleaning example build artifacts... + -@$(RM_DIR) examples\c\build 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\cpp\build 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_keyboard\bin 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_keyboard\obj 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_mouse\bin 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_mouse\obj 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_x360_pad\bin 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\csharp\virtual_x360_pad\obj 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\rust\target 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\typescript\node_modules 2>$(NULL_DEVICE) + -@$(RM_DIR) examples\typescript\dist 2>$(NULL_DEVICE) + +############################################################ +# Complete Rebuild +############################################################ + +.PHONY: rebuild-all +rebuild-all: clean-sdks clean-examples codegen-all build-sdks build-examples ## Complete rebuild: clean, regenerate all SDKs, build all SDKs and examples + @echo. + @echo ============================================================ + @echo REBUILD COMPLETE + @echo ============================================================ + @echo All SDKs have been regenerated and built. + @echo All examples have been built. + @echo ============================================================ From c307dec8845916b13aed283187505d6cd2e16801 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Dec 2025 21:12:59 +0100 Subject: [PATCH 050/298] Fix rust examples --- .../rust/async/virtual_keyboard/src/main.rs | 19 +++++++++++++---- examples/rust/async/virtual_mouse/src/main.rs | 19 +++++++++++++---- .../rust/async/virtual_x360_pad/src/main.rs | 19 +++++++++++++---- .../rust/sync/virtual_keyboard/src/main.rs | 21 ++++++++++++++----- examples/rust/sync/virtual_mouse/src/main.rs | 19 +++++++++++++---- .../rust/sync/virtual_x360_pad/src/main.rs | 19 +++++++++++++---- 6 files changed, 91 insertions(+), 25 deletions(-) diff --git a/examples/rust/async/virtual_keyboard/src/main.rs b/examples/rust/async/virtual_keyboard/src/main.rs index 43bad797..74e7f557 100644 --- a/examples/rust/async/virtual_keyboard/src/main.rs +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -1,4 +1,5 @@ use tokio::time::{sleep, Duration}; +use std::net::ToSocketAddrs; use viiper_client::{AsyncViiperClient, devices::keyboard::*}; #[tokio::main] @@ -10,10 +11,20 @@ async fn main() { std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs index b59e30de..177835c7 100644 --- a/examples/rust/async/virtual_mouse/src/main.rs +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -1,4 +1,5 @@ use tokio::time::{sleep, Duration}; +use std::net::ToSocketAddrs; use viiper_client::{AsyncViiperClient, devices::mouse::*}; #[tokio::main] @@ -10,10 +11,20 @@ async fn main() { std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); diff --git a/examples/rust/async/virtual_x360_pad/src/main.rs b/examples/rust/async/virtual_x360_pad/src/main.rs index ad235b3d..e9e8d25e 100644 --- a/examples/rust/async/virtual_x360_pad/src/main.rs +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -1,4 +1,5 @@ use tokio::time::Duration; +use std::net::ToSocketAddrs; use viiper_client::{AsyncViiperClient, devices::xbox360::*}; #[tokio::main] @@ -10,10 +11,20 @@ async fn main() { std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); diff --git a/examples/rust/sync/virtual_keyboard/src/main.rs b/examples/rust/sync/virtual_keyboard/src/main.rs index 934efc9e..3e7ead7c 100644 --- a/examples/rust/sync/virtual_keyboard/src/main.rs +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -1,3 +1,4 @@ +use std::net::ToSocketAddrs; use std::thread; use std::time::Duration; use viiper_client::{devices::keyboard::*, ViiperClient}; @@ -5,15 +6,25 @@ use viiper_client::{devices::keyboard::*, ViiperClient}; fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} ", args[0]); + eprintln!("Usage: {} ", args[0]); eprintln!("Example: {} localhost:3242", args[0]); std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs index 23a13549..38ff434e 100644 --- a/examples/rust/sync/virtual_mouse/src/main.rs +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -1,3 +1,4 @@ +use std::net::ToSocketAddrs; use std::thread; use std::time::Duration; use viiper_client::{devices::mouse::*, ViiperClient}; @@ -10,10 +11,20 @@ fn main() { std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); diff --git a/examples/rust/sync/virtual_x360_pad/src/main.rs b/examples/rust/sync/virtual_x360_pad/src/main.rs index 30c733e4..28594081 100644 --- a/examples/rust/sync/virtual_x360_pad/src/main.rs +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -1,5 +1,6 @@ use std::thread; use std::time::Duration; +use std::net::ToSocketAddrs; use viiper_client::{devices::xbox360::*, ViiperClient}; fn main() { @@ -10,10 +11,20 @@ fn main() { std::process::exit(1); } - let addr: std::net::SocketAddr = args[1].parse().unwrap_or_else(|e| { - eprintln!("Invalid address '{}': {}", args[1], e); - 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); From 34bb00c4b786601d2343fddd41a14d32d1dd1db9 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 2 Dec 2025 11:30:33 +0100 Subject: [PATCH 051/298] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 87ab917b..323785f7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) [![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) [![C SDK](https://img.shields.io/badge/C_SDK-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) -[![C++ SDK](https://img.shields.io/badge/C++_Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) +[![C++ SDK](https://img.shields.io/badge/C++_SDK-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) From 4513ef2b9b473a7305d01a77c8566e15316948c0 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 4 Dec 2025 01:52:44 +0100 Subject: [PATCH 052/298] Rust client: improve useability --- examples/rust/Cargo.lock | 4 ++-- internal/codegen/generator/rust/async_client.go | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index dca00c89..45cbdfd1 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -70,9 +70,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "lock_api" diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index e1f60e50..0f96030d 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -84,7 +84,7 @@ pub struct AsyncDeviceStream { reader: std::sync::Arc>, writer: std::sync::Arc>, cancel_token: Option, - disconnect_callback: Option>, + disconnect_callback: std::sync::Mutex>>, } #[cfg(feature = "async")] @@ -98,7 +98,7 @@ impl AsyncDeviceStream { reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), cancel_token: None, - disconnect_callback: None, + disconnect_callback: std::sync::Mutex::new(None), }) } @@ -147,7 +147,10 @@ impl AsyncDeviceStream { let reader = self.reader.clone(); let cancel_token = tokio_util::sync::CancellationToken::new(); let cancel_clone = cancel_token.clone(); - let disconnect = self.disconnect_callback.take(); + 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 { @@ -173,8 +176,11 @@ impl AsyncDeviceStream { where F: FnOnce() + Send + 'static, { - self.disconnect_callback = Some(Box::new(callback)); - Ok(()) + 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. From 72b80e1836651065071d2750530b1a485d5edfff Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 4 Dec 2025 13:04:57 +0100 Subject: [PATCH 053/298] Autoattach using ioctl on windows. Stolen from: https://github.com/fredemmott/USBPIP-VirtPP/blob/d758757cc878f64b9f3b1209b800de509c88b256/src/api/c/win32-attach.cpp --- internal/cmd/server.go | 2 +- internal/server/api/autoattach.go | 29 +- internal/server/api/autoattach_linux.go | 36 ++- internal/server/api/autoattach_windows.go | 263 +++++++++++++++++- internal/server/api/config.go | 1 + internal/server/api/config_unix.go | 7 + internal/server/api/config_windows.go | 7 + internal/server/api/handler/bus_device_add.go | 1 + 8 files changed, 310 insertions(+), 36 deletions(-) create mode 100644 internal/server/api/config_unix.go create mode 100644 internal/server/api/config_windows.go diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 23df7d84..65c4f5b5 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -63,7 +63,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger if s.ApiServerConfig.AutoAttachLocalClient { logger.Info("Auto-attach is enabled, checking prerequisites...") - if !api.CheckAutoAttachPrerequisites(logger) { + 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") diff --git a/internal/server/api/autoattach.go b/internal/server/api/autoattach.go index a0bb5f17..9eb371b9 100644 --- a/internal/server/api/autoattach.go +++ b/internal/server/api/autoattach.go @@ -2,36 +2,11 @@ package api import ( "context" - "fmt" "log/slog" - "os/exec" - "strconv" "github.com/Alia5/VIIPER/usbip" ) -func AttachLocalhostClient(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, - "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 +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 index bdcfe40b..92491947 100644 --- a/internal/server/api/autoattach_linux.go +++ b/internal/server/api/autoattach_linux.go @@ -4,17 +4,46 @@ 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(logger *slog.Logger) bool { +func CheckAutoAttachPrerequisites(_ bool, logger *slog.Logger) bool { allOk := true - // Check if usbip tool is available 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") @@ -26,11 +55,10 @@ func CheckAutoAttachPrerequisites(logger *slog.Logger) bool { logger.Debug("usbip tool found in PATH") } - // Check if vhci-hcd kernel module is loaded data, err := os.ReadFile("/proc/modules") if err != nil { logger.Debug("Could not read /proc/modules", "error", err) - // Don't fail here, might be in a container or restricted environment + // 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") diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index 8823d16a..400f04ac 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -3,14 +3,269 @@ package api import ( + "context" + "fmt" "log/slog" "os/exec" + "strconv" + "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 ( + DIGCF_PRESENT = 0x00000002 + DIGCF_DEVICEINTERFACE = 0x00000010 +) + +type SP_DEVICE_INTERFACE_DATA struct { + CbSize uint32 + InterfaceClassGuid windows.GUID + Flags uint32 + Reserved uintptr +} + +type SP_DEVICE_INTERFACE_DETAIL_DATA 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 ) -// CheckAutoAttachPrerequisites checks if auto-attach prerequisites are met on Windows. -// Returns true if all requirements are satisfied, false otherwise with helpful log messages. -func CheckAutoAttachPrerequisites(logger *slog.Logger) bool { - // Check if usbip.exe is available (from usbip-win2) +func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error { + if useNativeIOCTL { + return attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger) + } + return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger) +} + +func attachViaIOCTL(ctx 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) + + 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, + "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 +} + +func getDeviceInterfacePath(guid *windows.GUID) (string, error) { + r0, _, e1 := syscall.SyscallN(procSetupDiGetClassDevsW.Addr(), + uintptr(unsafe.Pointer(guid)), + 0, + 0, + uintptr(DIGCF_PRESENT|DIGCF_DEVICEINTERFACE)) + + 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() { + syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) + }() + + var interfaceData SP_DEVICE_INTERFACE_DATA + 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 + syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + 0, + 0, + uintptr(unsafe.Pointer(&requiredSize)), + 0) + + detailData := make([]byte, requiredSize) + detailHeader := (*SP_DEVICE_INTERFACE_DETAIL_DATA)(unsafe.Pointer(&detailData[0])) + detailHeader.CbSize = uint32(unsafe.Sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA{})) + + r2, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + uintptr(unsafe.Pointer(detailHeader)), + uintptr(requiredSize), + 0, + 0) + + if r2 == 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("usbip-win2 driver not found or not installed") + logger.Warn("Native IOCTL auto-attach requires the usbip-win2 driver") + logger.Info("Download and install usbip-win2:") + 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") diff --git a/internal/server/api/config.go b/internal/server/api/config.go index f20e676e..c1801507 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -8,4 +8,5 @@ type ServerConfig struct { 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"` ConnectionTimeout time.Duration `kong:"-"` + platformOpts `embed:""` } diff --git a/internal/server/api/config_unix.go b/internal/server/api/config_unix.go new file mode 100644 index 00000000..1ed36ab6 --- /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..85cbe148 --- /dev/null +++ b/internal/server/api/config_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package api + +type platformOpts struct { + AutoAttachWindowsNative bool `help:"Use native IOCTL instead of usbip.exe for auto-attach" default:"true" env:"VIIPER_API_AUTO_ATTACH_WINDOWS_NATIVE"` +} diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 3441d44e..e9f904a3 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -87,6 +87,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { req.Ctx, exportMeta, s.GetListenPort(), + apiSrv.Config().AutoAttachWindowsNative, logger, ) if err != nil { From ebdfb8e3b7b91c18a1a9cf42b9976495c81222f3 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 4 Dec 2025 13:46:18 +0100 Subject: [PATCH 054/298] Add bus auto-cleanup Configured by "ConnectionTimeout" Changelog(feat) --- internal/server/api/handler/bus_device_add.go | 2 +- .../server/api/handler/bus_device_add_test.go | 23 +++++++-------- .../server/api/handler/bus_device_remove.go | 2 +- internal/server/api/server.go | 2 ++ internal/server/usb/server.go | 28 +++++++++++++++---- virtualbus/virtualbus.go | 21 ++++++++++++++ 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index e9f904a3..92c27eaa 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -74,7 +74,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { return case <-connTimer.C: deviceIDStr := fmt.Sprintf("%d", exportMeta.DevId) - if err := b.RemoveDeviceByID(deviceIDStr); err != nil { + if err := s.RemoveDeviceByID(uint32(busID), 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) diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index e5e22389..a5515acf 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -132,21 +132,21 @@ func TestBusDeviceAdd(t *testing.T) { // 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)) + usbSrv := usb.New(usb.ServerConfig{ + Addr: "127.0.0.1:0", + ConnectionTimeout: time.Millisecond * 500, + }, slog.Default(), log.NewRaw(nil)) 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} + 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)) @@ -165,15 +165,16 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { _, err = c.DeviceAdd(80100, "xbox360", nil) 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) + require.Eventually(t, func() bool { + list, _ := c.DevicesList(80100) + return list != nil && len(list.Devices) == 0 + }, 3*time.Second, 50*time.Millisecond) - list2, err := c.DevicesList(80100) - require.NoError(t, err) - assert.Len(t, list2.Devices, 0) + 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 index 3df67922..ab7fbbdd 100644 --- a/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -31,7 +31,7 @@ func BusDeviceRemove(s *usb.Server) api.HandlerFunc { if b == nil { return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } - if err := b.RemoveDeviceByID(deviceID); err != nil { + if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { return api.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 0340d474..52ab98f9 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -225,7 +225,9 @@ func (a *Server) handleConn(conn net.Conn) { } else { connLogger.Info("disconnect timeout: removed device (no reconnection)", "busID", busID, "deviceID", deviceIDStr) } + return } + connLogger.Warn("disconnect timeout: device context closed but metadata missing") } }() } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 768759a3..41e4c3d2 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -141,8 +141,30 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { 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() { + select { + case <-emptyCtx.Done(): + // Cancelled - a new device was added + return + case <-time.After(s.config.ConnectionTimeout): + 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 bus.RemoveDeviceByID(deviceID) + return nil } // ListBuses returns a snapshot of active bus numbers. @@ -501,7 +523,6 @@ func isClientDisconnect(err error) bool { } 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 { @@ -509,7 +530,6 @@ func isClientDisconnect(err error) bool { } } } - // 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 @@ -517,7 +537,6 @@ func isClientDisconnect(err error) bool { 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) @@ -591,7 +610,6 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by return nil } -// buildConfigDescriptor builds a configuration descriptor for the device. func buildConfigDescriptor(desc *usb.Descriptor) []byte { var b bytes.Buffer h := usb.ConfigHeader{ diff --git a/virtualbus/virtualbus.go b/virtualbus/virtualbus.go index 7e6b9d75..8b39a7f9 100644 --- a/virtualbus/virtualbus.go +++ b/virtualbus/virtualbus.go @@ -27,6 +27,8 @@ type VirtualBus struct { 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. @@ -84,6 +86,12 @@ func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { vb.mutex.Lock() defer vb.mutex.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") @@ -146,6 +154,18 @@ func (vb *VirtualBus) Devices() []usb.Device { return out } +func (vb *VirtualBus) GetBusEmptyContext() context.Context { + vb.mutex.Lock() + defer vb.mutex.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 { @@ -158,6 +178,7 @@ func (vb *VirtualBus) RemoveDeviceByID(deviceID string) error { } delete(vb.allocatedDevIDs, d.meta.DevId) vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) + return nil } } From 7d3b1ea8600387bf9f1d10e39c53e9b3d68ab0dd Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 5 Dec 2025 20:58:59 +0100 Subject: [PATCH 055/298] Colored logging --- internal/log/logging.go | 105 +++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/internal/log/logging.go b/internal/log/logging.go index f01af1a8..a8aa8e75 100644 --- a/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,33 @@ 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 + + if logFile == "" { + 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}) + } 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})) + } + logger := slog.New(MultiHandler{hs: handlers}) + return logger, closeFiles, nil +} + // MultiHandler fans out records to multiple handlers. type MultiHandler struct{ hs []slog.Handler } @@ -92,29 +121,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) + buf.WriteString(fmt.Sprintf("%5s", r.Level.String())) + buf.WriteString("\033[0m") + + buf.WriteString(" ") + buf.WriteString(r.Message) + + r.Attrs(func(a slog.Attr) bool { + buf.WriteString(" ") + buf.WriteString(a.Key) + buf.WriteString("=") + 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 } From 570ea41d9892bea8d5952d17ab03a327f5be0c63 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 5 Dec 2025 21:00:32 +0100 Subject: [PATCH 056/298] Devices: Fix Vid/PID override --- device/keyboard/device.go | 4 ++-- device/mouse/device.go | 4 ++-- device/xbox360/device.go | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 1dd127e4..a0a490ff 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -26,8 +26,8 @@ func New(o *device.CreateOptions) *Keyboard { descriptor: defaultDescriptor, } if o != nil { - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IdVendor != nil { + d.descriptor.Device.IDVendor = *o.IdVendor } if o.IdProduct != nil { d.descriptor.Device.IDProduct = *o.IdProduct diff --git a/device/mouse/device.go b/device/mouse/device.go index bc91abeb..30c1ec59 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -25,8 +25,8 @@ func New(o *device.CreateOptions) *Mouse { descriptor: defaultDescriptor, } if o != nil { - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IdVendor != nil { + d.descriptor.Device.IDVendor = *o.IdVendor } if o.IdProduct != nil { d.descriptor.Device.IDProduct = *o.IdProduct diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 6c6576d7..9332ae34 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -24,8 +24,8 @@ func New(o *device.CreateOptions) *Xbox360 { descriptor: defaultDescriptor, } if o != nil { - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IdVendor != nil { + d.descriptor.Device.IDVendor = *o.IdVendor } if o.IdProduct != nil { d.descriptor.Device.IDProduct = *o.IdProduct @@ -170,7 +170,7 @@ var defaultDescriptor = usb.Descriptor{ Strings: map[uint8]string{ 0: "\x04\x09", // LangID: en-US (0x0409) 1: "©Microsoft Corporation", - 2: "Controller", + 2: "VIIPER Controller", //"Controller", 3: "296013F", }, } From 6d5f209ae710c8a00d395d58c5170d14b7f8e0d6 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 5 Dec 2025 21:14:16 +0100 Subject: [PATCH 057/298] Fix bus auto-cleanup --- internal/cmd/server.go | 1 + .../server/api/handler/bus_device_add_test.go | 1 + internal/server/usb/config.go | 1 + internal/server/usb/server.go | 40 ++++++++++++++++++- 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 65c4f5b5..f3d360d1 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -31,6 +31,7 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { 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) usbSrv := usb.New(s.UsbServerConfig, logger, rawLogger) diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index a5515acf..00e0cf3c 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -135,6 +135,7 @@ 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) diff --git a/internal/server/usb/config.go b/internal/server/usb/config.go index 5f7d4e1d..83432a6f 100644 --- a/internal/server/usb/config.go +++ b/internal/server/usb/config.go @@ -6,4 +6,5 @@ import "time" 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:"-"` } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 41e4c3d2..3983dc5e 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -148,11 +148,12 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { 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.ConnectionTimeout): + 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) @@ -162,6 +163,15 @@ func (s *Server) RemoveDeviceByID(busID uint32, deviceID string) error { } } }() + } 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 @@ -449,6 +459,34 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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: } From 337dbb37ede7076222f8f718045ff714e9f3ec28 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 04:16:34 +0100 Subject: [PATCH 058/298] Fix cargo publish --- .github/workflows/release.yml | 428 +++++++++++++++++----------------- 1 file changed, 218 insertions(+), 210 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 870f8113..4ffa212e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,223 +1,231 @@ name: Release on: - push: - tags: - - 'v*.*.*' + push: + tags: + - "v*.*.*" permissions: - contents: write - id-token: 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 }} - - client-sdks: - name: SDK smoke builds and pack - uses: ./.github/workflows/clients_ci.yml - with: - artifact_suffix: "-Release" - upload_artifacts: true - version: ${{ github.ref_name }} - - create-release: - name: Create Release - needs: [build, generate-changelog, client-sdks] - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + build: + uses: ./.github/workflows/build_base.yml 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 - filename="$(basename "$file")" - if [[ "$filename" == *.tar.gz ]]; then - ext="tar.gz" - else - ext="${filename##*.}" + mode: release + tag_name: ${{ github.ref_name }} + + client-sdks: + name: SDK smoke builds and pack + uses: ./.github/workflows/clients_ci.yml + with: + artifact_suffix: "-Release" + upload_artifacts: true + version: ${{ github.ref_name }} + + create-release: + name: Create Release + needs: [build, generate-changelog, client-sdks] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + 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: Set up Node.js (for npm publish) + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org/" + + - name: Set up .NET SDK (for NuGet publish) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # Acquire short-lived NuGet API key via OIDC Trusted Publishing + - name: NuGet login (OIDC → temp API key) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + # Publish SDKs to npm and NuGet using OIDC trusted publishing + - name: Publish TypeScript SDK to npm + working-directory: release_files + shell: bash + run: | + set -euo pipefail + TS_TARBALL="viiper-typescript-sdk.tgz" + if [ ! -f "$TS_TARBALL" ]; then + echo "ERROR: Missing TypeScript SDK 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 SDK tarball" >&2 + exit 1 + fi + if [ -z "$PKG_NAME" ]; then + echo "ERROR: Failed to extract package name from TypeScript SDK tarball" >&2 + exit 1 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}" + 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 - cp "$file" "release_files/${fname}" + NPM_TAG="latest" fi - fi - done - fi - done - ls -la release_files/ - - - name: Set up Node.js (for npm publish) - uses: actions/setup-node@v4 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org/' - - - name: Set up .NET SDK (for NuGet publish) - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - # Acquire short-lived NuGet API key via OIDC Trusted Publishing - - name: NuGet login (OIDC → temp API key) - id: login - uses: NuGet/login@v1 - with: - user: ${{ secrets.NUGET_USER }} - - # Publish SDKs to npm and NuGet using OIDC trusted publishing - - name: Publish TypeScript SDK to npm - working-directory: release_files - shell: bash - run: | - set -euo pipefail - TS_TARBALL="viiper-typescript-sdk.tgz" - if [ ! -f "$TS_TARBALL" ]; then - echo "ERROR: Missing TypeScript SDK 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 SDK tarball" >&2 - exit 1 - fi - if [ -z "$PKG_NAME" ]; then - echo "ERROR: Failed to extract package name from TypeScript SDK 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# SDK to NuGet - working-directory: release_files - shell: bash - run: | - set -euo pipefail - NUPKG="viiper-csharp-sdk-nupkg.nupkg" - if [ ! -f "$NUPKG" ]; then - echo "ERROR: Missing C# SDK 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: Publish Rust SDK to crates.io - working-directory: release_files - shell: bash - run: | - set -euo pipefail - CRATE="viiper-rust-sdk.crate" - if [ ! -f "$CRATE" ]; then - echo "ERROR: Missing Rust SDK 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" - 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 - - - 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 - 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 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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# SDK to NuGet + working-directory: release_files + shell: bash + run: | + set -euo pipefail + NUPKG="viiper-csharp-sdk-nupkg.nupkg" + if [ ! -f "$NUPKG" ]; then + echo "ERROR: Missing C# SDK 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 + + - name: Publish Rust SDK 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-sdk.crate" + if [ ! -f "$CRATE" ]; then + echo "ERROR: Missing Rust SDK 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 + + - 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 + 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 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 01bf63b729450888ef973a30b2049a32286b5589 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 05:43:05 +0100 Subject: [PATCH 059/298] Fix bench_test timeouts --- testing/e2e/bench_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/e2e/bench_test.go b/testing/e2e/bench_test.go index 9975e4fa..a1ad61c0 100644 --- a/testing/e2e/bench_test.go +++ b/testing/e2e/bench_test.go @@ -100,7 +100,8 @@ func Benchmark_Xbox360_Delay(b *testing.B) { s := cmd.Server{ UsbServerConfig: usb.ServerConfig{ - Addr: ":3241", + Addr: ":3241", + BusCleanupTimeout: 1 * time.Second, }, ApiServerConfig: api.ServerConfig{ Addr: ":3242", From 51f252899fc91d70097ef0aad4028e4dc12782bb Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 05:46:11 +0100 Subject: [PATCH 060/298] Rename: SDK -> Client Library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maybe something is cooking 😉 --- .github/workflows/clients_ci.yml | 542 +++++++++--------- .github/workflows/release.yml | 30 +- .github/workflows/snapshots.yml | 212 +++---- README.md | 12 +- docs/api/overview.md | 80 +-- docs/cli/codegen.md | 22 +- docs/cli/overview.md | 2 +- docs/clients/c.md | 26 +- docs/clients/cpp.md | 34 +- docs/clients/csharp.md | 26 +- docs/clients/generator.md | 21 +- docs/clients/go.md | 12 +- docs/clients/rust.md | 32 +- docs/clients/typescript.md | 26 +- docs/devices/keyboard.md | 8 +- docs/devices/mouse.md | 8 +- docs/devices/xbox360.md | 8 +- docs/getting-started/quickstart.md | 18 +- docs/index.md | 2 +- internal/cmd/codegen.go | 2 +- internal/codegen/common/fileheader.go | 2 +- internal/codegen/common/readme.go | 4 +- internal/codegen/generator/c/gen.go | 2 +- internal/codegen/generator/c/header_common.go | 6 +- internal/codegen/generator/c/header_device.go | 2 +- internal/codegen/generator/c/source_common.go | 2 +- internal/codegen/generator/c/source_device.go | 2 +- internal/codegen/generator/cpp/config.go | 4 +- internal/codegen/generator/cpp/device.go | 2 +- internal/codegen/generator/cpp/error.go | 4 +- internal/codegen/generator/cpp/gen.go | 2 +- internal/codegen/generator/cpp/json.go | 2 +- internal/codegen/generator/cpp/main_header.go | 2 +- internal/codegen/generator/cpp/socket.go | 2 +- internal/codegen/generator/csharp/gen.go | 2 +- internal/codegen/generator/csharp/project.go | 2 +- internal/codegen/generator/generator.go | 6 +- internal/codegen/generator/rust/gen.go | 2 +- internal/codegen/generator/rust/project.go | 2 +- internal/codegen/generator/typescript/gen.go | 2 +- .../codegen/generator/typescript/project.go | 6 +- internal/config/config.go | 2 +- mkdocs.yml | 10 +- 43 files changed, 596 insertions(+), 599 deletions(-) diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 05eee669..89de863f 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -1,277 +1,277 @@ -name: SDK CI +name: Client Libraries CI on: - pull_request: - branches: ["**"] - 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 SDK artifacts" - version: - required: false - type: string - default: "" - description: "Override version injected via ldflags (e.g. tag v1.2.3)" + 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 + contents: read jobs: - codegen: - name: Code generation - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - 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@v4 - with: - name: generated-clients - path: clients/ - retention-days: 1 - - typescript: - name: TypeScript SDK - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'npm' - cache-dependency-path: | - clients/typescript/package-lock.json - examples/typescript/package-lock.json - - - name: Build TypeScript SDK - 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 SDK tarball - if: ${{ inputs.upload_artifacts }} - working-directory: clients/typescript - run: | - for file in viiperclient-*.tgz; do - if [ -f "$file" ]; then - mv "$file" "viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz" - fi - done - - - name: Upload TypeScript SDK tarball - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: typescript-sdk${{ inputs.artifact_suffix }} - path: clients/typescript/viiperclient-typescript-sdk${{ inputs.artifact_suffix }}.tgz - if-no-files-found: error - - csharp: - name: C# SDK - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Pack C# SDK - 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# SDK nupkg - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: csharp-sdk-nupkg${{ inputs.artifact_suffix }} - path: artifacts/nuget/*.nupkg - if-no-files-found: error - - c-sdk: - name: C SDK - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2 - with: - cmake-version: '3.26.x' - - - name: Build C SDK - run: | - cmake -S clients/c -B clients/c/build - cmake --build clients/c/build --config Release --parallel - - - name: Build C examples (smoke) - run: | - cmake -S examples/c -B examples/c/build - cmake --build examples/c/build --config Release --parallel - - - name: Archive C SDK source - if: ${{ inputs.upload_artifacts }} - run: tar -czf c-sdk-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . - - - name: Upload C SDK source - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: c-sdk-source${{ inputs.artifact_suffix }} - path: c-sdk-source${{ inputs.artifact_suffix }}.tar.gz - if-no-files-found: error - - cpp-sdk: - name: C++ SDK - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2 - with: - cmake-version: '3.26.x' - - - 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++ SDK headers - if: ${{ inputs.upload_artifacts }} - run: | - cd clients/cpp - zip -r ../../cpp-sdk-headers${{ inputs.artifact_suffix }}.zip include/ - - - name: Upload C++ SDK headers - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: cpp-sdk-headers${{ inputs.artifact_suffix }} - path: cpp-sdk-headers${{ inputs.artifact_suffix }}.zip - if-no-files-found: error - - rust: - name: Rust SDK - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build Rust SDK - working-directory: clients/rust - run: | - cargo build --release - cargo build --release --features async - - - name: Package Rust SDK - 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 SDK 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-sdk${{ inputs.artifact_suffix }}.crate" - fi - done - - - name: Upload Rust SDK crate - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: rust-sdk${{ inputs.artifact_suffix }} - path: clients/rust/target/package/viiper-client-rust-sdk${{ inputs.artifact_suffix }}.crate - if-no-files-found: error + codegen: + name: Code generation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + 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@v4 + 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@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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@v4 + 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@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + 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@v4 + with: + name: csharp-client-library-nupkg${{ inputs.artifact_suffix }} + path: artifacts/nuget/*.nupkg + if-no-files-found: error + + c-sdk: + name: C Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + + - name: Set up CMake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: "3.26.x" + + - name: Build C Client Library + run: | + cmake -S clients/c -B clients/c/build + cmake --build clients/c/build --config Release --parallel + + - name: Build C examples (smoke) + run: | + cmake -S examples/c -B examples/c/build + cmake --build examples/c/build --config Release --parallel + + - name: Archive C Client Library source + if: ${{ inputs.upload_artifacts }} + run: tar -czf c-client-library-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . + + - name: Upload C Client Library source + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: c-client-library-source${{ inputs.artifact_suffix }} + path: c-client-library-source${{ inputs.artifact_suffix }}.tar.gz + if-no-files-found: error + + cpp-sdk: + name: C++ Client Library + needs: codegen + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + with: + name: generated-clients + path: clients/ + + - name: Set up CMake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: "3.26.x" + + - 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@v4 + 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@v4 + + - name: Download generated clients + uses: actions/download-artifact@v4 + 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@v4 + 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/release.yml b/.github/workflows/release.yml index 4ffa212e..f19c6d35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,8 @@ jobs: mode: release tag_name: ${{ github.ref_name }} - client-sdks: - name: SDK smoke builds and pack + client-libraries: + name: Client library smoke builds and pack uses: ./.github/workflows/clients_ci.yml with: artifact_suffix: "-Release" @@ -34,7 +34,7 @@ jobs: create-release: name: Create Release - needs: [build, generate-changelog, client-sdks] + needs: [build, generate-changelog, client-libraries] runs-on: ubuntu-latest steps: - name: Checkout code @@ -96,15 +96,15 @@ jobs: with: user: ${{ secrets.NUGET_USER }} - # Publish SDKs to npm and NuGet using OIDC trusted publishing - - name: Publish TypeScript SDK to npm + # 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-sdk.tgz" + TS_TARBALL="viiper-typescript-client-library.tgz" if [ ! -f "$TS_TARBALL" ]; then - echo "ERROR: Missing TypeScript SDK tarball: $TS_TARBALL" >&2 + echo "ERROR: Missing TypeScript client library tarball: $TS_TARBALL" >&2 ls -la exit 1 fi @@ -112,11 +112,11 @@ jobs: 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 SDK tarball" >&2 + 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 SDK tarball" >&2 + echo "ERROR: Failed to extract package name from TypeScript client library tarball" >&2 exit 1 fi echo "Package version: $PKG_VERSION" @@ -137,14 +137,14 @@ jobs: npm config delete always-auth || true npm publish "$TS_TARBALL" --provenance --access public --tag "$NPM_TAG" - - name: Publish C# SDK to NuGet + - name: Publish C# client library to NuGet working-directory: release_files shell: bash run: | set -euo pipefail - NUPKG="viiper-csharp-sdk-nupkg.nupkg" + NUPKG="viiper-csharp-client-library-nupkg.nupkg" if [ ! -f "$NUPKG" ]; then - echo "ERROR: Missing C# SDK package: $NUPKG" >&2 + echo "ERROR: Missing C# client library package: $NUPKG" >&2 ls -la exit 1 fi @@ -158,16 +158,16 @@ jobs: id: crates_io_auth uses: rust-lang/crates-io-auth-action@v1 - - name: Publish Rust SDK to crates.io + - 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-sdk.crate" + CRATE="viiper-rust-client-library.crate" if [ ! -f "$CRATE" ]; then - echo "ERROR: Missing Rust SDK crate: $CRATE" >&2 + echo "ERROR: Missing Rust client library crate: $CRATE" >&2 ls -la exit 1 fi diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 79feccde..2e2da652 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -1,125 +1,125 @@ name: Dev Snapshot Build on: - push: - branches: [ "main" ] + push: + branches: ["main"] permissions: - contents: write + contents: write jobs: - calculate-version: - name: Calculate Dev Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - 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" - - build: - needs: calculate-version - uses: ./.github/workflows/build_base.yml - with: - artifact_suffix: "-Snapshot" - upload_artifacts: true + calculate-version: + name: Calculate Dev Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 - generate-changelog: - name: Generate Changelog - needs: build - uses: ./.github/workflows/generate-changelog.yml - with: - mode: snapshot + - 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" - client-sdks: - name: SDK smoke builds and pack - needs: calculate-version - uses: ./.github/workflows/clients_ci.yml - with: - artifact_suffix: "-Snapshot" - upload_artifacts: true - version: ${{ needs.calculate-version.outputs.version }} + build: + needs: calculate-version + uses: ./.github/workflows/build_base.yml + with: + artifact_suffix: "-Snapshot" + upload_artifacts: true - create-pre-release: - name: Create Pre-Release - needs: [build, generate-changelog, client-sdks] - runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') - steps: - - name: Checkout code - uses: actions/checkout@v4 + generate-changelog: + name: Generate Changelog + needs: build + uses: ./.github/workflows/generate-changelog.yml with: - fetch-depth: 0 + mode: snapshot - - name: Download all artifacts - uses: actions/download-artifact@v4 + client-libraries: + name: Client library smoke builds and pack + needs: calculate-version + uses: ./.github/workflows/clients_ci.yml with: - path: artifacts + artifact_suffix: "-Snapshot" + upload_artifacts: true + version: ${{ needs.calculate-version.outputs.version }} - - 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/ + 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@v4 + with: + fetch-depth: 0 - - 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: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts - - name: Update Dev Snapshot Release - uses: andelf/nightly-release@main - 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. + - 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@main + 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. - 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 }}) + 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. + ⚠️ These are the latest development builds and may contain bugs or unfinished features. - ### Changes - ${{ needs.generate-changelog.outputs.changelog }} - files: | - ./release_files/* + ### Changes + ${{ needs.generate-changelog.outputs.changelog }} + files: | + ./release_files/* diff --git a/README.md b/README.md index 323785f7..b682f683 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@
- [![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.txt) -[![Client SDKs: MIT](https://img.shields.io/badge/Client_SDKs-MIT-green)](https://github.com/alia5/VIIPER/blob/main/internal/codegen/common/license.go) +[![Client Libraries: MIT](https://img.shields.io/badge/Client_Libraries-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) [![Downloads](https://img.shields.io/github/downloads/alia5/VIIPER/total?logo=github)](https://github.com/alia5/VIIPER/releases) [![Issues](https://img.shields.io/github/issues/alia5/VIIPER)](https://github.com/alia5/VIIPER/issues) @@ -15,10 +14,8 @@ [![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) [![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) [![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) -[![C SDK](https://img.shields.io/badge/C_SDK-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) -[![C++ SDK](https://img.shields.io/badge/C++_SDK-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) - - +[![C Client Library](https://img.shields.io/badge/C_Client_Library-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) +[![C++ Client Library](https://img.shields.io/badge/C++_Client_Library-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) # VIIPER 🐍 @@ -54,7 +51,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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) +- ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) MIT Licensed ## 🔌 Requirements @@ -104,6 +101,7 @@ make build The binary will be in `dist/viiper` (or `dist/viiper.exe` on Windows). For more build options: + ```bash make help # Show all available targets make test # Run tests diff --git a/docs/api/overview.md b/docs/api/overview.md index 184567ec..e3fe5f9a 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -2,16 +2,16 @@ 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 null-byte-terminated commands. -!!! 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: - +!!! tip "Client Libraries 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 client libraries rather than implementing the raw protocol yourself: + - [Go Client](../clients/go.md): Reference implementation included in the repository - [Generator Documentation](../clients/generator.md): Information about code generation - - [C SDK](../clients/c.md): Generated C library with type-safe device streams - - [C++ SDK](../clients/cpp.md): Header-only C++20 library (requires external JSON parser) - - [C# SDK](../clients/csharp.md): Generated .NET library with async/await support - - [TypeScript SDK](../clients/typescript.md): Generated Node.js library with EventEmitter streams - - [Rust SDK](../clients/rust.md): Generated Rust library with sync/async support + - [C Client Library](../clients/c.md): Generated C library with type-safe device streams + - [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. @@ -36,36 +36,36 @@ Tip: You can experiment with `nc`/`ncat` or PowerShell’s `tcpclient` to send l The server registers the following commands and streams: - `bus/list` - - List all virtual bus IDs. - - Response: `{ "buses": [1, 2, ...] }` + - List all virtual bus IDs. + - Response: `{ "buses": [1, 2, ...] }` - `bus/create [busId]` - - Create a new bus. If `busId` (numeric) is provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. - - Payload: Optional numeric bus ID (e.g., `5`) - - Response: `{ "busId": }` + - Create a new bus. If `busId` (numeric) is provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. + - Payload: Optional numeric bus ID (e.g., `5`) + - Response: `{ "busId": }` - `bus/remove ` - - Remove a bus and all devices on it. - - Payload: Numeric bus ID (e.g., `1`) - - Response: `{ "busId": }` + - Remove a bus and all devices on it. + - Payload: Numeric bus ID (e.g., `1`) + - Response: `{ "busId": }` - `bus/{id}/list` - - List devices on a bus. - - Response: `{ "devices": [{ "busId": 1, "devId": "1", "vid": "0x045e", "pid": "0x028e", "type": "xbox360" }, ...] }` + - List devices on a bus. + - Response: `{ "devices": [{ "busId": 1, "devId": "1", "vid": "0x045e", "pid": "0x028e", "type": "xbox360" }, ...] }` - `bus/{id}/add ` - - Add a device to a bus. - - Payload: JSON object with device creation parameters: `{"type": "", "idVendor": , "idProduct": }` - - Example: `{"type": "xbox360"}` or `{"type": "keyboard", "idVendor": 1234, "idProduct": 5678}` - - Response: JSON device object with fields: `{"busId": , "devId": "", "vid": "0x045e", "pid": "0x028e", "type": "xbox360"}` - - 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. - - 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). + - Add a device to a bus. + - Payload: JSON object with device creation parameters: `{"type": "", "idVendor": , "idProduct": }` + - Example: `{"type": "xbox360"}` or `{"type": "keyboard", "idVendor": 1234, "idProduct": 5678}` + - Response: JSON device object with fields: `{"busId": , "devId": "", "vid": "0x045e", "pid": "0x028e", "type": "xbox360"}` + - 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. + - 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 (missing tool, non-zero exit) are logged but do not affect the API response. - `bus/{id}/remove ` - - Remove a device by its device number on that bus. - - Payload: Numeric device ID (e.g., `1` for device 1-1 on the bus) - - Response: `{ "busId": , "devId": "" }` + - Remove a device by its device number on that bus. + - Payload: Numeric device ID (e.g., `1` for device 1-1 on the bus) + - Response: `{ "busId": , "devId": "" }` ### Streaming endpoint @@ -81,14 +81,14 @@ The server registers the following commands and streams: Direction: client ➜ server (input state) - 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) + - `Buttons` uint32 (4 bytes) + - `LT` uint8, `RT` uint8 (2 bytes) + - `LX, LY, RX, RY` int16 each (8 bytes) Direction: server ➜ client (rumble) - Fixed 2-byte packets: - - `LeftMotor` uint8, `RightMotor` uint8 + - `LeftMotor` uint8, `RightMotor` uint8 See `/device/xbox360/protocol.go` for full details. @@ -97,13 +97,13 @@ See `/device/xbox360/protocol.go` for full details. Direction: client ➜ server (keys pressed) - Variable-length packets per frame: - - Header: Modifiers uint8, KeyCount uint8 - - Body: KeyCount bytes of HID Usage IDs for currently pressed (non-modifier) keys + - Header: Modifiers uint8, KeyCount uint8 + - Body: KeyCount bytes of HID Usage IDs for currently pressed (non-modifier) keys Direction: server ➜ client (LED state) - 1-byte packets whenever host LED state changes: - - Bit 0 NumLock, Bit 1 CapsLock, Bit 2 ScrollLock + - Bit 0 NumLock, Bit 1 CapsLock, Bit 2 ScrollLock Host-facing HID input report is 34 bytes: [Modifiers (1), Reserved (1), 256-bit key bitmap (32)]. @@ -114,9 +114,9 @@ See `/device/keyboard/` for helpers and constants. Direction: client ➜ server (motion/buttons) - Fixed 5-byte packets per frame: - - Buttons uint8 (bits 0..4) - - dX int8, dY int8 - - Wheel int8, Pan int8 + - Buttons uint8 (bits 0..4) + - dX int8, dY int8 + - Wheel int8, Pan int8 Direction: server ➜ client @@ -127,9 +127,9 @@ Note: Motion and wheel deltas are consumed after each IN report so movement is r Note on protocol compatibility: - 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. + - 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. ## Example sessions diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index 3c8f63d1..557dc849 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -1,6 +1,6 @@ # 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. ## Usage @@ -16,7 +16,7 @@ Scans the VIIPER server codebase to extract: - Device wire formats from `viiper:wire` comment tags - Device constants (keycodes, modifiers, button masks) -Then generates client SDKs with: +Then generates client libraries with: - Management API clients - Device-agnostic stream wrappers @@ -30,7 +30,7 @@ Then generates client SDKs with: ### `--output` -Output directory for generated SDKs (relative to repository root). +Output directory for generated client libraries (relative to repository root). **Default:** `clients` **Environment Variable:** `VIIPER_CODEGEN_OUTPUT` @@ -38,7 +38,7 @@ Output directory for generated SDKs (relative to repository root). **Example:** ```bash -viiper codegen --output=../sdk-output +viiper codegen --output=../client-libs-output ``` ### `--lang` @@ -52,28 +52,28 @@ Target language to generate. **Examples:** ```bash -# Generate all SDKs +# Generate all client libraries viiper codegen --lang=all -# Generate C SDK only +# Generate C client library only viiper codegen --lang=c -# Generate C# SDK only +# Generate C# client library only viiper codegen --lang=csharp -# Generate TypeScript SDK only +# Generate TypeScript client library only viiper codegen --lang=typescript ``` ## Examples -### Generate All SDKs +### Generate All Client Libraries ```bash go run ./cmd/viiper codegen ``` -### Generate C SDK and Rebuild Examples +### Generate C Client Library and Rebuild Examples ```bash go run ./cmd/viiper codegen --lang=c @@ -94,5 +94,5 @@ Run codegen when any of these change: ## 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 +- [C Client Library Documentation](../clients/c.md): C-specific usage and build instructions - [Configuration](configuration.md): Global configuration options diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 73f46159..6725b3cc 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -6,7 +6,7 @@ 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 +- [`codegen`](codegen.md) - Generate client libraries from source code annotations ## Global Options diff --git a/docs/clients/c.md b/docs/clients/c.md index 249d9a0f..25103899 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -1,10 +1,10 @@ -# C SDK Documentation +# C Client Library Documentation -The VIIPER C SDK provides a lightweight, dependency-free client library for interacting with VIIPER servers and controlling virtual devices. +The VIIPER C client library provides a lightweight, dependency-free client library for interacting with VIIPER servers and controlling virtual devices. ## Overview -The C SDK features: +The C client library features: - **Device-agnostic streaming API**: Uniform interface for all device types - **Zero dependencies**: Pure C99, no external libraries required @@ -13,20 +13,20 @@ The C SDK features: - **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 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 ### Building from Source -The C SDK is generated from the VIIPER server codebase: +The C client library is generated from the VIIPER server codebase: ```bash go run ./cmd/viiper codegen --lang=c ``` -Build the SDK: +Build the client library: ```bash cd ../clients/c @@ -40,7 +40,7 @@ cmake --build build --config Release **CMake:** ```cmake -# Add viiper SDK +# Add viiper client library add_subdirectory(path/to/clients/c) target_link_libraries(your_target PRIVATE viiper) @@ -212,7 +212,7 @@ viiper_device_close(device); 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`). +The C client library provides generated structs and constants in device-specific headers (e.g., `viiper_keyboard.h`, `viiper_mouse.h`, `viiper_xbox360.h`). ## Struct Packing @@ -287,10 +287,10 @@ cmake --build build --config Release ## See Also -- [Generator Documentation](generator.md): How the C SDK is generated +- [Generator Documentation](generator.md): How the C client library is generated - [Go Client Documentation](go.md): Reference implementation -- [C++ SDK Documentation](cpp.md): Header-only C++ SDK -- [C# SDK Documentation](csharp.md): .NET SDK -- [Rust SDK Documentation](rust.md): Rust SDK -- [TypeScript SDK Documentation](typescript.md): Node.js SDK +- [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/cpp.md b/docs/clients/cpp.md index 55951e66..7d3cb1db 100644 --- a/docs/clients/cpp.md +++ b/docs/clients/cpp.md @@ -1,10 +1,10 @@ -# C++ SDK Documentation +# C++ Client Library Documentation -The VIIPER C++ SDK provides a modern, header-only C++20 client library for interacting with VIIPER servers and controlling virtual devices. +The VIIPER C++ client library provides a modern, header-only C++20 client library for interacting with VIIPER servers and controlling virtual devices. ## Overview -The C++ SDK features: +The C++ client library features: - **Header-only**: No separate compilation required, just include and use - **Modern C++20**: Uses concepts, designated initializers, std::optional, smart pointers @@ -14,12 +14,12 @@ The C++ SDK features: - **Cross-platform**: Windows (MSVC) and POSIX (GCC/Clang) !!! warning "JSON Parser Required" - The C++ SDK 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 SDK headers. - + 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. !!! note "License" - The C++ SDK is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + 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 @@ -61,11 +61,11 @@ target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json) go run ./cmd/viiper codegen --lang=cpp ``` -The SDK will be generated in `clients/cpp/include/viiper/`. +The client library will be generated in `clients/cpp/include/viiper/`. ## JSON Parser Configuration -Before including the VIIPER SDK, you must configure a JSON parser. The SDK is designed to work with any JSON library that provides a compatible interface. +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) @@ -295,7 +295,7 @@ The device is also automatically stopped when the `ViiperDevice` is destroyed. ## Generated Constants and Maps -The C++ SDK automatically generates constants and helper maps for each device type. +The C++ client library automatically generates constants and helper maps for each device type. ### Keyboard Constants @@ -322,7 +322,7 @@ bool caps_lock = (leds & viiper::keyboard::LEDCapsLock) != 0; ### Helper Maps -The SDK generates useful lookup maps for working with keyboard input: +The client library generates useful lookup maps for working with keyboard input: **CHAR_TO_KEY Map** - Convert ASCII characters to key codes: @@ -507,9 +507,9 @@ clients/cpp/include/viiper/ - **JSON library** (nlohmann/json recommended) - **Platform**: Windows (MSVC 2019+) or POSIX (GCC 10+, Clang 10+) -### Windows-specific +### Windows-Specific -The SDK uses Winsock2 for networking. Link against `Ws2_32.lib` (done automatically via `#pragma comment`). +The client library uses Winsock2 for networking. Link against `Ws2_32.lib` (done automatically via `#pragma comment`). ### POSIX-specific @@ -547,11 +547,11 @@ viiper server --api-addr localhost:3242 ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [C SDK Documentation](c.md): Pure C alternative -- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support -- [C# SDK Documentation](csharp.md): .NET SDK -- [TypeScript SDK Documentation](typescript.md): Node.js SDK +- [Generator Documentation](generator.md): How generated client libraries work +- [C Client Library Documentation](c.md): Pure C alternative +- [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 index 1bb57394..0549aeda 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -1,10 +1,10 @@ -# C# SDK Documentation +# C# Client Library Documentation -The VIIPER C# SDK provides a modern, type-safe .NET client library for interacting with VIIPER servers and controlling virtual devices. +The VIIPER C# client library provides a modern, type-safe .NET client library for interacting with VIIPER servers and controlling virtual devices. ## Overview -The C# SDK features: +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 @@ -13,7 +13,7 @@ The C# SDK features: - **Zero external dependencies**: Uses only built-in .NET libraries !!! note "License" - The C# SDK is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + 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 @@ -206,7 +206,7 @@ await using var device = await client.ConnectDeviceAsync(busId, deviceId); ## Generated Constants and Maps -The C# SDK automatically generates enums and helper maps for each device type. +The C# client library automatically generates enums and helper maps for each device type. ### Keyboard Constants @@ -235,7 +235,7 @@ bool capsLock = (leds & (byte)LED.CapsLock) != 0; ### Helper Maps -The SDK generates useful lookup maps for working with keyboard input: +The client library generates useful lookup maps for working with keyboard input: **CharToKey Map** - Convert ASCII characters to key codes: @@ -457,7 +457,7 @@ dotnet run -- localhost ## Project Structure -Generated SDK layout: +Generated client library layout: ```text clients/csharp/Viiper.Client/ @@ -502,12 +502,12 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [Go SDK Documentation](go.md): Reference implementation patterns -- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support -- [TypeScript SDK Documentation](typescript.md): Node.js SDK -- [C SDK Documentation](c.md): Alternative SDK for native integration -- [C++ SDK Documentation](cpp.md): Header-only C++ SDK +- [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](c.md): Alternative client library for native integration +- [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 1e65bcf9..a365301c 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,18 +10,18 @@ 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 -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=c # Generate C client library only +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) @@ -218,14 +218,13 @@ Run codegen when any of these change: - **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 - [Go Client Documentation](go.md): Go reference client usage -- [C SDK Documentation](c.md): C-specific usage, build, and examples -- [C# SDK Documentation](csharp.md): C#-specific usage, async patterns, and map helpers -- [TypeScript SDK Documentation](typescript.md): TypeScript-specific usage, EventEmitter patterns, and examples +- [C Client Library Documentation](c.md): C-specific usage, build, and examples +- [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 bbadcc5a..cd089c86 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -229,10 +229,10 @@ Full working examples are available in the repository: ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [C SDK Documentation](c.md): Generated C SDK usage -- [C++ SDK Documentation](cpp.md): Header-only C++ SDK -- [C# SDK Documentation](csharp.md): .NET SDK -- [Rust SDK Documentation](rust.md): Rust SDK -- [TypeScript SDK Documentation](typescript.md): Node.js SDK +- [Generator Documentation](generator.md): How generated client libraries work +- [C Client Library Documentation](c.md): Generated C client library usage +- [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 index b1a10404..bbd91541 100644 --- a/docs/clients/rust.md +++ b/docs/clients/rust.md @@ -1,10 +1,10 @@ -# Rust SDK Documentation +# Rust Client Library Documentation -The VIIPER Rust SDK provides a type-safe, zero-cost abstraction client library for interacting with VIIPER servers and controlling virtual devices. +The VIIPER Rust client library provides a type-safe, zero-cost abstraction client library for interacting with VIIPER servers and controlling virtual devices. ## Overview -The Rust SDK features: +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 @@ -13,14 +13,14 @@ The Rust SDK features: - **Tokio-based async**: Optional `async` feature for async/await support with Tokio runtime !!! note "License" - The Rust SDK is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + 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 SDK using Cargo: +Install the client library using Cargo: ```bash cargo add viiper-client @@ -289,7 +289,7 @@ stream.on_output(|reader| { ## Generated Constants and Maps -The Rust SDK generates constants and lazy-static maps for each device type. +The Rust client library generates constants and lazy-static maps for each device type. ### Keyboard Constants @@ -322,7 +322,7 @@ let caps_lock = (leds & LED_CAPS_LOCK) != 0; ### Helper Maps -The SDK generates useful lookup maps for working with keyboard input: +The client library generates useful lookup maps for working with keyboard input: **CHAR_TO_KEY** - Convert ASCII characters to key codes: @@ -462,7 +462,7 @@ pub struct MouseInput { ## Error Handling -The SDK uses a custom `ViiperError` type for all errors: +The client library uses a custom `ViiperError` type for all errors: ```rust use viiper_client::ViiperError; @@ -489,7 +489,7 @@ if let Err(ViiperError::Protocol(problem)) = result { ## Features -The Rust SDK supports optional features: +The Rust client library supports optional features: | Feature | Description | Dependencies | |---------|-------------|--------------| @@ -540,7 +540,7 @@ cargo run --release -p virtual_x360_pad_async -- localhost:3242 ## Project Structure -Generated SDK layout: +Generated client library layout: ```text clients/rust/ @@ -587,12 +587,12 @@ viiper-client = { version = "0.1", features = ["async"] } ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [Go SDK Documentation](go.md): Reference implementation patterns -- [C# SDK Documentation](csharp.md): Alternative managed language SDK -- [TypeScript SDK Documentation](typescript.md): Node.js SDK -- [C SDK Documentation](c.md): Native C SDK -- [C++ SDK Documentation](cpp.md): Header-only C++ SDK +- [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](c.md): Native C 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 index 222062ae..30221f25 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -1,10 +1,10 @@ -# TypeScript SDK Documentation +# TypeScript Client Library Documentation -The VIIPER TypeScript SDK provides a modern, type-safe Node.js client library for interacting with VIIPER servers and controlling virtual devices. +The VIIPER TypeScript client library provides a modern, type-safe Node.js client library for interacting with VIIPER servers and controlling virtual devices. ## Overview -The TypeScript SDK features: +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) @@ -13,14 +13,14 @@ The TypeScript SDK features: - **Zero external dependencies**: Uses only built-in Node.js libraries !!! note "License" - The TypeScript SDK is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. + 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 NPM Package (Recommended) +### 1. Using the Published Package (Recommended) -Install the SDK from the public npm registry: +Install the client library from the public npm registry: ```bash npm install viiperclient @@ -295,7 +295,7 @@ const capsLock = (leds & LED.CapsLock) !== 0; ### Helper Maps -The SDK generates useful lookup maps for working with keyboard input: +The client library generates useful lookup maps for working with keyboard input: **CharToKey Map** - Convert ASCII characters to key codes: @@ -512,12 +512,12 @@ Some quick troubleshooting tips for the TypeScript SDK and device streams: ## See Also -- [Generator Documentation](generator.md): How generated SDKs work -- [Go SDK Documentation](go.md): Reference implementation patterns -- [C# SDK Documentation](csharp.md): Alternative managed language SDK -- [Rust SDK Documentation](rust.md): Rust SDK with sync/async support -- [C SDK Documentation](c.md): Alternative SDK for native integration -- [C++ SDK Documentation](cpp.md): Header-only C++ SDK +- [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](c.md): Alternative client library for native integration +- [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/keyboard.md b/docs/devices/keyboard.md index 1ba047fc..aaf1b0c2 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -8,12 +8,12 @@ A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, plu - OUT: 0x01 (LED output report) - Device type id (for API add): `keyboard` -## Client SDK Support +## Client Library Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/keyboard`), and **generated SDKs** provide equivalent structures with proper packing. +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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) +See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) ## HID report format (host-facing) @@ -68,7 +68,7 @@ printf "bus/create\0" | nc localhost 3242 printf 'bus/1/add {"type":"keyboard"}\0' | nc localhost 3242 ``` -Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. +Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. ## Examples diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index 80638b10..8801a0fc 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -6,12 +6,12 @@ A standard 5-button mouse with vertical and horizontal scroll wheels. Reports re - Interface/Endpoint: IN 0x81 (mouse input report) - Device type id (for API add): `mouse` -## Client SDK Support +## Client Library Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/mouse`), and **generated SDKs** provide equivalent structures with proper packing. +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: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) +See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) ## HID report format (host-facing) @@ -46,7 +46,7 @@ printf "bus/create\0" | nc localhost 3242 printf 'bus/1/add {"type":"mouse"}\0' | nc localhost 3242 ``` -Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. +Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. ## Examples diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 1cc13583..32e48a71 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -6,12 +6,12 @@ The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most - Interfaces/Endpoints: single HID interface with one IN interrupt endpoint and one OUT interrupt endpoint for rumble - Device type id (for API add): `xbox360` -## Client SDK Support +## Client Library Support -The wire protocol is abstracted by client SDKs. The **Go client** includes built-in types (`/device/xbox360`), and **generated SDKs** provide equivalent structures with proper packing. +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. You don't need to manually construct packets, just use the provided types and send them via the device stream. -See: [Go Client](../clients/go.md), [Generated SDKs](../clients/generator.md) +See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) ## Adding the device @@ -27,7 +27,7 @@ printf 'bus/1/add {"type":"xbox360"}\0' | nc localhost 3242 The API returns a Device object with `busId`, `devId`, and other details. Attach it from a USB/IP client, then open a stream to drive input and receive rumble. -Or use one of the [client SDKs](../clients/generator.md) which handle the protocol automatically. +Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. ## Streaming protocol diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f055a258..52f80d1b 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -37,22 +37,22 @@ viiper server --usb.addr=:9000 --api.addr=:9001 VIIPER provides multiple ways to interact with the API. Choose the method that works best for you. -### Option 1: Using Client SDKs (Recommended) +### Option 1: Using Client Libraries (Recommended) -Client SDKs are available for C, C#, Go, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. +Client libraries are available for C, C#, Go, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. -For complete SDK documentation and code examples, see: +For complete client library documentation and code examples, see: -- [C SDK Documentation](../clients/c.md) -- [C# SDK Documentation](../clients/csharp.md) -- [TypeScript SDK Documentation](../clients/typescript.md) +- [C Client Library Documentation](../clients/c.md) +- [C# Client Library Documentation](../clients/csharp.md) +- [TypeScript Client Library Documentation](../clients/typescript.md) - [Go Client Documentation](../clients/go.md) Full working examples for all device types are available in the `examples/` directory of the repository. ### Option 2: Using Raw TCP (netcat) -For quick testing without SDKs: +For quick testing without client libraries: ```bash # Create a bus @@ -139,7 +139,7 @@ Now that you have a working setup: 1. **Explore Examples**: Check the `examples/` directory for complete working programs in C, C#, Go, and TypeScript 2. **Read API Documentation**: Learn about all available [API commands](../api/overview.md) -3. **Choose an SDK**: Pick a [client SDK](../clients/generator.md) for your preferred language +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 @@ -213,5 +213,5 @@ If you have VIIPER running as a service, your application's instance may conflic - [CLI Reference](../cli/overview.md) - Complete command documentation - [API Reference](../api/overview.md) - Management API protocol -- [Client SDKs](../clients/generator.md) - Language-specific client libraries +- [Client Libraries](../clients/generator.md) - Language-specific client libraries - [Configuration](../cli/configuration.md) - Environment variables and config files diff --git a/docs/index.md b/docs/index.md index 25165a57..41680ec8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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](api/overview.md) +- ✅ Multiple client libraries for easy integration; see [Client Libraries](api/overview.md) MIT Licensed --- diff --git a/internal/cmd/codegen.go b/internal/cmd/codegen.go index 37bd7223..95b04b6d 100644 --- a/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -7,7 +7,7 @@ import ( ) type Codegen struct { - Output string `help:"Output directory for generated SDKs (repo-root relative). Default resolves to /clients" default:"./clients" env:"VIIPER_CODEGEN_OUTPUT"` + 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"` } diff --git a/internal/codegen/common/fileheader.go b/internal/codegen/common/fileheader.go index d6067b5e..f3273d6f 100644 --- a/internal/codegen/common/fileheader.go +++ b/internal/codegen/common/fileheader.go @@ -6,5 +6,5 @@ import "fmt" // 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 SDK\n%s DO NOT EDIT - This file is generated from the VIIPER server codebase\n\n", commentPrefix, langLabel, commentPrefix) + 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/readme.go b/internal/codegen/common/readme.go index 84767547..6e42b66c 100644 --- a/internal/codegen/common/readme.go +++ b/internal/codegen/common/readme.go @@ -7,9 +7,9 @@ import ( "path/filepath" ) -const readmeTemplate = `# VIIPER Client SDK +const readmeTemplate = `# VIIPER Client Library -This is an automatically generated client SDK for [VIIPER](https://github.com/Alia5/VIIPER) - **Virtual** **I**nput over **IP** **E**mulato**R** +This is an automatically generated client library for [VIIPER](https://github.com/Alia5/VIIPER) - **Virtual** **I**nput over **IP** **E**mulato**R** ## Documentation diff --git a/internal/codegen/generator/c/gen.go b/internal/codegen/generator/c/gen.go index 7083214e..36588f07 100644 --- a/internal/codegen/generator/c/gen.go +++ b/internal/codegen/generator/c/gen.go @@ -60,6 +60,6 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - logger.Info("Generated C SDK", "dir", outputDir) + logger.Info("Generated C client library", "dir", outputDir) return nil } diff --git a/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go index 4f308634..f4592ef9 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -13,7 +13,7 @@ import ( const commonHeaderTmpl = `#ifndef VIIPER_H #define VIIPER_H -/* Auto-generated VIIPER - C SDK: common header */ +/* Auto-generated VIIPER - C Client Library: common header */ #ifdef __cplusplus extern "C" { @@ -78,7 +78,7 @@ typedef struct { {{end}} {{end}} -/* Free helpers for DTOs (call to release memory allocated by the SDK) */ +/* Free helpers for DTOs (call to release memory allocated by the client library) */ {{range .DTOs}} {{if ne .Name "Device"}} VIIPER_API void viiper_free_{{snakecase .Name}}(viiper_{{snakecase .Name}}_t* v); @@ -141,7 +141,7 @@ VIIPER_API viiper_error_t viiper_device_send( ); /* Register callback for device output (device → client, async) - * User provides buffer - SDK reads directly into it and calls callback with byte count */ + * User provides buffer - client library reads directly into it and calls callback with byte count */ VIIPER_API void viiper_device_on_output( viiper_device_t* device, void* buffer, diff --git a/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go index 04499459..465d2507 100644 --- a/internal/codegen/generator/c/header_device.go +++ b/internal/codegen/generator/c/header_device.go @@ -15,7 +15,7 @@ import ( const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H #define VIIPER_{{upper .Device}}_H -/* Auto-generated VIIPER - C SDK: {{.Device}} device */ +/* Auto-generated VIIPER - C Client Library: {{.Device}} device */ /* Minimal path fix: include common header without duplicated module path */ #include "viiper.h" diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go index 77a7de0f..136f33fe 100644 --- a/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -10,7 +10,7 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -const commonSourceTmpl = `/* Auto-generated VIIPER - C SDK: common source */ +const commonSourceTmpl = `/* Auto-generated VIIPER - C Client Library: common source */ #include "viiper.h" #include diff --git a/internal/codegen/generator/c/source_device.go b/internal/codegen/generator/c/source_device.go index 98b63d7d..a94645c2 100644 --- a/internal/codegen/generator/c/source_device.go +++ b/internal/codegen/generator/c/source_device.go @@ -12,7 +12,7 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/scanner" ) -const deviceSourceTmpl = `/* Auto-generated VIIPER - C SDK: device source ({{.Device}}) */ +const deviceSourceTmpl = `/* Auto-generated VIIPER - C Client Library: device source ({{.Device}}) */ #include "viiper.h" #include "viiper_{{.Device}}.h" diff --git a/internal/codegen/generator/cpp/config.go b/internal/codegen/generator/cpp/config.go index 825873e7..4adc670a 100644 --- a/internal/codegen/generator/cpp/config.go +++ b/internal/codegen/generator/cpp/config.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -const configTemplate = `// Auto-generated VIIPER C++ SDK +const configTemplate = `// Auto-generated VIIPER C++ Client Library // DO NOT EDIT - This file is generated from the VIIPER server codebase #pragma once @@ -16,7 +16,7 @@ const configTemplate = `// Auto-generated VIIPER C++ SDK // JSON Parser Configuration // ============================================================================ // -// VIIPER C++ SDK requires a JSON library for parsing API responses. +// 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) diff --git a/internal/codegen/generator/cpp/device.go b/internal/codegen/generator/cpp/device.go index 4eac0cc2..ae658586 100644 --- a/internal/codegen/generator/cpp/device.go +++ b/internal/codegen/generator/cpp/device.go @@ -9,7 +9,7 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -const deviceTemplate = `// Auto-generated VIIPER C++ SDK +const deviceTemplate = `// Auto-generated VIIPER C++ Client Library // DO NOT EDIT - This file is generated from the VIIPER server codebase #pragma once diff --git a/internal/codegen/generator/cpp/error.go b/internal/codegen/generator/cpp/error.go index 2601d044..5efd38b6 100644 --- a/internal/codegen/generator/cpp/error.go +++ b/internal/codegen/generator/cpp/error.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -const errorTemplate = `// Auto-generated VIIPER C++ SDK +const errorTemplate = `// Auto-generated VIIPER C++ Client Library // DO NOT EDIT - This file is generated from the VIIPER server codebase #pragma once @@ -19,7 +19,7 @@ const errorTemplate = `// Auto-generated VIIPER C++ SDK namespace viiper { // ============================================================================ -// Error (simplified, generic like Rust SDK) +// Error (simplified, generic like Rust client library) // ============================================================================ struct Error { diff --git a/internal/codegen/generator/cpp/gen.go b/internal/codegen/generator/cpp/gen.go index 2ac1889b..2953814c 100644 --- a/internal/codegen/generator/cpp/gen.go +++ b/internal/codegen/generator/cpp/gen.go @@ -74,6 +74,6 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - logger.Info("Generated C++ SDK", "dir", outputDir) + logger.Info("Generated C++ client library", "dir", outputDir) return nil } diff --git a/internal/codegen/generator/cpp/json.go b/internal/codegen/generator/cpp/json.go index cb089161..df2c893d 100644 --- a/internal/codegen/generator/cpp/json.go +++ b/internal/codegen/generator/cpp/json.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -const jsonTemplate = `// Auto-generated VIIPER C++ SDK +const jsonTemplate = `// Auto-generated VIIPER C++ Client Library // DO NOT EDIT - This file is generated from the VIIPER server codebase #pragma once diff --git a/internal/codegen/generator/cpp/main_header.go b/internal/codegen/generator/cpp/main_header.go index 46cbebef..74b33f28 100644 --- a/internal/codegen/generator/cpp/main_header.go +++ b/internal/codegen/generator/cpp/main_header.go @@ -14,7 +14,7 @@ const mainHeaderTemplate = `{{.Header}} #pragma once // ============================================================================ -// VIIPER C++ SDK - Header-Only Library +// VIIPER C++ Client Library - Header-Only Library // ============================================================================ // // Version: {{.Major}}.{{.Minor}}.{{.Patch}} diff --git a/internal/codegen/generator/cpp/socket.go b/internal/codegen/generator/cpp/socket.go index 6a40e78f..c4e7084a 100644 --- a/internal/codegen/generator/cpp/socket.go +++ b/internal/codegen/generator/cpp/socket.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -const socketTemplate = `// Auto-generated VIIPER C++ SDK +const socketTemplate = `// Auto-generated VIIPER C++ Client Library // DO NOT EDIT - This file is generated from the VIIPER server codebase #pragma once diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go index 73727480..1444f0e0 100644 --- a/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -66,6 +66,6 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - logger.Info("Generated C# SDK", "dir", outputDir) + logger.Info("Generated C# client library", "dir", outputDir) return nil } diff --git a/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go index a2c2fb99..dd96fb2a 100644 --- a/internal/codegen/generator/csharp/project.go +++ b/internal/codegen/generator/csharp/project.go @@ -22,7 +22,7 @@ const projectTemplate = ` Viiper.Client {{.Version}} Peter Repukat - VIIPER Client SDK for C# + VIIPER Client Library for C# MIT https://github.com/Alia5/VIIPER https://github.com/Alia5/VIIPER diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index 774de30b..6820614b 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -40,7 +40,7 @@ func New(outputDir string, logger *slog.Logger) *Generator { func (g *Generator) GenAll() error { for lang := range generators { if err := g.GenerateLang(lang); err != nil { - return fmt.Errorf("generate %s SDK: %w", lang, err) + return fmt.Errorf("generate %s client library: %w", lang, err) } } return nil @@ -56,7 +56,7 @@ func (g *Generator) GenerateLang(lang string) error { return fmt.Errorf("unsupported language '%s' (supported: %v)", lang, supported) } - g.logger.Info("Generating SDK", "language", lang) + g.logger.Info("Generating client library", "language", lang) md, err := g.ScanAll() if err != nil { @@ -72,7 +72,7 @@ func (g *Generator) GenerateLang(lang string) error { return err } - g.logger.Info("SDK generation complete", "language", lang, "output", outputPath) + g.logger.Info("Client library generation complete", "language", lang, "output", outputPath) return nil } diff --git a/internal/codegen/generator/rust/gen.go b/internal/codegen/generator/rust/gen.go index 321ed9ec..b6674480 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -89,7 +89,7 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - logger.Info("Generated Rust SDK", "dir", projectDir) + logger.Info("Generated Rust client library", "dir", projectDir) return nil } diff --git a/internal/codegen/generator/rust/project.go b/internal/codegen/generator/rust/project.go index 7bb3c498..d3cdeeda 100644 --- a/internal/codegen/generator/rust/project.go +++ b/internal/codegen/generator/rust/project.go @@ -14,7 +14,7 @@ version = "{{.Version}}" edition = "2021" rust-version = "1.70" authors = ["Peter Repukat"] -description = "VIIPER Client SDK for Rust" +description = "VIIPER Client Library for Rust" license = "MIT" repository = "https://github.com/Alia5/VIIPER" homepage = "https://github.com/Alia5/VIIPER" diff --git a/internal/codegen/generator/typescript/gen.go b/internal/codegen/generator/typescript/gen.go index 8a02c39d..807fd612 100644 --- a/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -71,6 +71,6 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - logger.Info("Generated TypeScript SDK", "dir", projectDir) + logger.Info("Generated TypeScript client library", "dir", projectDir) return nil } diff --git a/internal/codegen/generator/typescript/project.go b/internal/codegen/generator/typescript/project.go index 3fd23621..87650516 100644 --- a/internal/codegen/generator/typescript/project.go +++ b/internal/codegen/generator/typescript/project.go @@ -9,10 +9,10 @@ import ( "text/template" ) -const packageTemplate = `{ +const packageJSONTemplate = `{ "name": "viiperclient", "version": "{{.Version}}", - "description": "VIIPER Client SDK for TypeScript/Node.js", + "description": "VIIPER Client Library for TypeScript/Node.js", "license": "MIT", "repository": { "type": "git", @@ -76,7 +76,7 @@ func generateProject(logger *slog.Logger, projectDir, version string) error { } func packageTemplateFor(version string) string { - tmpl, err := template.New("pkg").Parse(packageTemplate) + tmpl, err := template.New("pkg").Parse(packageJSONTemplate) if err != nil { return "{}" } diff --git a/internal/config/config.go b/internal/config/config.go index dfd8706c..7119cebb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,5 +21,5 @@ type CLI struct { 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"` + Codegen cmd.Codegen `cmd:"" help:"Generate client libraries from server code"` } diff --git a/mkdocs.yml b/mkdocs.yml index 593be0de..6ab2660a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,11 +69,11 @@ nav: - API Overview: api/overview.md - Go Client: clients/go.md - Generator Documentation: clients/generator.md - - C SDK: clients/c.md - - C++ SDK: clients/cpp.md - - C# SDK: clients/csharp.md - - Rust SDK: clients/rust.md - - TypeScript SDK: clients/typescript.md + - C Client Library: clients/c.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 - Devices: - Xbox 360 Controller: devices/xbox360.md - Keyboard: devices/keyboard.md From 9fda3321d3cbd6d93c4c79883c5e1aad3fc323ec Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 05:48:35 +0100 Subject: [PATCH 061/298] Update github release markdown descriptions --- .github/scripts/generate-changelog.sh | 8 ++++---- .github/workflows/release.yml | 3 --- .github/workflows/snapshots.yml | 3 --- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 2a7561bb..6fb4f839 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -86,7 +86,7 @@ done # Write changelog { - echo "# Changelog for $VERSION_TITLE" + echo "## Changelog for $VERSION_TITLE" echo "" if [[ -z "$TAG_OR_RANGE" ]]; then echo "This page shows unreleased changes in the development version." @@ -98,21 +98,21 @@ done 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/release.yml b/.github/workflows/release.yml index f19c6d35..e1c3988d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,9 +220,6 @@ jobs: 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 diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 2e2da652..e3cbfbe3 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -113,13 +113,10 @@ jobs: 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/* From 1564343412d06f49b589f05d8276810e9c9b2be2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 16:52:01 +0100 Subject: [PATCH 062/298] Update README.md / docs --- README.md | 67 +++++++++++++++++---- docs/getting-started/installation.md | 23 +++++--- docs/index.md | 88 +++++++++++++++++++++++++--- 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index b682f683..ac177772 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The binary is portable and can be bundled with your application. 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. +VIIPER provides a liehgtweight TCP based API for feeder application development. ### ✨ Features @@ -44,7 +44,7 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ 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) + 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) @@ -53,11 +53,10 @@ All devices _**can and must be**_ controlled programmatically via an API. - ✅ API server for device/bus management and controlling virtual devices programmatically - ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) MIT Licensed +- 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. ## 🔌 Requirements -VIIPER is a standalone binary that requires USBIP. - **Linux:** - **Arch Linux:** @@ -72,23 +71,49 @@ VIIPER is a standalone binary that requires USBIP. - [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). -## 🔌 API +--- + +## 🥫 Feeder application development + +VIIPER _currently_ comes in a single flavor: + +- a standalone executable that exposes an API over TCP. +- There will eventually be a library version (libVIIPER) that you can link against directly from your application. +For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) + +### 🔌 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. + +> ⚠️ Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. +See [Client Libraries Available](docs/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 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. +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. -See the [API documentation](./docs/api) for details (🚧 in progress 🚧). +See the [API documentation](./docs/api) for details -## 🛠️ Development +--- + +## 🛠️ VIIPER development ### 🧰 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` - + - Linux/macOS: Usually pre-installed + - Windows: `winget install ezwinports.make` ### 🔄 Building from Source @@ -107,12 +132,16 @@ make help # Show all available targets make test # Run tests ``` +--- + ## 🤝 Contributing 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. +--- + ## ❓ FAQ ### What is USBIP and why does VIIPER use it? @@ -120,6 +149,15 @@ See the [issues page](https://github.com/Alia5/VIIPER/issues) for bugs and featu 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 is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself + +- 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 + - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. +- **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. + Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) + ### Can I use VIIPER for gaming? Yes! VIIPER can create virtual controllers (currently only Xbox360) that appear as real hardware to games and applications. @@ -134,7 +172,8 @@ This makes VIIPER portable, easier to extend, and simpler to bundle with applica ### Can I add support for other device types? Yes! VIIPER's architecture is designed to be extensible. -Check the [xbox360 device implementation](.//device/xbox360/) as a reference for creating new device types. +Check the [xbox360 device implementation](./device/xbox360/) as a reference for creating new device types. +In the future there will be a plugin system to load and expose device types dynamically. ### What about the proxy mode? @@ -147,6 +186,8 @@ Useful for reverse engineering USB protocols and understanding how devices commu 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](docs/testing/e2e_latency.md). +--- + ## 📄 License ```license diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index c8cee872..918f2418 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,8 +1,15 @@ # Installation +VIIPER _currently_ comes in a single flavor: + +- a standalone executable that exposes an API over TCP. +- There will eventually be a shared-library version (libVIIPER) that you can link against directly from your application. +For more information, see [FAQ](https://github.com/Alia5/VIIPER#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) + ## Requirements -VIIPER relies on USBIP. You must have USBIP installed on your system. +VIIPER relies on USBIP. +You must have a USBIP-Client implementation available on your system to use VIIPER's virtual devices. ### Linux @@ -62,13 +69,6 @@ If you don't plan to use the auto-attach feature, you can skip this setup and di ## Installing VIIPER -### Pre-built Binaries (Recommended) - -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) - ### Portable Deployment VIIPER does not require system-wide installation. @@ -88,6 +88,13 @@ This makes VIIPER ideal for embedding in applications or distributing as part of - Use a custom port via `--api.addr` flag to run a separate instance - Check if VIIPER is already running before starting their own instance +### 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) + ### Building from Source Building from source is only necessary if you need to modify VIIPER or target an unsupported platform. diff --git a/docs/index.md b/docs/index.md index 41680ec8..8e929946 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,26 +26,100 @@ The binary is portable and can be bundled with your application. 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. +VIIPER provides a liehgtweight TCP based API for feeder application development. -## Key Features +### ✨ Features - ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation (virtual device); see [Devices › Xbox 360 Controller](devices/xbox360.md) + - ✅ 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) + - 🔜 ??? + 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) -- ✅ Proxy mode: forward real USB devices and inspect/record traffic +- ✅ 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 libraries for easy integration; see [Client Libraries](api/overview.md) MIT Licensed +- 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. --- -### Additional Resources / Misc +## 🥫 Feeder application development -- [E2E Latency Benchmarks](testing/e2e_latency.md) +VIIPER _currently_ comes in a single flavor: + +- a standalone executable that exposes an API over TCP. +- There will eventually be a shared-library version (libVIIPER) that you can link against directly from your application. +For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) + +### 🔌 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. + +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 is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself + +- 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 + - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. +- **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. + Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) + +### Can I use VIIPER for gaming? + +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. + +### How is VIIPER different from other controller emulators? + +Most controller emulators require custom kernel drivers for each device type. +VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without kernel drivers. +This makes VIIPER portable, easier to extend, and simpler to bundle with applications. + +### Can I add support for other device types? + +Yes! VIIPER's architecture is designed to be extensible. +In the future there will be a plugin system to load and expose device types dynamically. + +### 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). From d02234137d0130b4bd3f321348cf11b9fd3263cd Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 21:39:46 +0100 Subject: [PATCH 063/298] Fix: free bus IDs not getting re-used Changelog(fix) --- internal/server/api/handler/bus_create.go | 3 ++- internal/server/usb/server.go | 12 ++++++++++++ virtualbus/virtualbus.go | 16 +++++----------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go index d5f082cb..0f0ae211 100644 --- a/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -36,7 +36,8 @@ func BusCreate(s *usb.Server) api.HandlerFunc { return nil } - b := virtualbus.New() + busId := s.NextFreeBusID() + b := virtualbus.New(busId) if err := s.AddBus(b); err != nil { return api.ErrInternal(fmt.Sprintf("failed to add bus: %v", err)) } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 3983dc5e..c7cdc7b7 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -195,6 +195,18 @@ func (s *Server) GetBus(busID uint32) *virtualbus.VirtualBus { 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++ + } +} + // ListenAndServe starts the USB-IP server and handles incoming connections. func (s *Server) ListenAndServe() error { ln, err := net.Listen("tcp", s.config.Addr) diff --git a/virtualbus/virtualbus.go b/virtualbus/virtualbus.go index 8b39a7f9..505676e1 100644 --- a/virtualbus/virtualbus.go +++ b/virtualbus/virtualbus.go @@ -15,9 +15,8 @@ import ( 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 + allocatedBusIds = make(map[uint32]bool) + globalMutex sync.Mutex ) // VirtualBus manages USB bus topology and auto-assigns device addresses. @@ -38,19 +37,14 @@ type DeviceMeta struct { } // New creates a new VirtualBus instance with a unique auto-assigned bus number. -func New() *VirtualBus { +func New(busID uint32) *VirtualBus { globalMutex.Lock() defer globalMutex.Unlock() - busId := globalBusCounter - if busId == 0 { - busId = 1 - } - globalBusCounter = busId + 1 - allocatedBusIds[busId] = true + allocatedBusIds[busID] = true return &VirtualBus{ - busId: busId, + busId: busID, nextDevID: 0, allocatedDevIDs: make(map[uint32]bool), } From fbb1e00a4940e29598338aad55d54872017df4be Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 6 Dec 2025 22:48:52 +0100 Subject: [PATCH 064/298] Update README/docs --- README.md | 16 ++++++++++------ docs/index.md | 26 ++++++++++++++------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ac177772..6476e68f 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,18 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. -VIIPER is a self-contained, standalone binary that uses USBIP to handle the USB protocol layer. -Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. -Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. -The binary is portable and can be bundled with your application. +- VIIPER abstracts away all USB / USBIP details. +- Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. +- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. +VIIPER _currently_ comes in a single flavor: + +- a self-contained, (no dependencies) portable, standalone executable. + providing a lightweight TCP based API for feeder application development. +- There will eventually be a library version (libVIIPER) that you can link against directly from your application. +For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) -VIIPER provides a liehgtweight TCP based API for feeder application development. +Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. ### ✨ Features diff --git a/docs/index.md b/docs/index.md index 8e929946..9965de9a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,6 @@ # VIIPER Documentation -Welcome to the VIIPER documentation! - VIIPER is a tool to create virtual input devices using USBIP. ## Quick Links @@ -19,22 +17,26 @@ VIIPER is a tool to create virtual input devices using USBIP. 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. -VIIPER is a self-contained, standalone binary that uses USBIP to handle the USB protocol layer. -Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. -Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. -The binary is portable and can be bundled with your application. +- VIIPER abstracts away all USB / USBIP details. +- Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. +- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. -Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. +VIIPER _currently_ comes in a single flavor: -VIIPER provides a liehgtweight TCP based API for feeder application development. +- a self-contained, (no dependencies) portable, standalone executable. + providing a lightweight TCP based API for feeder application development. +- There will eventually be a library version (libVIIPER) that you can link against directly from your application. +For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) + +Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. ### ✨ 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) - - 🔜 ??? + - ✅ 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) + - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) From 873895ec242a038f3adb3170af62ebc4aac580fb Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 7 Dec 2025 00:26:06 +0100 Subject: [PATCH 065/298] Fix go version info for local builds Breaks x86, though, whatever --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 523ee640..eb31767a 100644 --- a/Makefile +++ b/Makefile @@ -133,7 +133,7 @@ ifeq ($(OS),Windows_NT) @echo Generating Windows version info... @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(VERSIONINFO_JSON)" "versioninfo.tmp.json" - @cd $(SRC_DIR) && goversioninfo -o $(RESOURCE_SYSO) versioninfo.tmp.json + @cd $(SRC_DIR) && goversioninfo -64 -o $(RESOURCE_SYSO) versioninfo.tmp.json @del versioninfo.tmp.json else @echo Skipping versioninfo generation on non-Windows platform From 5f5bb0dba206bd35acf881e84bc445990ce74508 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 13 Dec 2025 02:02:36 +0100 Subject: [PATCH 066/298] Update README.md --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6476e68f..9c5e1c5e 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,8 @@ **Virtual** **I**nput over **IP** **E**mulato**R** -VIIPER is a tool to create virtual input devices using USBIP. - -## ℹ️ 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. +VIIPER creates virtual USB input devices using USBIP via a simple API. +These virtual devices appear as real hardware, indistinguishable to the operating system and applications, allowing you to emulate controllers, keyboards, and other input devices as you see fit. - VIIPER abstracts away all USB / USBIP details. - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. From 3631997633a72f5e21b7486be327c8fd2e55fe88 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 16 Dec 2025 22:52:52 +0100 Subject: [PATCH 067/298] Breaking: Use int16 instead of int8 for mouse relative actions (dx, dy, wheel, pan) Changelog(feat) --- device/mouse/device.go | 25 +++++--- device/mouse/handler.go | 2 +- device/mouse/inputstate.go | 64 +++++++++++-------- docs/clients/typescript.md | 10 +-- docs/devices/mouse.md | 14 ++-- examples/cpp/virtual_mouse.cpp | 4 +- examples/csharp/virtual_mouse/Program.cs | 6 +- examples/go/virtual_mouse/main.go | 4 +- examples/rust/async/virtual_mouse/src/main.rs | 2 +- examples/rust/sync/virtual_mouse/src/main.rs | 2 +- examples/typescript/virtual_mouse.ts | 2 +- 11 files changed, 74 insertions(+), 61 deletions(-) diff --git a/device/mouse/device.go b/device/mouse/device.go index 30c1ec59..f6146682 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -92,17 +92,22 @@ var hidReportDescriptor = []byte{ 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x02, // Report Count (2) + 0x81, 0x06, // Input (Data, Variable, Relative) 0x09, 0x38, // Usage (Wheel) - 0x15, 0x81, // Logical Minimum (-127) - 0x25, 0x7F, // Logical Maximum (127) - 0x75, 0x08, // Report Size (8) - 0x95, 0x03, // Report Count (3) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x01, // Report Count (1) 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) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) 0x95, 0x01, // Report Count (1) 0x81, 0x06, // Input (Data, Variable, Relative) 0xC0, // End Collection @@ -150,9 +155,9 @@ var defaultDescriptor = usb.Descriptor{ Endpoints: []usb.EndpointDescriptor{ { BEndpointAddress: 0x81, - BMAttributes: 0x03, // Interrupt - WMaxPacketSize: 0x0008, - BInterval: 0x0A, // 10 ms + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 0x0010, // 16 bytes (9 needed) + BInterval: 0x0A, // 10 ms }, }, }, diff --git a/device/mouse/handler.go b/device/mouse/handler.go index 206d61f8..b12d0f5a 100644 --- a/device/mouse/handler.go +++ b/device/mouse/handler.go @@ -29,7 +29,7 @@ func (r *handler) StreamHandler() api.StreamHandlerFunc { return fmt.Errorf("device is not mouse") } - buf := make([]byte, 5) + buf := make([]byte, 9) for { if _, err := io.ReadFull(conn, buf); err != nil { if err == io.EOF { diff --git a/device/mouse/inputstate.go b/device/mouse/inputstate.go index c427326a..a003b79c 100644 --- a/device/mouse/inputstate.go +++ b/device/mouse/inputstate.go @@ -5,57 +5,65 @@ import ( ) // InputState represents the mouse state used to build a report. -// viiper:wire mouse c2s buttons:u8 dx:i8 dy:i8 wheel:i8 pan:i8 +// viiper:wire mouse c2s buttons:u8 dx:i16 dy:i16 wheel:i16 pan:i16 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 + // Delta X/Y: signed 16-bit relative movement + DX, DY int16 + // Wheel: signed 16-bit vertical scroll + Wheel int16 + // Pan: signed 16-bit horizontal scroll + Pan int16 } -// BuildReport encodes an InputState into the 5-byte HID mouse report. +// BuildReport encodes an InputState into the 9-byte HID mouse report. // -// Report layout (5 bytes): +// Report layout (9 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) +// Bytes 1-2: DX (int16 little-endian, -32768 to +32767) +// Bytes 3-4: DY (int16 little-endian) +// Bytes 5-6: Wheel (int16 little-endian) +// Bytes 7-8: Pan (int16 little-endian) func (st InputState) BuildReport() []byte { - b := make([]byte, 5) + b := make([]byte, 9) 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) + b[2] = byte(st.DX >> 8) + b[3] = byte(st.DY) + b[4] = byte(st.DY >> 8) + b[5] = byte(st.Wheel) + b[6] = byte(st.Wheel >> 8) + b[7] = byte(st.Pan) + b[8] = byte(st.Pan >> 8) return b } -// MarshalBinary encodes InputState to 5 bytes. +// MarshalBinary encodes InputState to 9 bytes. func (m *InputState) MarshalBinary() ([]byte, error) { - b := make([]byte, 5) + b := make([]byte, 9) b[0] = m.Buttons b[1] = byte(m.DX) - b[2] = byte(m.DY) - b[3] = byte(m.Wheel) - b[4] = byte(m.Pan) + 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 5 bytes into InputState. +// UnmarshalBinary decodes 9 bytes into InputState. func (m *InputState) UnmarshalBinary(data []byte) error { - if len(data) < 5 { + if len(data) < 9 { 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]) + 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/docs/clients/typescript.md b/docs/clients/typescript.md index 30221f25..0553871a 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -423,14 +423,14 @@ const rightMotor = data.readUInt8(1); ```typescript interface MouseInput { Buttons: number; // Button flags - Dx: number; // Relative X movement (-128 to 127) - Dy: number; // Relative Y movement (-128 to 127) - Wheel: number; // Vertical scroll (-128 to 127) - Pan: number; // Horizontal scroll (-128 to 127) + Dx: number; // Relative X movement (-32768 to 32767) + Dy: number; // Relative Y movement (-32768 to 32767) + Wheel: number; // Vertical scroll (-32768 to 32767) + Pan: number; // Horizontal scroll (-32768 to 32767) } ``` -**Wire format:** Fixed 5 bytes, packed structure +**Wire format:** Fixed 9 bytes, int16 values little-endian ## Configuration and Advanced Usage diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index 8801a0fc..9990fd00 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -15,13 +15,13 @@ See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/gene ## HID report format (host-facing) -Input (device → host): 5 bytes +Input (device → host): 9 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) +- Bytes 1-2: X delta (int16 little-endian, -32768 to +32767) +- Bytes 3-4: Y delta (int16 little-endian, -32768 to +32767) +- Bytes 5-6: Vertical wheel (int16 little-endian; positive up) +- Bytes 7-8: Horizontal wheel/pan (int16 little-endian; positive right) Deltas are consumed after each IN report so motion is truly relative and not repeated across host polls. @@ -29,8 +29,8 @@ Deltas are consumed after each IN report so motion is truly relative and not rep Wire format from your client into VIIPER: -- Fixed 5-byte packets matching the HID report layout: - [Buttons, dX, dY, Wheel, Pan] +- Fixed 9-byte packets matching the HID report layout: + [Buttons, dX_lo, dX_hi, dY_lo, dY_hi, Wheel_lo, Wheel_hi, Pan_lo, Pan_hi] Buttons persist until changed; motion/wheel deltas are applied once and reset. diff --git a/examples/cpp/virtual_mouse.cpp b/examples/cpp/virtual_mouse.cpp index 517e787b..bec5181a 100644 --- a/examples/cpp/virtual_mouse.cpp +++ b/examples/cpp/virtual_mouse.cpp @@ -93,8 +93,8 @@ int main(int argc, char** argv) { while (running && stream->is_connected()) { // Move diagonally: (+step,+step) then (-step,-step) next tick - std::int8_t dx = static_cast(step * dir); - std::int8_t dy = static_cast(step * dir); + std::int16_t dx = static_cast(step * dir); + std::int16_t dy = static_cast(step * dir); dir *= -1; // One-shot movement report (diagonal) diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs index 7fd699c6..ecd88352 100644 --- a/examples/csharp/virtual_mouse/Program.cs +++ b/examples/csharp/virtual_mouse/Program.cs @@ -64,12 +64,12 @@ async Task Cleanup() // Send movement/click/scroll every 3s var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); -sbyte dir = 1; const sbyte step = 50; +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 = (sbyte)(step * dir); - var dy = (sbyte)(step * dir); + 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 }); diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index 7feeedeb..854b661d 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -88,8 +88,8 @@ func main() { 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 + 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 { diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs index 177835c7..48674916 100644 --- a/examples/rust/async/virtual_mouse/src/main.rs +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -89,7 +89,7 @@ async fn main() { // 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 + 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 { diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs index 38ff434e..3dbb59f7 100644 --- a/examples/rust/sync/virtual_mouse/src/main.rs +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -95,7 +95,7 @@ fn main() { // 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 + 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 diff --git a/examples/typescript/virtual_mouse.ts b/examples/typescript/virtual_mouse.ts index c76b5a80..6a6aedfb 100644 --- a/examples/typescript/virtual_mouse.ts +++ b/examples/typescript/virtual_mouse.ts @@ -75,7 +75,7 @@ async function main() { // 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 + 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."); From a9dd1202f2c77c341d5e8586abee363552935b91 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 17 Dec 2025 11:43:27 +0100 Subject: [PATCH 068/298] Update pipeline --- .github/scripts/generate-changelog.sh | 6 ------ .github/workflows/release.yml | 4 +--- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 6fb4f839..1388ee4d 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -88,12 +88,6 @@ done { 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 "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1c3988d..927c093d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,10 +216,8 @@ jobs: uses: softprops/action-gh-release@v1 with: tag_name: ${{ steps.build_info.outputs.tag_name }} - name: "Release ${{ steps.build_info.outputs.version }}" + name: "VIIPER ${{ steps.build_info.outputs.version }}" body: | - ## VIIPER Release ${{ steps.build_info.outputs.version }} - ${{ needs.generate-changelog.outputs.changelog }} files: release_files/* prerelease: false From 667e92c9551ff8191f624f612d25d50d54565022 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 17 Dec 2025 20:45:06 +0100 Subject: [PATCH 069/298] Add install command / scripts Changelog(feat) --- .github/workflows/docs-deploy.yml | 18 +++ docs/cli/overview.md | 2 + docs/getting-started/installation.md | 116 +++++++++++++++-- docs/index.md | 8 +- internal/cmd/install.go | 50 ++++++++ internal/cmd/install_linux.go | 98 ++++++++++++++ internal/cmd/install_windows.go | 185 +++++++++++++++++++++++++++ internal/config/config.go | 6 +- scripts/install.ps1 | 53 ++++++++ scripts/install.sh | 61 +++++++++ 10 files changed, 580 insertions(+), 17 deletions(-) create mode 100644 internal/cmd/install.go create mode 100644 internal/cmd/install_linux.go create mode 100644 internal/cmd/install_windows.go create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a5a893bd..87067b01 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -34,6 +34,24 @@ jobs: 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} + + # Validate version format (must be semantic version) + 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: | diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 6725b3cc..c73e8780 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -6,6 +6,8 @@ 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 +- `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/getting-started/installation.md b/docs/getting-started/installation.md index 918f2418..3a37d112 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -65,14 +65,10 @@ Check if the module is loaded: lsmod | grep vhci_hcd ``` -If you don't plan to use the auto-attach feature, you can skip this setup and disable it with `--api.auto-attach-local-client=false`. - ## Installing VIIPER -### Portable Deployment - VIIPER does not require system-wide installation. -The `viiper` executable is completely self-contained (and statically linked) and can be: +The `viiper` executable is completely self-contained (and fully portable without any dependencies, except USBIP) and can be: - Placed in any directory - Shipped alongside your application @@ -82,11 +78,10 @@ The `viiper` executable is completely self-contained (and statically linked) and This makes VIIPER ideal for embedding in applications or distributing as part of a software package. !!! 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 either: - + 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 - Connect to the existing VIIPER instance (if accessible) - Use a custom port via `--api.addr` flag to run a separate instance - - Check if VIIPER is already running before starting their own instance ### Pre-built Binaries @@ -95,11 +90,110 @@ Download the latest release from the [GitHub Releases](https://github.com/Alia5/ - Windows (x64, ARM64) - Linux (x64, ARM64) -### Building from Source +### Automated Install Script + +Regardless of portability, it can be convenient to have VIIPER start automatically on system boot, especially if end users want to use your application through a network or you want to enable that possibility. + +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're 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. + +!!! warning "USBIP not included" + The install scripts do **not** install/setup USBIP. + Make sure a USBIP-client is installed and configured before installing VIIPER. + +**Linux:** + +```bash +curl -fsSL https://alia5.github.io/VIIPER/install.sh | sh +``` + +Installs to: `/usr/local/bin/viiper` + +**Windows (PowerShell):** + +```powershell +irm https://alia5.github.io/VIIPER/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. Configure automatic startup (Registry RunKey on Windows, systemd service on Linux) +4. Start the VIIPER server + +**Version-Specific Installation:** + +The install scripts are version-aware based on where you download them from: + +- **Latest stable release:** + `curl -fsSL https://alia5.github.io/VIIPER/install.sh | sh` + +- **Specific version (e.g., v0.2.2):** + `curl -fsSL https://alia5.github.io/VIIPER/0.2.2/install.sh | sh` + +- **Latest _pre_-release (development snapshot):** + `curl -fsSL https://alia5.github.io/VIIPER/main/install.sh | sh` + + +## System Startup Configuration + +The `install` and `uninstall` commands configure automatic startup for the VIIPER binary. + +!!! info "What These Commands Do" + These commands **do not copy or move** the VIIPER binary. They configure your system to automatically run the binary from its **current location** when the system boots. + + Make sure the binary is in a permanent location before running `viiper install`! + +### `viiper install` + +Configures VIIPER to start automatically on system boot: + +```bash +viiper install +``` + +- **Windows**: + - Adds entry to Registry RunKey: `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\VIIPER` + - Value: `"" server` + - Kills any previous autorun instances + - Starts the server + +- **Linux**: + - Creates systemd service: `/etc/systemd/system/viiper.service` + - Service ExecStart points to current binary path + - Enables and starts the service + +### `viiper uninstall` + +Removes VIIPER from system startup and stops any running instance: + +```bash +viiper uninstall +``` + +- **Windows**: + - Removes Registry RunKey entry + - Kills any running autorun instances + +- **Linux**: + - Stops and disables the systemd service + - Removes `/etc/systemd/system/viiper.service` + +## Building from Source Building from source is only necessary if you need to modify VIIPER or target an unsupported platform. -#### Prerequisites +### Prerequisites - [Go](https://go.dev/) 1.25 or newer - USBIP installed @@ -107,7 +201,7 @@ Building from source is only necessary if you need to modify VIIPER or target an - Linux/macOS: Usually pre-installed - Windows: `winget install ezwinports.make` -#### Build Steps +### Build Steps ```bash git clone https://github.com/Alia5/VIIPER.git diff --git a/docs/index.md b/docs/index.md index 9965de9a..fcf5598f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,10 +33,10 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio ### ✨ 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) - - 🔜 ??? + - ✅ 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) + - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) 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..b42ea07f --- /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=%q +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..033fe416 --- /dev/null +++ b/internal/cmd/install_windows.go @@ -0,0 +1,185 @@ +//go:build windows + +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "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 + } + + value := fmt.Sprintf("%q server", exePath) + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) + if err != nil { + return err + } + defer key.Close() + + 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").Start(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + logger.Info("VIIPER install completed for Windows autorun", "exe", exePath) + 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() + + 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() + + 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/internal/config/config.go b/internal/config/config.go index 7119cebb..a8bbcb2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,8 @@ type CLI struct { 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 libraries from server code"` + Config cmd.ConfigCommand `cmd:"" help:"Manage configuration files"` + Codegen cmd.Codegen `cmd:"" help:"Generate client libraries from server code"` + 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/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..538196e0 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = "Stop" + +$viiperVersion = "dev-snapshot" + +$repo = "Alia5/VIIPER" +$apiUrl = "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") { + $arch = "arm64" +} + +$binaryName = "viiper-windows-$arch.exe" +$downloadUrl = "https://github.com/$repo/releases/download/$version/$binaryName" + +Write-Host "Downloading from: $downloadUrl" +$tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } + +try { + $tempViiper = Join-Path $tempDir "viiper.exe" + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempViiper -ErrorAction Stop + + $installDir = Join-Path $env:LOCALAPPDATA "VIIPER" + $installPath = Join-Path $installDir "viiper.exe" + + Write-Host "Installing binary to $installPath..." + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Copy-Item $tempViiper $installPath -Force + + Write-Host "Configuring system startup..." + & $installPath install + + Write-Host "VIIPER installed successfully!" -ForegroundColor Green + Write-Host "Binary installed to: $installPath" + Write-Host "VIIPER server is now running and will start automatically on boot." +} finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 00000000..2cb17efa --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,61 @@ +#!/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=$(echo "$RELEASE_DATA" | grep -o '"tag_name":"[^"]*' | cut -d'"' -f4) + +if [ -z "$VERSION" ]; then + echo "Error: Could not fetch VIIPER release" >&2 + 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" >&2 + echo "Supported: x86_64 (amd64), aarch64/arm64" >&2 + exit 1 + ;; +esac + +BINARY_NAME="viiper-linux-${ARCH}" +DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}" + +echo "Downloading from: $DOWNLOAD_URL" +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +cd "$TEMP_DIR" +if ! curl -fsSL -o viiper "$DOWNLOAD_URL"; then + echo "Error: Could not download VIIPER binary" >&2 + exit 1 +fi + +chmod +x viiper + +INSTALL_DIR="/usr/local/bin" +INSTALL_PATH="$INSTALL_DIR/viiper" + +echo "Installing binary to $INSTALL_PATH..." +sudo mkdir -p "$INSTALL_DIR" +sudo cp viiper "$INSTALL_PATH" +sudo chmod +x "$INSTALL_PATH" + +echo "Configuring system startup..." +sudo "$INSTALL_PATH" install + +echo "VIIPER installed successfully!" +echo "Binary installed to: $INSTALL_PATH" +echo "VIIPER server is now running and will start automatically on boot." From c676a24b54d699bae427245eb530fc04c8991b26 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 17 Dec 2025 21:20:17 +0100 Subject: [PATCH 070/298] Update docs --- docs/getting-started/quickstart.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 52f80d1b..5105aa0d 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -23,7 +23,8 @@ This starts two services: - **API Server** on port `3242` (device management) !!! 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`. + 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. ### Custom Ports @@ -209,6 +210,22 @@ Ensure the device is attached via USBIP AND you've opened a device stream via th 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 From bba6246ed722d612a688cfaccff2748280cf019c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 18 Dec 2025 01:57:30 +0100 Subject: [PATCH 071/298] Fix benchmarks when real controllers present + add steamdeck bench results --- docs/testing/e2e_latency.md | 11 ++++++++++- testing/e2e/bench_test.go | 27 ++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index a579af9d..2d943b3d 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -40,6 +40,15 @@ cd testing/e2e 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 % | @@ -58,4 +67,4 @@ Use a larger `-count` if you want to increase the number of runs. - `% 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 any other game controllers connected and without an already running VIIPER server instance. +- Benchmarks must be run without an already running VIIPER server instance. diff --git a/testing/e2e/bench_test.go b/testing/e2e/bench_test.go index a1ad61c0..0cba95cc 100644 --- a/testing/e2e/bench_test.go +++ b/testing/e2e/bench_test.go @@ -98,6 +98,13 @@ func Benchmark_Xbox360_Delay(b *testing.B) { defer sdl.Quit() sdl.Init(sdl.INIT_GAMEPAD) + sdl.UpdateGamepads() + existingGamepads, _ := sdl.GetGamepads() + existingGamepadSet := make(map[sdl.JoystickID]bool) + for _, id := range existingGamepads { + existingGamepadSet[id] = true + } + s := cmd.Server{ UsbServerConfig: usb.ServerConfig{ Addr: ":3241", @@ -108,7 +115,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, }, - ConnectionTimeout: 5, + ConnectionTimeout: 5 * time.Second, } logger := slog.Default() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -138,6 +145,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { 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) @@ -148,18 +156,23 @@ func Benchmark_Xbox360_Delay(b *testing.B) { for range 10 { sdl.UpdateGamepads() gIDs, _ := sdl.GetGamepads() - if len(gIDs) > 0 { - gamepad, err = gIDs[0].OpenGamepad() - defer gamepad.Close() - if err != nil { - b.Fatalf("OpenGamepad failed: %v", err) + for _, id := range gIDs { + if !existingGamepadSet[id] { + gamepad, err = id.OpenGamepad() + 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 gamepad found for testing") + b.Fatalf("No new gamepad found for testing (expected VIIPER virtual device)") } padChann := make(chan bool) prevPadPressed := false From b4f078fc3f350722e96a51f873fae7d12301ef7c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 18 Dec 2025 02:20:23 +0100 Subject: [PATCH 072/298] Disable nagles algorithm Changelog(fix) --- apiclient/stream.go | 7 +++++++ apiclient/transport.go | 8 ++++++++ internal/codegen/generator/c/source_common.go | 12 +++++++++++- internal/codegen/generator/cpp/socket.go | 5 +++++ internal/codegen/generator/csharp/client.go | 2 ++ internal/codegen/generator/rust/async_client.go | 4 +++- internal/codegen/generator/rust/client.go | 4 +++- internal/codegen/generator/typescript/client.go | 3 ++- internal/server/api/server.go | 5 +++++ internal/server/proxy/server.go | 5 +++++ internal/server/usb/server.go | 5 +++++ 11 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apiclient/stream.go b/apiclient/stream.go index ecc9de85..73254295 100644 --- a/apiclient/stream.go +++ b/apiclient/stream.go @@ -6,6 +6,7 @@ import ( "encoding" "fmt" "io" + "log/slog" "net" "sync" "time" @@ -39,6 +40,12 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D 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) + } + } + streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { conn.Close() diff --git a/apiclient/transport.go b/apiclient/transport.go index 2e5e259f..7a0f1644 100644 --- a/apiclient/transport.go +++ b/apiclient/transport.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/url" "strings" @@ -91,6 +92,13 @@ func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar return "", fmt.Errorf("dial: %w", err) } defer conn.Close() + + 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.cfg.WriteTimeout > 0 { _ = conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) } diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go index 136f33fe..c541a2af 100644 --- a/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -24,6 +24,7 @@ static int viiper_wsa_init_once = 0; #else # include # include +# include # include # include # include @@ -219,7 +220,16 @@ static int viiper_connect(const char* host, uint16_t port) { 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 (connect(s, p->ai_addr, (int)p->ai_addrlen) == 0) { + int flag = 1; +#if defined(_WIN32) || defined(_WIN64) + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&flag, sizeof(flag)); +#else + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); +#endif + fd = s; + break; + } #if defined(_WIN32) || defined(_WIN64) closesocket(s); #else diff --git a/internal/codegen/generator/cpp/socket.go b/internal/codegen/generator/cpp/socket.go index c4e7084a..b95ba6ef 100644 --- a/internal/codegen/generator/cpp/socket.go +++ b/internal/codegen/generator/cpp/socket.go @@ -31,6 +31,7 @@ const socketTemplate = `// Auto-generated VIIPER C++ Client Library #else #include #include +#include #include #include #include @@ -144,6 +145,10 @@ public: 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()) { diff --git a/internal/codegen/generator/csharp/client.go b/internal/codegen/generator/csharp/client.go index 78a01afa..280c143d 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -56,6 +56,7 @@ public class ViiperClient : IDisposable { using var client = new TcpClient(); await client.ConnectAsync(_host, _port, cancellationToken); + client.NoDelay = true; using var stream = client.GetStream(); @@ -102,6 +103,7 @@ public class ViiperClient : IDisposable { var client = new TcpClient(); await client.ConnectAsync(_host, _port, cancellationToken); + client.NoDelay = true; var stream = client.GetStream(); // Streaming handshake uses null terminator (same framing as management). var streamPath = $"bus/{{lb}}busId{{rb}}/{{lb}}devId{{rb}}\0"; diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index 0f96030d..72f141b7 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -39,6 +39,7 @@ impl AsyncViiperClient { payload: Option<&str>, ) -> Result { let mut stream = TcpStream::connect(self.addr).await?; + stream.set_nodelay(true)?; stream.write_all(path.as_bytes()).await?; if let Some(p) = payload { @@ -91,7 +92,8 @@ pub struct AsyncDeviceStream { impl AsyncDeviceStream { pub async fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { let mut stream = TcpStream::connect(addr).await?; - let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.set_nodelay(true)?; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); stream.write_all(handshake.as_bytes()).await?; let (reader, writer) = stream.into_split(); Ok(Self { diff --git a/internal/codegen/generator/rust/client.go b/internal/codegen/generator/rust/client.go index cf8b7ef2..7ac1c564 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -35,6 +35,7 @@ impl ViiperClient { payload: Option<&str>, ) -> Result { let mut stream = TcpStream::connect(self.addr)?; + stream.set_nodelay(true)?; stream.write_all(path.as_bytes())?; if let Some(p) = payload { @@ -83,7 +84,8 @@ pub struct DeviceStream { impl DeviceStream { pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { let mut stream = TcpStream::connect(addr)?; - let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.set_nodelay(true)?; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); stream.write_all(handshake.as_bytes())?; Ok(Self { stream, diff --git a/internal/codegen/generator/typescript/client.go b/internal/codegen/generator/typescript/client.go index 6249c57b..366aed0f 100644 --- a/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -49,6 +49,7 @@ export class ViiperClient { return new Promise((resolve, reject) => { const socket = new Socket(); socket.connect(this.port, this.host, () => { + socket.setNoDelay(true); let line = path; // preserve case if (payload && payload.length > 0) line += ' ' + payload; line += '\0'; @@ -69,7 +70,6 @@ export class ViiperClient { reject(e); return; } - // Typed error detection (RFC 7807 style) if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { socket.end(); reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); @@ -89,6 +89,7 @@ export class ViiperClient { return new Promise((resolve, reject) => { const socket = new Socket(); socket.connect(this.port, this.host, () => { + socket.setNoDelay(true); const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; socket.write(encoder.encode(line)); resolve(new ViiperDevice(socket)); diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 52ab98f9..0914a72a 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -79,6 +79,11 @@ func (a *Server) serve() { a.logger.Info("API accept error", "error", err) return } + if tcpConn, ok := c.(*net.TCPConn); ok { + if err := tcpConn.SetNoDelay(true); err != nil { + a.logger.Warn("failed to set TCP_NODELAY", "error", err) + } + } go a.handleConn(c) } } diff --git a/internal/server/proxy/server.go b/internal/server/proxy/server.go index 4236daca..52464dc8 100644 --- a/internal/server/proxy/server.go +++ b/internal/server/proxy/server.go @@ -50,6 +50,11 @@ func (s *Server) ListenAndServe() error { 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) } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index c7cdc7b7..489c53b7 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -226,6 +226,11 @@ func (s *Server) ListenAndServe() error { 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 { From f0229f4d75e73c2fee2ba2cea916bdee6c091e64 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 18 Dec 2025 17:12:24 +0100 Subject: [PATCH 073/298] gofmt --- apiclient/client_test.go | 250 +-- apiclient/stream.go | 398 ++--- apiclient/stream_test.go | 376 ++--- apiclient/transport_test.go | 310 ++-- cmd/viiper/meta.go | 144 +- device/context.go | 68 +- device/keyboard/const.go | 672 ++++----- device/keyboard/device.go | 428 +++--- device/keyboard/handler.go | 174 +-- device/keyboard/helpers.go | 174 +-- device/keyboard/inputstate.go | 228 +-- device/mouse/const.go | 22 +- device/mouse/device.go | 350 ++--- device/mouse/handler.go | 98 +- device/mouse/inputstate.go | 138 +- device/options.go | 12 +- device/report.go | 14 +- device/xbox360/const.go | 40 +- device/xbox360/handler.go | 120 +- device/xbox360/inputstate.go | 208 +-- examples/go/virtual_keyboard/main.go | 316 ++-- examples/go/virtual_mouse/main.go | 304 ++-- examples/go/virtual_x360_pad/main.go | 318 ++-- internal/cmd/codegen.go | 48 +- internal/cmd/config.go | 418 ++--- internal/cmd/proxy.go | 96 +- internal/cmd/server.go | 190 +-- internal/codegen/cmd/scan-constants/main.go | 132 +- internal/codegen/cmd/scan-dtos/main.go | 66 +- internal/codegen/cmd/scan-routes/main.go | 80 +- internal/codegen/common/enums.go | 76 +- internal/codegen/common/fileheader.go | 20 +- internal/codegen/common/gotypes.go | 38 +- internal/codegen/common/license.go | 92 +- internal/codegen/common/naming.go | 206 +-- internal/codegen/common/readme.go | 76 +- internal/codegen/common/version.go | 92 +- internal/codegen/common/wiresize.go | 214 +-- internal/codegen/generator/c/cmake.go | 174 +-- internal/codegen/generator/c/gen.go | 130 +- internal/codegen/generator/c/header_common.go | 428 +++--- internal/codegen/generator/c/header_device.go | 200 +-- internal/codegen/generator/c/helpers.go | 1100 +++++++------- internal/codegen/generator/c/source_common.go | 1342 ++++++++--------- internal/codegen/generator/c/source_device.go | 108 +- internal/codegen/generator/cpp/client.go | 336 ++--- internal/codegen/generator/cpp/json.go | 232 +-- internal/codegen/generator/cpp/socket.go | 744 ++++----- internal/codegen/generator/csharp/client.go | 450 +++--- .../codegen/generator/csharp/constants.go | 874 +++++------ internal/codegen/generator/csharp/device.go | 336 ++--- .../codegen/generator/csharp/device_types.go | 434 +++--- internal/codegen/generator/csharp/gen.go | 142 +- internal/codegen/generator/csharp/helpers.go | 100 +- internal/codegen/generator/csharp/project.go | 134 +- internal/codegen/generator/csharp/types.go | 172 +-- internal/codegen/generator/generator.go | 328 ++-- .../codegen/generator/rust/async_client.go | 510 +++---- internal/codegen/generator/rust/client.go | 400 ++--- internal/codegen/generator/rust/constants.go | 450 +++--- .../codegen/generator/rust/device_types.go | 360 ++--- internal/codegen/generator/rust/error.go | 134 +- internal/codegen/generator/rust/gen.go | 350 ++--- internal/codegen/generator/rust/helpers.go | 330 ++-- internal/codegen/generator/rust/project.go | 202 +-- internal/codegen/generator/rust/types.go | 276 ++-- internal/codegen/generator/rust/wire.go | 90 +- .../generator/typescript/binary_utils.go | 112 +- .../codegen/generator/typescript/client.go | 398 ++--- .../codegen/generator/typescript/constants.go | 464 +++--- .../generator/typescript/device_types.go | 344 ++--- .../generator/typescript/device_wrapper.go | 126 +- internal/codegen/generator/typescript/gen.go | 152 +- .../codegen/generator/typescript/helpers.go | 42 +- .../codegen/generator/typescript/index.go | 154 +- .../codegen/generator/typescript/project.go | 172 +-- .../codegen/generator/typescript/types.go | 136 +- internal/codegen/meta/meta.go | 26 +- internal/codegen/scanner/constants_test.go | 112 +- internal/codegen/scanner/dtos.go | 378 ++--- internal/codegen/scanner/dtos_test.go | 184 +-- internal/codegen/scanner/payload.go | 840 +++++------ internal/codegen/scanner/returns.go | 248 +-- internal/codegen/scanner/routes.go | 347 +++-- internal/codegen/scanner/routes_test.go | 176 +-- internal/codegen/scanner/wiretags.go | 272 ++-- internal/configpaths/files.go | 208 +-- internal/log/rawlogger.go | 122 +- internal/registry/devices.go | 14 +- internal/server/api/autoattach.go | 24 +- internal/server/api/autoattach_linux.go | 152 +- internal/server/api/autoattach_windows.go | 558 +++---- internal/server/api/config.go | 24 +- internal/server/api/config_unix.go | 14 +- internal/server/api/config_windows.go | 14 +- internal/server/api/device_registry.go | 118 +- internal/server/api/device_registry_test.go | 352 ++--- internal/server/api/device_stream_handler.go | 108 +- .../server/api/device_stream_handler_test.go | 268 ++-- internal/server/api/errors.go | 58 +- .../server/api/handler/bus_create_test.go | 186 +-- .../server/api/handler/bus_device_add_test.go | 362 ++--- .../api/handler/bus_device_remove_test.go | 188 +-- .../api/handler/bus_devices_list_test.go | 218 +-- internal/server/api/handler/bus_list_test.go | 116 +- .../server/api/handler/bus_remove_test.go | 228 +-- internal/server/api/server_test.go | 138 +- internal/server/proxy/parser.go | 678 ++++----- internal/server/proxy/server.go | 378 ++--- internal/server/usb/config.go | 20 +- internal/testing/mocks.go | 74 +- testing/e2e/bench_test.go | 492 +++--- testing/e2e/scripts/lat_bench.go | 718 ++++----- 113 files changed, 14192 insertions(+), 14193 deletions(-) diff --git a/apiclient/client_test.go b/apiclient/client_test.go index 56961323..5f165677 100644 --- a/apiclient/client_test.go +++ b/apiclient/client_test.go @@ -1,125 +1,125 @@ -package apiclient_test - -import ( - "context" - "errors" - "testing" - - apiclient "github.com/Alia5/VIIPER/apiclient" - apitypes "github.com/Alia5/VIIPER/apitypes" - - "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) *apiclient.Client { - return apiclient.WithTransport(apiclient.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 *apiclient.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 *apiclient.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 structured", - setup: func(responses map[string]string) error { - responses["bus/create"] = `{"status":400,"title":"Bad Request","detail":"invalid busId"}` - return nil - }, - call: func(c *apiclient.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 *apiclient.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 *apiclient.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 *apiclient.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 *apiclient.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) { - 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 := apiclient.WithTransport(apiclient.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) { - responses := map[string]string{} - responses["bus/list"] = `{"buses":[1,2,3],"extra":true}` // extra field should cause decode error - c := testClient(responses, nil) - _, err := c.BusList() - assert.Error(t, err) -} +package apiclient_test + +import ( + "context" + "errors" + "testing" + + apiclient "github.com/Alia5/VIIPER/apiclient" + apitypes "github.com/Alia5/VIIPER/apitypes" + + "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) *apiclient.Client { + return apiclient.WithTransport(apiclient.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 *apiclient.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 *apiclient.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 structured", + setup: func(responses map[string]string) error { + responses["bus/create"] = `{"status":400,"title":"Bad Request","detail":"invalid busId"}` + return nil + }, + call: func(c *apiclient.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 *apiclient.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 *apiclient.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 *apiclient.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 *apiclient.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) { + 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 := apiclient.WithTransport(apiclient.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) { + responses := map[string]string{} + responses["bus/list"] = `{"buses":[1,2,3],"extra":true}` // extra field should cause decode error + c := testClient(responses, nil) + _, err := c.BusList() + assert.Error(t, err) +} diff --git a/apiclient/stream.go b/apiclient/stream.go index 73254295..f4c593d7 100644 --- a/apiclient/stream.go +++ b/apiclient/stream.go @@ -1,199 +1,199 @@ -package apiclient - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "log/slog" - "net" - "sync" - "time" - - apitypes "github.com/Alia5/VIIPER/apitypes" - "github.com/Alia5/VIIPER/device" -) - -// 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) - } - } - - streamPath := fmt.Sprintf("bus/%d/%s\x00", 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, o *device.CreateOptions) (*DeviceStream, *apitypes.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() -} +package apiclient + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "log/slog" + "net" + "sync" + "time" + + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" +) + +// 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) + } + } + + streamPath := fmt.Sprintf("bus/%d/%s\x00", 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, o *device.CreateOptions) (*DeviceStream, *apitypes.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/apiclient/stream_test.go b/apiclient/stream_test.go index 128de139..b44e9050 100644 --- a/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -1,188 +1,188 @@ -package apiclient_test - -import ( - "context" - "errors" - "log/slog" - "net" - "testing" - "time" - - apiclient "github.com/Alia5/VIIPER/apiclient" - apitypes "github.com/Alia5/VIIPER/apitypes" - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/device/xbox360" - "github.com/Alia5/VIIPER/internal/log" - api "github.com/Alia5/VIIPER/internal/server/api" - handler "github.com/Alia5/VIIPER/internal/server/api/handler" - "github.com/Alia5/VIIPER/internal/server/usb" - htesting "github.com/Alia5/VIIPER/internal/testing" - pusb "github.com/Alia5/VIIPER/usb" - "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 *apitypes.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: &apitypes.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", - }, - { - name: "strict JSON decode error (extra field)", - setup: func(responses map[string]string) error { - responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test","extra":true}` - return nil - }, - wantErrSubstr: "decode:", - }, - } - - 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 *apiclient.DeviceStream) - - tests := []struct { - name string - busID uint32 - customRegistration bool - op operation - }{ - { - name: "read deadline timeout", - busID: 201, - op: func(t *testing.T, stream *apiclient.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 *apiclient.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 { 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() - - b, err := virtualbus.NewWithBusId(tt.busID) - require.NoError(t, err) - require.NoError(t, usbSrv.AddBus(b)) - - c := apiclient.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) - }) - } -} +package apiclient_test + +import ( + "context" + "errors" + "log/slog" + "net" + "testing" + "time" + + apiclient "github.com/Alia5/VIIPER/apiclient" + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/log" + api "github.com/Alia5/VIIPER/internal/server/api" + handler "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + htesting "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "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 *apitypes.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: &apitypes.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", + }, + { + name: "strict JSON decode error (extra field)", + setup: func(responses map[string]string) error { + responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test","extra":true}` + return nil + }, + wantErrSubstr: "decode:", + }, + } + + 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 *apiclient.DeviceStream) + + tests := []struct { + name string + busID uint32 + customRegistration bool + op operation + }{ + { + name: "read deadline timeout", + busID: 201, + op: func(t *testing.T, stream *apiclient.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 *apiclient.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 { 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() + + b, err := virtualbus.NewWithBusId(tt.busID) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(b)) + + c := apiclient.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) + }) + } +} diff --git a/apiclient/transport_test.go b/apiclient/transport_test.go index e3bf4c70..8a9f412a 100644 --- a/apiclient/transport_test.go +++ b/apiclient/transport_test.go @@ -1,155 +1,155 @@ -package apiclient_test - -import ( - "encoding/json" - "net" - "strings" - "testing" - "time" - - "github.com/Alia5/VIIPER/apiclient" - - "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() - var buf []byte - var tmp [1]byte - for { - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, 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 := apiclient.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() - - 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 { - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, err := conn.Read(tmp) - if err != nil { - break - } - b := tmp[0] - buf = append(buf, b) - if b == '\x00' { // end of request - break - } - } - _, _ = conn.Write([]byte(resp)) - conn.Close() - }() - - client := apiclient.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) -} +package apiclient_test + +import ( + "encoding/json" + "net" + "strings" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + + "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() + var buf []byte + var tmp [1]byte + for { + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, 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 := apiclient.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() + + 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 { + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := conn.Read(tmp) + if err != nil { + break + } + b := tmp[0] + buf = append(buf, b) + if b == '\x00' { // end of request + break + } + } + _, _ = conn.Write([]byte(resp)) + conn.Close() + }() + + client := apiclient.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) +} diff --git a/cmd/viiper/meta.go b/cmd/viiper/meta.go index 20fd9443..d61c62bf 100644 --- a/cmd/viiper/meta.go +++ b/cmd/viiper/meta.go @@ -1,72 +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" - } -} +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/device/context.go b/device/context.go index 9175b4f6..60fd5f27 100644 --- a/device/context.go +++ b/device/context.go @@ -1,34 +1,34 @@ -// 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 -} +// 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/keyboard/const.go b/device/keyboard/const.go index 24b01e40..2570577a 100644 --- a/device/keyboard/const.go +++ b/device/keyboard/const.go @@ -1,336 +1,336 @@ -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, -} +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 index a0a490ff..8348820c 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -1,214 +1,214 @@ -// Package keyboard provides a HID keyboard device implementation with full N-key rollover. -package keyboard - -import ( - "sync" - "sync/atomic" - - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usbip" -) - -// 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) - descriptor usb.Descriptor -} - -// New returns a new Keyboard device. -func New(o *device.CreateOptions) *Keyboard { - 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 - } - } - return d -} - -// 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 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, - }, - 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) GetDescriptor() *usb.Descriptor { - return &k.descriptor -} +// Package keyboard provides a HID keyboard device implementation with full N-key rollover. +package keyboard + +import ( + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +// 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) + descriptor usb.Descriptor +} + +// New returns a new Keyboard device. +func New(o *device.CreateOptions) *Keyboard { + 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 + } + } + return d +} + +// 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 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, + }, + 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) GetDescriptor() *usb.Descriptor { + return &k.descriptor +} diff --git a/device/keyboard/handler.go b/device/keyboard/handler.go index c958648f..2c1d3b72 100644 --- a/device/keyboard/handler.go +++ b/device/keyboard/handler.go @@ -1,87 +1,87 @@ -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 { 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) - } - } -} +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 { 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) + } + } +} diff --git a/device/keyboard/helpers.go b/device/keyboard/helpers.go index ab49fc0e..88df3c4f 100644 --- a/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/device/keyboard/inputstate.go b/device/keyboard/inputstate.go index 9cda3107..add99273 100644 --- a/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -1,114 +1,114 @@ -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(st.DY) - b[4] = byte(st.DY >> 8) - b[5] = byte(st.Wheel) - b[6] = byte(st.Wheel >> 8) - b[7] = byte(st.Pan) - b[8] = byte(st.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 -} +package mouse + +import ( + "io" +) + +// InputState represents the mouse state used to build a report. +// viiper:wire mouse c2s buttons:u8 dx:i16 dy:i16 wheel:i16 pan:i16 +type InputState struct { + // Button bitfield: bit 0=Left, 1=Right, 2=Middle, 3=Back, 4=Forward + Buttons uint8 + // Delta X/Y: signed 16-bit relative movement + DX, DY int16 + // Wheel: signed 16-bit vertical scroll + Wheel int16 + // Pan: signed 16-bit horizontal scroll + Pan int16 +} + +// BuildReport encodes an InputState into the 9-byte HID mouse report. +// +// Report layout (9 bytes): +// +// Byte 0: Button bitfield (bit 0=Left, 1=Right, 2=Middle, 3=Back, 4=Forward, bits 5-7=padding) +// Bytes 1-2: DX (int16 little-endian, -32768 to +32767) +// Bytes 3-4: DY (int16 little-endian) +// Bytes 5-6: Wheel (int16 little-endian) +// Bytes 7-8: Pan (int16 little-endian) +func (st InputState) BuildReport() []byte { + b := make([]byte, 9) + b[0] = st.Buttons & 0x1F // 5 buttons, mask upper bits + b[1] = byte(st.DX) + b[2] = byte(st.DX >> 8) + b[3] = byte(st.DY) + b[4] = byte(st.DY >> 8) + b[5] = byte(st.Wheel) + b[6] = byte(st.Wheel >> 8) + b[7] = byte(st.Pan) + b[8] = byte(st.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/options.go b/device/options.go index f9b87025..d34d0f57 100644 --- a/device/options.go +++ b/device/options.go @@ -1,6 +1,6 @@ -package device - -type CreateOptions struct { - IdVendor *uint16 - IdProduct *uint16 -} +package device + +type CreateOptions struct { + IdVendor *uint16 + IdProduct *uint16 +} diff --git a/device/report.go b/device/report.go index 23a74881..123dd83c 100644 --- a/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/device/xbox360/const.go b/device/xbox360/const.go index ff493c04..ef77adbe 100644 --- a/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/handler.go b/device/xbox360/handler.go index 65336dce..e72229f9 100644 --- a/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -1,60 +1,60 @@ -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 { return New(o) } - -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 { return New(o) } + +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) + } + } +} diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index ac86c671..8fee03be 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -1,104 +1,104 @@ -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 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 (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[4] = st.LT - b[5] = st.RT - binary.LittleEndian.PutUint16(b[6:8], uint16(st.LX)) - binary.LittleEndian.PutUint16(b[8:10], uint16(st.LY)) - binary.LittleEndian.PutUint16(b[10:12], uint16(st.RX)) - binary.LittleEndian.PutUint16(b[12:14], uint16(st.RY)) - // Remaining bytes (14-19) are left zeroed - 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 +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 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 (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[4] = st.LT + b[5] = st.RT + binary.LittleEndian.PutUint16(b[6:8], uint16(st.LX)) + binary.LittleEndian.PutUint16(b[8:10], uint16(st.LY)) + binary.LittleEndian.PutUint16(b[10:12], uint16(st.RX)) + binary.LittleEndian.PutUint16(b[12:14], uint16(st.RY)) + // Remaining bytes (14-19) are left zeroed + 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 +} diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 67e802f5..8f2ab5a7 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -1,158 +1,158 @@ -package main - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "os" - "os/signal" - "syscall" - "time" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device/keyboard" -) - -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 { - 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() - - 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 - } - } -} +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/keyboard" +) + +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 { + 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() + + 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 854b661d..dda557d0 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -1,152 +1,152 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device/mouse" -) - -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 { - 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() - - 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.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/apiclient" + "github.com/Alia5/VIIPER/device/mouse" +) + +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 { + 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() + + 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.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 + } + } +} diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 6e3f3d62..2a7a14b0 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -1,159 +1,159 @@ -package main - -import ( - "bufio" - "context" - "encoding" - "fmt" - "io" - "os" - "os/signal" - "syscall" - "time" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device/xbox360" -) - -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 { - 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() - - 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 - } - } -} +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/xbox360" +) + +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 { + 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() + + 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/internal/cmd/codegen.go b/internal/cmd/codegen.go index 95b04b6d..cb568ab4 100644 --- a/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -1,24 +1,24 @@ -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) - -} +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/internal/cmd/config.go b/internal/cmd/config.go index 8d2ad684..2ec3d586 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -1,209 +1,209 @@ -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" - 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" + 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 + } +} diff --git a/internal/cmd/proxy.go b/internal/cmd/proxy.go index 8d385ad2..13ba56b8 100644 --- a/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -1,48 +1,48 @@ -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 - 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 + return nil + case err := <-proxyErrCh: + return err + } +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go index f3d360d1..a838c2dd 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -1,95 +1,95 @@ -package cmd - -import ( - "context" - "fmt" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - - "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" -) - -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 { - 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) - 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).") - } - - 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 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) - 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 - } -} +package cmd + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "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" +) + +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 { + 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) + 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).") + } + + 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 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) + 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/internal/codegen/cmd/scan-constants/main.go b/internal/codegen/cmd/scan-constants/main.go index f3505bc5..4e982aa8 100644 --- a/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" - - "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)) - } - } - } -} +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/internal/codegen/cmd/scan-dtos/main.go b/internal/codegen/cmd/scan-dtos/main.go index f339cc11..a27de101 100644 --- a/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" - - "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) - } - - 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) + } + + 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)) +} diff --git a/internal/codegen/cmd/scan-routes/main.go b/internal/codegen/cmd/scan-routes/main.go index 96331f7a..2f5f1132 100644 --- a/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" - - "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)) -} +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/enums.go b/internal/codegen/common/enums.go index 277809f3..6c773de5 100644 --- a/internal/codegen/common/enums.go +++ b/internal/codegen/common/enums.go @@ -1,38 +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 -} +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 index f3273d6f..aecef2fe 100644 --- a/internal/codegen/common/fileheader.go +++ b/internal/codegen/common/fileheader.go @@ -1,10 +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) -} +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 index 04b6ebec..eb7c5d0b 100644 --- a/internal/codegen/common/gotypes.go +++ b/internal/codegen/common/gotypes.go @@ -1,19 +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 -} +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/internal/codegen/common/license.go b/internal/codegen/common/license.go index 6bb5f0d9..ee932414 100644 --- a/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 index 9ed7dfa7..4437ebf7 100644 --- a/internal/codegen/common/naming.go +++ b/internal/codegen/common/naming.go @@ -1,103 +1,103 @@ -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 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' } +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 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 index 6e42b66c..777c0754 100644 --- a/internal/codegen/common/readme.go +++ b/internal/codegen/common/readme.go @@ -1,38 +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 -} +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 index b3398a09..1a01b388 100644 --- a/internal/codegen/common/version.go +++ b/internal/codegen/common/version.go @@ -1,46 +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 -} +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 index 919fa635..b8574c81 100644 --- a/internal/codegen/common/wiresize.go +++ b/internal/codegen/common/wiresize.go @@ -1,107 +1,107 @@ -package common - -import ( - "sort" - "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 { - baseType := field.Type - if strings.Contains(baseType, "*") { - return 0 - } - total += WireTypeSize(baseType) - } - - 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 - if direction == "input" { - dir = "c2s" - } else if direction == "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 -} +package common + +import ( + "sort" + "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 { + baseType := field.Type + if strings.Contains(baseType, "*") { + return 0 + } + total += WireTypeSize(baseType) + } + + 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 + if direction == "input" { + dir = "c2s" + } else if direction == "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/c/cmake.go b/internal/codegen/generator/c/cmake.go index 52fe8381..e1553ff1 100644 --- a/internal/codegen/generator/c/cmake.go +++ b/internal/codegen/generator/c/cmake.go @@ -1,87 +1,87 @@ -package cgen - -import ( - "bytes" - "fmt" - "log/slog" - "os" - "path/filepath" - "sort" - "text/template" - - "github.com/Alia5/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 -set(VIIPER_SOURCES - src/viiper.c -{{range .Devices}} src/viiper_{{.}}.c -{{end}}) - -add_library(viiper SHARED ${VIIPER_SOURCES}) -add_library(viiper_static STATIC ${VIIPER_SOURCES}) - -if(NOT WIN32) - set_target_properties(viiper_static PROPERTIES OUTPUT_NAME viiper) -endif() - -# Include directories -target_include_directories(viiper PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include -) -target_include_directories(viiper_static PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include -) - -# Platform-specific settings -if(WIN32) - target_compile_definitions(viiper PRIVATE VIIPER_BUILD) - target_compile_definitions(viiper_static PRIVATE VIIPER_BUILD) - target_link_libraries(viiper ws2_32) - target_link_libraries(viiper_static ws2_32) -else() - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden") -endif() - -# Installation -install(TARGETS viiper viiper_static - 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 -} +package cgen + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "text/template" + + "github.com/Alia5/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 +set(VIIPER_SOURCES + src/viiper.c +{{range .Devices}} src/viiper_{{.}}.c +{{end}}) + +add_library(viiper SHARED ${VIIPER_SOURCES}) +add_library(viiper_static STATIC ${VIIPER_SOURCES}) + +if(NOT WIN32) + set_target_properties(viiper_static PROPERTIES OUTPUT_NAME viiper) +endif() + +# Include directories +target_include_directories(viiper PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) +target_include_directories(viiper_static PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +# Platform-specific settings +if(WIN32) + target_compile_definitions(viiper PRIVATE VIIPER_BUILD) + target_compile_definitions(viiper_static PRIVATE VIIPER_BUILD) + target_link_libraries(viiper ws2_32) + target_link_libraries(viiper_static ws2_32) +else() + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden") +endif() + +# Installation +install(TARGETS viiper viiper_static + 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/internal/codegen/generator/c/gen.go b/internal/codegen/generator/c/gen.go index 36588f07..605b671c 100644 --- a/internal/codegen/generator/c/gen.go +++ b/internal/codegen/generator/c/gen.go @@ -1,65 +1,65 @@ -package cgen - -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") - 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, major, minor, patch); 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 - } - - if err := common.GenerateReadme(logger, outputDir); err != nil { - return err - } - - logger.Info("Generated C client library", "dir", outputDir) - return nil -} +package cgen + +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") + 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, major, minor, patch); 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 + } + + 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/c/header_common.go b/internal/codegen/generator/c/header_common.go index f4592ef9..7b5b41bf 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -1,214 +1,214 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const commonHeaderTmpl = `#ifndef VIIPER_H -#define VIIPER_H - -/* Auto-generated VIIPER - C Client Library: 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 {{.Major}} -#define VIIPER_VERSION_MINOR {{.Minor}} -#define VIIPER_VERSION_PATCH {{.Patch}} - -/* 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 client library) */ -{{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 */ -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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, - {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, - {{responseCType .ResponseDTO}}* out{{end}} -); -{{end}} - -/* ======================================================================== - * Device Streaming API - * ======================================================================== */ - -/* Callback type for receiving device output */ -typedef void (*viiper_output_cb)(void* buffer, size_t bytes_read, void* user_data); - -/* Callback type for disconnect notification */ -typedef void (*viiper_disconnect_cb)(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) - * User provides buffer - client library reads directly into it and calls callback with byte count */ -VIIPER_API void viiper_device_on_output( - viiper_device_t* device, - void* buffer, - size_t buffer_size, - viiper_output_cb callback, - void* user_data -); - -/* Register callback for disconnect notification (called when connection is lost) */ -VIIPER_API void viiper_device_on_disconnect( - viiper_device_t* device, - viiper_disconnect_cb callback, - void* user_data -); - -/* Close device stream and free resources */ -VIIPER_API void viiper_device_close(viiper_device_t* device); - -/* OpenStream: connect to an existing device's stream channel (device must already exist) */ -VIIPER_API viiper_error_t viiper_open_stream( - viiper_client_t* client, - uint32_t bus_id, - const char* dev_id, - viiper_device_t** out_device -); - -/* Convenience: AddDeviceAndConnect (create device then connect its stream) */ -VIIPER_API viiper_error_t viiper_add_device_and_connect( - viiper_client_t* client, - uint32_t bus_id, - const viiper_device_create_request_t* request, - viiper_device_info_t* out_info, - viiper_device_t** out_device -); - -#ifdef __cplusplus -} -#endif - -#endif /* VIIPER_H */ -` - -func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata, major, minor, patch int) 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() - - // Create data with version and metadata - data := struct { - *meta.Metadata - Major int - Minor int - Patch int - }{ - Metadata: md, - Major: major, - Minor: minor, - Patch: patch, - } - - if err := t.Execute(f, data); err != nil { - return fmt.Errorf("exec header tmpl: %w", err) - } - logger.Info("Generated C header", "file", out) - return nil -} +package cgen + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const commonHeaderTmpl = `#ifndef VIIPER_H +#define VIIPER_H + +/* Auto-generated VIIPER - C Client Library: 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 {{.Major}} +#define VIIPER_VERSION_MINOR {{.Minor}} +#define VIIPER_VERSION_PATCH {{.Patch}} + +/* 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 client library) */ +{{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 */ +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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, + {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, + {{responseCType .ResponseDTO}}* out{{end}} +); +{{end}} + +/* ======================================================================== + * Device Streaming API + * ======================================================================== */ + +/* Callback type for receiving device output */ +typedef void (*viiper_output_cb)(void* buffer, size_t bytes_read, void* user_data); + +/* Callback type for disconnect notification */ +typedef void (*viiper_disconnect_cb)(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) + * User provides buffer - client library reads directly into it and calls callback with byte count */ +VIIPER_API void viiper_device_on_output( + viiper_device_t* device, + void* buffer, + size_t buffer_size, + viiper_output_cb callback, + void* user_data +); + +/* Register callback for disconnect notification (called when connection is lost) */ +VIIPER_API void viiper_device_on_disconnect( + viiper_device_t* device, + viiper_disconnect_cb callback, + void* user_data +); + +/* Close device stream and free resources */ +VIIPER_API void viiper_device_close(viiper_device_t* device); + +/* OpenStream: connect to an existing device's stream channel (device must already exist) */ +VIIPER_API viiper_error_t viiper_open_stream( + viiper_client_t* client, + uint32_t bus_id, + const char* dev_id, + viiper_device_t** out_device +); + +/* Convenience: AddDeviceAndConnect (create device then connect its stream) */ +VIIPER_API viiper_error_t viiper_add_device_and_connect( + viiper_client_t* client, + uint32_t bus_id, + const viiper_device_create_request_t* request, + viiper_device_info_t* out_info, + viiper_device_t** out_device +); + +#ifdef __cplusplus +} +#endif + +#endif /* VIIPER_H */ +` + +func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata, major, minor, patch int) 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() + + // Create data with version and metadata + data := struct { + *meta.Metadata + Major int + Minor int + Patch int + }{ + Metadata: md, + Major: major, + Minor: minor, + Patch: patch, + } + + if err := t.Execute(f, data); err != nil { + return fmt.Errorf("exec header tmpl: %w", err) + } + logger.Info("Generated C header", "file", out) + return nil +} diff --git a/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go index 465d2507..eee07b94 100644 --- a/internal/codegen/generator/c/header_device.go +++ b/internal/codegen/generator/c/header_device.go @@ -1,100 +1,100 @@ -package cgen - -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 deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H -#define VIIPER_{{upper .Device}}_H - -/* Auto-generated VIIPER - C Client Library: {{.Device}} device */ - -/* Minimal path fix: include common header without duplicated module path */ -#include "viiper.h" - -/* ======================================================================== - * {{.Device}} Constants - * ======================================================================== */ -/* Device output size (bytes to read from socket in callback) */ -#define VIIPER_{{upper .Device}}_OUTPUT_SIZE {{.OutputSize}} - -{{- if gt (len .Pkg.Constants) 0 }} -/* {{.Device}} constants */ -{{range .Pkg.Constants -}} -#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{printf "0x%X" .Value}} -{{end}} -{{- end}} - -/* ======================================================================== - * {{.Device}} Lookup Maps - * ======================================================================== */ -{{- range .Pkg.Maps }} -/* {{.Name}} lookup function */ -{{mapFuncDecl $.Device .}} -{{- 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 - OutputSize int -} - -func generateDeviceHeader(logger *slog.Logger, includeDir, device string, md *meta.Metadata) error { - pkg := md.DevicePackages[device] - - // Calculate OUTPUT_SIZE from s2c wire tag - outputSize := 0 - if md.WireTags != nil { - if s2cTag := md.WireTags.GetTag(device, "s2c"); s2cTag != nil { - outputSize = common.CalculateOutputSize(s2cTag) - } - } - - data := deviceHeaderData{ - Device: device, - Pkg: pkg, - OutputSize: outputSize, - } - 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 -} +package cgen + +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 deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H +#define VIIPER_{{upper .Device}}_H + +/* Auto-generated VIIPER - C Client Library: {{.Device}} device */ + +/* Minimal path fix: include common header without duplicated module path */ +#include "viiper.h" + +/* ======================================================================== + * {{.Device}} Constants + * ======================================================================== */ +/* Device output size (bytes to read from socket in callback) */ +#define VIIPER_{{upper .Device}}_OUTPUT_SIZE {{.OutputSize}} + +{{- if gt (len .Pkg.Constants) 0 }} +/* {{.Device}} constants */ +{{range .Pkg.Constants -}} +#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{printf "0x%X" .Value}} +{{end}} +{{- end}} + +/* ======================================================================== + * {{.Device}} Lookup Maps + * ======================================================================== */ +{{- range .Pkg.Maps }} +/* {{.Name}} lookup function */ +{{mapFuncDecl $.Device .}} +{{- 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 + OutputSize int +} + +func generateDeviceHeader(logger *slog.Logger, includeDir, device string, md *meta.Metadata) error { + pkg := md.DevicePackages[device] + + // Calculate OUTPUT_SIZE from s2c wire tag + outputSize := 0 + if md.WireTags != nil { + if s2cTag := md.WireTags.GetTag(device, "s2c"); s2cTag != nil { + outputSize = common.CalculateOutputSize(s2cTag) + } + } + + data := deviceHeaderData{ + Device: device, + Pkg: pkg, + OutputSize: outputSize, + } + 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/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go index 952617ba..11c966e3 100644 --- a/internal/codegen/generator/c/helpers.go +++ b/internal/codegen/generator/c/helpers.go @@ -1,550 +1,550 @@ -package cgen - -import ( - "fmt" - "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{ - "ctype": cType, - "snakecase": common.ToSnakeCase, - "upper": strings.ToUpper, - "hasWireTag": func(device, direction string) bool { return common.HasWireTag(md, device, direction) }, - "wireFields": func(device, direction string) string { return wireFieldsString(md, device, direction) }, - "indent": indent, - "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, - "pathParams": common.ExtractPathParams, - "join": strings.Join, - "mapFuncDecl": mapFuncDecl, - "mapFuncImpl": mapFuncImpl, - "payloadCType": func(pi scanner.PayloadInfo) string { return payloadCType(md, pi) }, - "responseCType": func(name string) string { return responseCType(md, name) }, - "marshalPayload": func(pi scanner.PayloadInfo) string { return marshalPayload(md, pi) }, - "genFreeFunc": func(dto scanner.DTOSchema) string { return generateFreeFunction(md, dto) }, - "genParser": func(dto scanner.DTOSchema) string { return generateParser(md, dto) }, - } -} - -func payloadCType(md *meta.Metadata, pi scanner.PayloadInfo) string { - switch pi.Kind { - case scanner.PayloadJSON: - if pi.RawType != "" { - return fmt.Sprintf("const viiper_%s_t*", dtoToCTypeName(md, pi.RawType)) - } - return "const char*" // fallback to raw JSON string - case scanner.PayloadNumeric: - if pi.Required { - return "uint32_t" - } - return "uint32_t*" - case scanner.PayloadString: - return "const char*" - default: - return "" - } -} - -func responseCType(md *meta.Metadata, dtoName string) string { - if dtoName == "" { - return "" - } - return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, dtoName)) -} - -func dtoToCTypeName(md *meta.Metadata, dtoName string) string { - if md.CTypeNames != nil { - if mapped, ok := md.CTypeNames[dtoName]; ok { - return mapped - } - } - return common.ToSnakeCase(dtoName) -} - -func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { - if pi.Kind != scanner.PayloadJSON || pi.RawType == "" { - return "" - } - - var dto *scanner.DTOSchema - for i := range md.DTOs { - if md.DTOs[i].Name == pi.RawType { - dto = &md.DTOs[i] - break - } - } - if dto == nil { - return "" - } - - varName := "request" - lines := []string{ - fmt.Sprintf("if (%s) {", varName), - } - - firstField := true - for _, f := range dto.Fields { - isPointer := strings.HasPrefix(f.Type, "*") - baseType := strings.TrimPrefix(f.Type, "*") - - var condition string - if !f.Optional { - condition = "" - } else if isPointer { - condition = fmt.Sprintf("if (%s->%s) ", varName, f.Name) - } else { - condition = "" - } - - var fieldCode string - switch baseType { - case "string": - if firstField { - fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":\\\"%%s\\\"\", *%s->%s);", - condition, f.JSONName, varName, f.Name) - } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", - condition, f.JSONName, varName, f.Name) - } - case "uint16", "uint32": - if firstField { - fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":%%u\", (unsigned)*%s->%s);", - condition, f.JSONName, varName, f.Name) - } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", - condition, f.JSONName, varName, f.Name) - } - default: - continue - } - - if firstField { - firstField = false - } - - lines = append(lines, " "+fieldCode) - } - - lines = append(lines, " strncat(payload, \"}\", sizeof(payload) - strlen(payload) - 1);") - lines = append(lines, " }") - - return strings.Join(lines, "\n") -} - -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 kind == "struct" { - return fmt.Sprintf("viiper_%s_t*", common.ToSnakeCase(goType)) - } - return goType - } -} - -func wireFieldsString(md *meta.Metadata, device, direction string) string { - tag := common.GetWireTag(md, 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, common.ToSnakeCase(field.Name))) - continue - } - lines = append(lines, fmt.Sprintf("%s %s;", wireBaseToC(field.Type), field.Name)) - } - return strings.Join(lines, "\n ") -} - -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 fieldDecl(md *meta.Metadata, f scanner.FieldInfo) string { - if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { - elem := strings.TrimPrefix(f.Type, "[]") - cElem := fieldTypeToCType(md, elem) - return fmt.Sprintf("%s* %s; size_t %s_count;%s", cElem, f.Name, common.ToSnakeCase(f.Name), optComment(f)) - } - - if strings.HasPrefix(f.Type, "*") { - elem := strings.TrimPrefix(f.Type, "*") - cElem := fieldTypeToCType(md, elem) - return fmt.Sprintf("%s* %s;%s", cElem, f.Name, optComment(f)) - } - - return fmt.Sprintf("%s %s;%s", fieldTypeToCType(md, f.Type), f.Name, optComment(f)) -} - -func fieldTypeToCType(md *meta.Metadata, goType string) string { - 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 "int", "int64": - return "int64_t" - case "bool": - return "int" - case "float32": - return "float" - case "float64": - return "double" - } - - for _, dto := range md.DTOs { - if dto.Name == goType { - return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, goType)) - } - } - - return fmt.Sprintf("viiper_%s_t", common.ToSnakeCase(goType)) -} - -func optComment(f scanner.FieldInfo) string { - if f.Optional { - return " /* optional */" - } - return "" -} - -func mapFuncDecl(device string, mapInfo scanner.MapInfo) string { - keyType := mapGoTypeToCType(mapInfo.KeyType) - valueType := mapGoTypeToCType(mapInfo.ValueType) - funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) - - return fmt.Sprintf("int %s(%s key, %s* out_value);", funcName, keyType, valueType) -} - -func mapFuncImpl(device string, mapInfo scanner.MapInfo) string { - keyType := mapGoTypeToCType(mapInfo.KeyType) - valueType := mapGoTypeToCType(mapInfo.ValueType) - funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) - - var builder strings.Builder - builder.WriteString(fmt.Sprintf("int %s(%s key, %s* out_value) {\n", funcName, keyType, valueType)) - builder.WriteString(" if (!out_value) return 0;\n") - builder.WriteString(" switch (key) {\n") - - keys := common.SortedStringKeys(mapInfo.Entries) - for _, keyStr := range keys { - value := mapInfo.Entries[keyStr] - cKey := formatCMapKey(keyStr, mapInfo.KeyType, device) - cValue := formatCMapValue(value, mapInfo.ValueType, device) - builder.WriteString(fmt.Sprintf(" case %s: *out_value = %s; return 1;\n", cKey, cValue)) - } - - builder.WriteString(" default: return 0;\n") - builder.WriteString(" }\n") - builder.WriteString("}\n") - - return builder.String() -} - -func mapGoTypeToCType(goType string) string { - switch goType { - case "byte", "uint8": - return "uint8_t" - case "uint16": - return "uint16_t" - case "uint32", "uint": - return "uint32_t" - case "uint64": - return "uint64_t" - case "int8": - return "int8_t" - case "int16": - return "int16_t" - case "int32", "int": - return "int32_t" - case "int64": - return "int64_t" - case "bool": - return "int" - case "string": - return "const char*" - default: - return goType - } -} - -func formatCMapKey(key string, goType string, device string) string { - switch goType { - case "byte", "uint8": - if len(key) == 1 { - switch key[0] { - case '\n': - return "'\\n'" - case '\r': - return "'\\r'" - case '\t': - return "'\\t'" - case '\\': - return "'\\\\'" - case '\'': - return "'\\''" - case 0: - return "'\\0'" - } - if key[0] >= 32 && key[0] <= 126 { - return fmt.Sprintf("'%s'", key) - } - return fmt.Sprintf("0x%02X", key[0]) - } - if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { - prefix := common.ExtractPrefix(key) - if prefix != "" { - constName := strings.ToUpper(common.ToSnakeCase(key)) - return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) - } - } - return key - case "string": - return fmt.Sprintf("\"%s\"", key) - default: - return key - } -} - -func formatCMapValue(value interface{}, goType string, device string) string { - switch goType { - case "byte", "uint8", "uint16", "uint32", "uint64": - if str, ok := value.(string); ok && !strings.Contains(str, " ") { - if len(str) > 0 && (str[0] >= 'A' && str[0] <= 'Z') { - prefix := common.ExtractPrefix(str) - if prefix != "" { - constName := strings.ToUpper(common.ToSnakeCase(str)) - return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) - } - } - return str - } - if num, ok := value.(int64); ok { - return fmt.Sprintf("0x%X", num) - } - if num, ok := value.(uint64); ok { - return fmt.Sprintf("0x%X", num) - } - return fmt.Sprintf("%v", value) - case "bool": - if b, ok := value.(bool); ok { - if b { - return "1" - } - return "0" - } - if str, ok := value.(string); ok { - if str == "true" { - return "1" - } - if str == "false" { - return "0" - } - return str - } - return "0" - case "string": - if str, ok := value.(string); ok { - return fmt.Sprintf("\"%s\"", str) - } - return fmt.Sprintf("\"%v\"", value) - default: - return fmt.Sprintf("%v", value) - } -} - -func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { - snakeName := dtoToCTypeName(md, dto.Name) - lines := []string{ - fmt.Sprintf("VIIPER_API void viiper_free_%s(viiper_%s_t* v){", snakeName, snakeName), - } - - hasPointers := false - for _, f := range dto.Fields { - if strings.HasPrefix(f.Type, "*") || strings.HasPrefix(f.Type, "[]") { - hasPointers = true - break - } - } - - if !hasPointers { - lines = append(lines, " (void)v; }") - return strings.Join(lines, "") - } - - lines = append(lines, " if (!v) return;") - - for _, f := range dto.Fields { - baseType := strings.TrimPrefix(f.Type, "*") - baseType = strings.TrimPrefix(baseType, "[]") - - if strings.HasPrefix(f.Type, "[]") { - if baseType != "string" && baseType != "uint8" && baseType != "uint16" && baseType != "uint32" && baseType != "uint64" { - lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ ", f.Name, common.ToSnakeCase(f.JSONName))) - for _, dtoSchema := range md.DTOs { - if dtoSchema.Name == baseType { - for _, field := range dtoSchema.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - if fieldType == "string" { - lines = append(lines, fmt.Sprintf("if (v->%s[i].%s) free((void*)v->%s[i].%s); ", f.Name, field.Name, f.Name, field.Name)) - } - } - break - } - } - lines = append(lines, " } free(v->"+f.Name+");}") - } else if baseType == "string" { - lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ if (v->%s[i]) free((void*)v->%s[i]); } free(v->%s);}", f.Name, common.ToSnakeCase(f.JSONName), f.Name, f.Name, f.Name)) - } else { - lines = append(lines, fmt.Sprintf(" if (v->%s) free(v->%s);", f.Name, f.Name)) - } - } else if strings.HasPrefix(f.Type, "*") && baseType == "string" { - lines = append(lines, fmt.Sprintf(" if (v->%s) free((void*)v->%s);", f.Name, f.Name)) - } - } - - lines = append(lines, " }") - return strings.Join(lines, "") -} - -func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { - snakeName := dtoToCTypeName(md, dto.Name) - parserName := fmt.Sprintf("viiper_parse_%s", snakeName) - if strings.HasSuffix(snakeName, "_info") { - parserName = fmt.Sprintf("viiper_parse_%s_obj", snakeName) - } - - lines := []string{ - fmt.Sprintf("static int %s(const char* json, viiper_%s_t* out){", parserName, snakeName), - } - - var parserCalls []string - for _, f := range dto.Fields { - baseType := strings.TrimPrefix(f.Type, "*") - baseType = strings.TrimPrefix(baseType, "[]") - - required := !f.Optional - - if strings.HasPrefix(f.Type, "[]") { - if baseType == "uint32" { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_array_uint32(json, \"%s\", &out->%s, &out->%s_count)", f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) - } else { - parserFuncName := fmt.Sprintf("json_parse_array_%s", dtoToCTypeName(md, baseType)) - parserCalls = append(parserCalls, fmt.Sprintf("%s(json, \"%s\", &out->%s, &out->%s_count)", parserFuncName, f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) - } - } else if baseType == "string" { - if required { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_string_alloc(json, \"%s\", (char**)&out->%s)", f.JSONName, f.Name)) - } else { - lines = append(lines, fmt.Sprintf(" json_parse_string_alloc(json, \"%s\", (char**)&out->%s);", f.JSONName, f.Name)) - } - } else if baseType == "uint32" { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_uint32(json, \"%s\", &out->%s)", f.JSONName, f.Name)) - } - } - - if len(parserCalls) == 0 { - lines = append(lines, " return 0; }") - } else if len(parserCalls) == 1 { - lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", parserCalls[0])) - } else { - for i, call := range parserCalls { - if i < len(parserCalls)-1 { - lines = append(lines, fmt.Sprintf(" if (%s!=0) return -1;", call)) - } else { - lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", call)) - } - } - } - - return strings.Join(lines, "") -} +package cgen + +import ( + "fmt" + "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{ + "ctype": cType, + "snakecase": common.ToSnakeCase, + "upper": strings.ToUpper, + "hasWireTag": func(device, direction string) bool { return common.HasWireTag(md, device, direction) }, + "wireFields": func(device, direction string) string { return wireFieldsString(md, device, direction) }, + "indent": indent, + "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, + "pathParams": common.ExtractPathParams, + "join": strings.Join, + "mapFuncDecl": mapFuncDecl, + "mapFuncImpl": mapFuncImpl, + "payloadCType": func(pi scanner.PayloadInfo) string { return payloadCType(md, pi) }, + "responseCType": func(name string) string { return responseCType(md, name) }, + "marshalPayload": func(pi scanner.PayloadInfo) string { return marshalPayload(md, pi) }, + "genFreeFunc": func(dto scanner.DTOSchema) string { return generateFreeFunction(md, dto) }, + "genParser": func(dto scanner.DTOSchema) string { return generateParser(md, dto) }, + } +} + +func payloadCType(md *meta.Metadata, pi scanner.PayloadInfo) string { + switch pi.Kind { + case scanner.PayloadJSON: + if pi.RawType != "" { + return fmt.Sprintf("const viiper_%s_t*", dtoToCTypeName(md, pi.RawType)) + } + return "const char*" // fallback to raw JSON string + case scanner.PayloadNumeric: + if pi.Required { + return "uint32_t" + } + return "uint32_t*" + case scanner.PayloadString: + return "const char*" + default: + return "" + } +} + +func responseCType(md *meta.Metadata, dtoName string) string { + if dtoName == "" { + return "" + } + return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, dtoName)) +} + +func dtoToCTypeName(md *meta.Metadata, dtoName string) string { + if md.CTypeNames != nil { + if mapped, ok := md.CTypeNames[dtoName]; ok { + return mapped + } + } + return common.ToSnakeCase(dtoName) +} + +func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { + if pi.Kind != scanner.PayloadJSON || pi.RawType == "" { + return "" + } + + var dto *scanner.DTOSchema + for i := range md.DTOs { + if md.DTOs[i].Name == pi.RawType { + dto = &md.DTOs[i] + break + } + } + if dto == nil { + return "" + } + + varName := "request" + lines := []string{ + fmt.Sprintf("if (%s) {", varName), + } + + firstField := true + for _, f := range dto.Fields { + isPointer := strings.HasPrefix(f.Type, "*") + baseType := strings.TrimPrefix(f.Type, "*") + + var condition string + if !f.Optional { + condition = "" + } else if isPointer { + condition = fmt.Sprintf("if (%s->%s) ", varName, f.Name) + } else { + condition = "" + } + + var fieldCode string + switch baseType { + case "string": + if firstField { + fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":\\\"%%s\\\"\", *%s->%s);", + condition, f.JSONName, varName, f.Name) + } else { + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", + condition, f.JSONName, varName, f.Name) + } + case "uint16", "uint32": + if firstField { + fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":%%u\", (unsigned)*%s->%s);", + condition, f.JSONName, varName, f.Name) + } else { + fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", + condition, f.JSONName, varName, f.Name) + } + default: + continue + } + + if firstField { + firstField = false + } + + lines = append(lines, " "+fieldCode) + } + + lines = append(lines, " strncat(payload, \"}\", sizeof(payload) - strlen(payload) - 1);") + lines = append(lines, " }") + + return strings.Join(lines, "\n") +} + +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 kind == "struct" { + return fmt.Sprintf("viiper_%s_t*", common.ToSnakeCase(goType)) + } + return goType + } +} + +func wireFieldsString(md *meta.Metadata, device, direction string) string { + tag := common.GetWireTag(md, 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, common.ToSnakeCase(field.Name))) + continue + } + lines = append(lines, fmt.Sprintf("%s %s;", wireBaseToC(field.Type), field.Name)) + } + return strings.Join(lines, "\n ") +} + +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 fieldDecl(md *meta.Metadata, f scanner.FieldInfo) string { + if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { + elem := strings.TrimPrefix(f.Type, "[]") + cElem := fieldTypeToCType(md, elem) + return fmt.Sprintf("%s* %s; size_t %s_count;%s", cElem, f.Name, common.ToSnakeCase(f.Name), optComment(f)) + } + + if strings.HasPrefix(f.Type, "*") { + elem := strings.TrimPrefix(f.Type, "*") + cElem := fieldTypeToCType(md, elem) + return fmt.Sprintf("%s* %s;%s", cElem, f.Name, optComment(f)) + } + + return fmt.Sprintf("%s %s;%s", fieldTypeToCType(md, f.Type), f.Name, optComment(f)) +} + +func fieldTypeToCType(md *meta.Metadata, goType string) string { + 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 "int", "int64": + return "int64_t" + case "bool": + return "int" + case "float32": + return "float" + case "float64": + return "double" + } + + for _, dto := range md.DTOs { + if dto.Name == goType { + return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, goType)) + } + } + + return fmt.Sprintf("viiper_%s_t", common.ToSnakeCase(goType)) +} + +func optComment(f scanner.FieldInfo) string { + if f.Optional { + return " /* optional */" + } + return "" +} + +func mapFuncDecl(device string, mapInfo scanner.MapInfo) string { + keyType := mapGoTypeToCType(mapInfo.KeyType) + valueType := mapGoTypeToCType(mapInfo.ValueType) + funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) + + return fmt.Sprintf("int %s(%s key, %s* out_value);", funcName, keyType, valueType) +} + +func mapFuncImpl(device string, mapInfo scanner.MapInfo) string { + keyType := mapGoTypeToCType(mapInfo.KeyType) + valueType := mapGoTypeToCType(mapInfo.ValueType) + funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) + + var builder strings.Builder + builder.WriteString(fmt.Sprintf("int %s(%s key, %s* out_value) {\n", funcName, keyType, valueType)) + builder.WriteString(" if (!out_value) return 0;\n") + builder.WriteString(" switch (key) {\n") + + keys := common.SortedStringKeys(mapInfo.Entries) + for _, keyStr := range keys { + value := mapInfo.Entries[keyStr] + cKey := formatCMapKey(keyStr, mapInfo.KeyType, device) + cValue := formatCMapValue(value, mapInfo.ValueType, device) + builder.WriteString(fmt.Sprintf(" case %s: *out_value = %s; return 1;\n", cKey, cValue)) + } + + builder.WriteString(" default: return 0;\n") + builder.WriteString(" }\n") + builder.WriteString("}\n") + + return builder.String() +} + +func mapGoTypeToCType(goType string) string { + switch goType { + case "byte", "uint8": + return "uint8_t" + case "uint16": + return "uint16_t" + case "uint32", "uint": + return "uint32_t" + case "uint64": + return "uint64_t" + case "int8": + return "int8_t" + case "int16": + return "int16_t" + case "int32", "int": + return "int32_t" + case "int64": + return "int64_t" + case "bool": + return "int" + case "string": + return "const char*" + default: + return goType + } +} + +func formatCMapKey(key string, goType string, device string) string { + switch goType { + case "byte", "uint8": + if len(key) == 1 { + switch key[0] { + case '\n': + return "'\\n'" + case '\r': + return "'\\r'" + case '\t': + return "'\\t'" + case '\\': + return "'\\\\'" + case '\'': + return "'\\''" + case 0: + return "'\\0'" + } + if key[0] >= 32 && key[0] <= 126 { + return fmt.Sprintf("'%s'", key) + } + return fmt.Sprintf("0x%02X", key[0]) + } + if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { + prefix := common.ExtractPrefix(key) + if prefix != "" { + constName := strings.ToUpper(common.ToSnakeCase(key)) + return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) + } + } + return key + case "string": + return fmt.Sprintf("\"%s\"", key) + default: + return key + } +} + +func formatCMapValue(value interface{}, goType string, device string) string { + switch goType { + case "byte", "uint8", "uint16", "uint32", "uint64": + if str, ok := value.(string); ok && !strings.Contains(str, " ") { + if len(str) > 0 && (str[0] >= 'A' && str[0] <= 'Z') { + prefix := common.ExtractPrefix(str) + if prefix != "" { + constName := strings.ToUpper(common.ToSnakeCase(str)) + return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) + } + } + return str + } + if num, ok := value.(int64); ok { + return fmt.Sprintf("0x%X", num) + } + if num, ok := value.(uint64); ok { + return fmt.Sprintf("0x%X", num) + } + return fmt.Sprintf("%v", value) + case "bool": + if b, ok := value.(bool); ok { + if b { + return "1" + } + return "0" + } + if str, ok := value.(string); ok { + if str == "true" { + return "1" + } + if str == "false" { + return "0" + } + return str + } + return "0" + case "string": + if str, ok := value.(string); ok { + return fmt.Sprintf("\"%s\"", str) + } + return fmt.Sprintf("\"%v\"", value) + default: + return fmt.Sprintf("%v", value) + } +} + +func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { + snakeName := dtoToCTypeName(md, dto.Name) + lines := []string{ + fmt.Sprintf("VIIPER_API void viiper_free_%s(viiper_%s_t* v){", snakeName, snakeName), + } + + hasPointers := false + for _, f := range dto.Fields { + if strings.HasPrefix(f.Type, "*") || strings.HasPrefix(f.Type, "[]") { + hasPointers = true + break + } + } + + if !hasPointers { + lines = append(lines, " (void)v; }") + return strings.Join(lines, "") + } + + lines = append(lines, " if (!v) return;") + + for _, f := range dto.Fields { + baseType := strings.TrimPrefix(f.Type, "*") + baseType = strings.TrimPrefix(baseType, "[]") + + if strings.HasPrefix(f.Type, "[]") { + if baseType != "string" && baseType != "uint8" && baseType != "uint16" && baseType != "uint32" && baseType != "uint64" { + lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ ", f.Name, common.ToSnakeCase(f.JSONName))) + for _, dtoSchema := range md.DTOs { + if dtoSchema.Name == baseType { + for _, field := range dtoSchema.Fields { + fieldType := strings.TrimPrefix(field.Type, "*") + if fieldType == "string" { + lines = append(lines, fmt.Sprintf("if (v->%s[i].%s) free((void*)v->%s[i].%s); ", f.Name, field.Name, f.Name, field.Name)) + } + } + break + } + } + lines = append(lines, " } free(v->"+f.Name+");}") + } else if baseType == "string" { + lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ if (v->%s[i]) free((void*)v->%s[i]); } free(v->%s);}", f.Name, common.ToSnakeCase(f.JSONName), f.Name, f.Name, f.Name)) + } else { + lines = append(lines, fmt.Sprintf(" if (v->%s) free(v->%s);", f.Name, f.Name)) + } + } else if strings.HasPrefix(f.Type, "*") && baseType == "string" { + lines = append(lines, fmt.Sprintf(" if (v->%s) free((void*)v->%s);", f.Name, f.Name)) + } + } + + lines = append(lines, " }") + return strings.Join(lines, "") +} + +func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { + snakeName := dtoToCTypeName(md, dto.Name) + parserName := fmt.Sprintf("viiper_parse_%s", snakeName) + if strings.HasSuffix(snakeName, "_info") { + parserName = fmt.Sprintf("viiper_parse_%s_obj", snakeName) + } + + lines := []string{ + fmt.Sprintf("static int %s(const char* json, viiper_%s_t* out){", parserName, snakeName), + } + + var parserCalls []string + for _, f := range dto.Fields { + baseType := strings.TrimPrefix(f.Type, "*") + baseType = strings.TrimPrefix(baseType, "[]") + + required := !f.Optional + + if strings.HasPrefix(f.Type, "[]") { + if baseType == "uint32" { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_array_uint32(json, \"%s\", &out->%s, &out->%s_count)", f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) + } else { + parserFuncName := fmt.Sprintf("json_parse_array_%s", dtoToCTypeName(md, baseType)) + parserCalls = append(parserCalls, fmt.Sprintf("%s(json, \"%s\", &out->%s, &out->%s_count)", parserFuncName, f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) + } + } else if baseType == "string" { + if required { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_string_alloc(json, \"%s\", (char**)&out->%s)", f.JSONName, f.Name)) + } else { + lines = append(lines, fmt.Sprintf(" json_parse_string_alloc(json, \"%s\", (char**)&out->%s);", f.JSONName, f.Name)) + } + } else if baseType == "uint32" { + parserCalls = append(parserCalls, fmt.Sprintf("json_parse_uint32(json, \"%s\", &out->%s)", f.JSONName, f.Name)) + } + } + + if len(parserCalls) == 0 { + lines = append(lines, " return 0; }") + } else if len(parserCalls) == 1 { + lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", parserCalls[0])) + } else { + for i, call := range parserCalls { + if i < len(parserCalls)-1 { + lines = append(lines, fmt.Sprintf(" if (%s!=0) return -1;", call)) + } else { + lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", call)) + } + } + } + + return strings.Join(lines, "") +} diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go index c541a2af..22612f3a 100644 --- a/internal/codegen/generator/c/source_common.go +++ b/internal/codegen/generator/c/source_common.go @@ -1,671 +1,671 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const commonSourceTmpl = `/* Auto-generated VIIPER - C Client Library: common source */ - -#include "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 -# 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 */ - void* output_buffer; - size_t output_buffer_size; - viiper_output_cb callback; - void* callback_user; - /* Disconnect callback */ - viiper_disconnect_cb disconnect_callback; - void* disconnect_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 - * ======================================================================== */ - -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) { - int flag = 1; -#if defined(_WIN32) || defined(_WIN64) - setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&flag, sizeof(flag)); -#else - setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); -#endif - 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, "\0", 1, 0); - if (wr < 0) return -1; -#else - ssize_t wr = send(fd, line, n, 0); - if (wr < 0) return -1; - wr = send(fd, "\0", 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 (rd == 0) break; - if (ch == '\0') 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 */ -{{range .DTOs}}{{if ne .Name "Device"}} -{{genFreeFunc .}} -{{end}}{{end}} - -/* Parsers */ -static int viiper_parse_device_info_obj(const char* json, viiper_device_info_t* out){ if (json_parse_uint32(json, "busId", &out->BusID)!=0) return -1; if (json_parse_string_alloc(json, "devId", (char**)&out->DevId)!=0) return -1; json_parse_string_alloc(json, "vid", (char**)&out->Vid); json_parse_string_alloc(json, "pid", (char**)&out->Pid); json_parse_string_alloc(json, "type", (char**)&out->Type); return 0; } -{{range .DTOs}}{{if ne .Name "Device"}} -{{genParser .}} -{{end}}{{end}} - -/* ======================================================================== - * 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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, - {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, - {{responseCType .ResponseDTO}}* 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 based on PayloadKind */ - char payload[512]; payload[0]='\0'; - {{if eq .Payload.Kind "json"}} - {{marshalPayload .Payload}} - {{else if eq .Payload.Kind "numeric"}}{{if .Payload.Required}} - snprintf(payload, sizeof payload, "%u", (unsigned)payload_value); - {{else}} - if (payload_value) { - snprintf(payload, sizeof payload, "%u", (unsigned)*payload_value); - } - {{end}} - {{else if eq .Payload.Kind "string"}} - if (payload_str && payload_str[0]) { - snprintf(payload, sizeof payload, "%s", payload_str); - } - {{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: {"status":4xx/5xx} (RFC 7807) */ - if (line && strncmp(line, "{\"status\":", 11) == 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 "Device"}}prc = viiper_parse_device_info_obj(line, out);{{else}}prc = viiper_parse_{{snakecase .ResponseDTO}}(line, out);{{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; - 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 */ - if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { - int rd = recv(dev->socket_fd, (char*)dev->output_buffer, (int)dev->output_buffer_size, 0); - if (rd <= 0) break; - dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); - } -#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 */ - if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { - ssize_t rd = recv(dev->socket_fd, dev->output_buffer, dev->output_buffer_size, 0); - if (rd <= 0) break; - dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); - } -#endif - } - /* Call disconnect callback if registered */ - if (dev->disconnect_callback) { - dev->disconnect_callback(dev->disconnect_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 with null terminator for framing */ - char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); - if (viiper_send_line(fd, pathbuf) != 0) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return VIIPER_ERROR_IO; - } - 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, - void* buffer, - size_t buffer_size, - viiper_output_cb callback, - void* user_data -) { - if (!device) return; - device->output_buffer = buffer; - device->output_buffer_size = buffer_size; - device->callback = callback; - device->callback_user = user_data; -} - -VIIPER_API void viiper_device_on_disconnect( - viiper_device_t* device, - viiper_disconnect_cb callback, - void* user_data -) { - if (!device) return; - device->disconnect_callback = callback; - device->disconnect_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); -} - -/* OpenStream: connect to an existing device's stream channel (device must already exist on bus) */ -VIIPER_API viiper_error_t viiper_open_stream( - 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; - char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); - if (viiper_send_line(fd, pathbuf) != 0) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return VIIPER_ERROR_IO; - } - 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; -#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; -} - -/* Convenience wrapper: AddDeviceAndConnect (create device on bus then open stream) */ -VIIPER_API viiper_error_t viiper_add_device_and_connect( - viiper_client_t* client, - uint32_t bus_id, - const viiper_device_create_request_t* request, - viiper_device_info_t* out_info, - viiper_device_t** out_device -) { - if (!client || !out_info || !out_device) return VIIPER_ERROR_INVALID_PARAM; - char busIdStr[32]; snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)bus_id); - viiper_error_t rc = viiper_bus_device_add(client, busIdStr, request, out_info); - if (rc != VIIPER_OK) return rc; - const char* devId = out_info->DevId ? out_info->DevId : NULL; - if (!devId) return VIIPER_ERROR_PROTOCOL; /* missing devId */ - return viiper_open_stream(client, bus_id, devId, out_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 -} +package cgen + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const commonSourceTmpl = `/* Auto-generated VIIPER - C Client Library: common source */ + +#include "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 +# 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 */ + void* output_buffer; + size_t output_buffer_size; + viiper_output_cb callback; + void* callback_user; + /* Disconnect callback */ + viiper_disconnect_cb disconnect_callback; + void* disconnect_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 + * ======================================================================== */ + +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) { + int flag = 1; +#if defined(_WIN32) || defined(_WIN64) + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&flag, sizeof(flag)); +#else + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); +#endif + 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, "\0", 1, 0); + if (wr < 0) return -1; +#else + ssize_t wr = send(fd, line, n, 0); + if (wr < 0) return -1; + wr = send(fd, "\0", 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 (rd == 0) break; + if (ch == '\0') 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 */ +{{range .DTOs}}{{if ne .Name "Device"}} +{{genFreeFunc .}} +{{end}}{{end}} + +/* Parsers */ +static int viiper_parse_device_info_obj(const char* json, viiper_device_info_t* out){ if (json_parse_uint32(json, "busId", &out->BusID)!=0) return -1; if (json_parse_string_alloc(json, "devId", (char**)&out->DevId)!=0) return -1; json_parse_string_alloc(json, "vid", (char**)&out->Vid); json_parse_string_alloc(json, "pid", (char**)&out->Pid); json_parse_string_alloc(json, "type", (char**)&out->Type); return 0; } +{{range .DTOs}}{{if ne .Name "Device"}} +{{genParser .}} +{{end}}{{end}} + +/* ======================================================================== + * 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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, + {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, + {{responseCType .ResponseDTO}}* 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 based on PayloadKind */ + char payload[512]; payload[0]='\0'; + {{if eq .Payload.Kind "json"}} + {{marshalPayload .Payload}} + {{else if eq .Payload.Kind "numeric"}}{{if .Payload.Required}} + snprintf(payload, sizeof payload, "%u", (unsigned)payload_value); + {{else}} + if (payload_value) { + snprintf(payload, sizeof payload, "%u", (unsigned)*payload_value); + } + {{end}} + {{else if eq .Payload.Kind "string"}} + if (payload_str && payload_str[0]) { + snprintf(payload, sizeof payload, "%s", payload_str); + } + {{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: {"status":4xx/5xx} (RFC 7807) */ + if (line && strncmp(line, "{\"status\":", 11) == 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 "Device"}}prc = viiper_parse_device_info_obj(line, out);{{else}}prc = viiper_parse_{{snakecase .ResponseDTO}}(line, out);{{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; + 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 */ + if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { + int rd = recv(dev->socket_fd, (char*)dev->output_buffer, (int)dev->output_buffer_size, 0); + if (rd <= 0) break; + dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); + } +#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 */ + if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { + ssize_t rd = recv(dev->socket_fd, dev->output_buffer, dev->output_buffer_size, 0); + if (rd <= 0) break; + dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); + } +#endif + } + /* Call disconnect callback if registered */ + if (dev->disconnect_callback) { + dev->disconnect_callback(dev->disconnect_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 with null terminator for framing */ + char pathbuf[256]; + snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); + if (viiper_send_line(fd, pathbuf) != 0) { +#if defined(_WIN32) || defined(_WIN64) + closesocket(fd); +#else + close(fd); +#endif + return VIIPER_ERROR_IO; + } + 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, + void* buffer, + size_t buffer_size, + viiper_output_cb callback, + void* user_data +) { + if (!device) return; + device->output_buffer = buffer; + device->output_buffer_size = buffer_size; + device->callback = callback; + device->callback_user = user_data; +} + +VIIPER_API void viiper_device_on_disconnect( + viiper_device_t* device, + viiper_disconnect_cb callback, + void* user_data +) { + if (!device) return; + device->disconnect_callback = callback; + device->disconnect_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); +} + +/* OpenStream: connect to an existing device's stream channel (device must already exist on bus) */ +VIIPER_API viiper_error_t viiper_open_stream( + 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; + char pathbuf[256]; + snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); + if (viiper_send_line(fd, pathbuf) != 0) { +#if defined(_WIN32) || defined(_WIN64) + closesocket(fd); +#else + close(fd); +#endif + return VIIPER_ERROR_IO; + } + 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; +#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; +} + +/* Convenience wrapper: AddDeviceAndConnect (create device on bus then open stream) */ +VIIPER_API viiper_error_t viiper_add_device_and_connect( + viiper_client_t* client, + uint32_t bus_id, + const viiper_device_create_request_t* request, + viiper_device_info_t* out_info, + viiper_device_t** out_device +) { + if (!client || !out_info || !out_device) return VIIPER_ERROR_INVALID_PARAM; + char busIdStr[32]; snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)bus_id); + viiper_error_t rc = viiper_bus_device_add(client, busIdStr, request, out_info); + if (rc != VIIPER_OK) return rc; + const char* devId = out_info->DevId ? out_info->DevId : NULL; + if (!devId) return VIIPER_ERROR_PROTOCOL; /* missing devId */ + return viiper_open_stream(client, bus_id, devId, out_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/internal/codegen/generator/c/source_device.go b/internal/codegen/generator/c/source_device.go index a94645c2..6f4c57cf 100644 --- a/internal/codegen/generator/c/source_device.go +++ b/internal/codegen/generator/c/source_device.go @@ -1,54 +1,54 @@ -package cgen - -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 deviceSourceTmpl = `/* Auto-generated VIIPER - C Client Library: device source ({{.Device}}) */ - -#include "viiper.h" -#include "viiper_{{.Device}}.h" - -/* ======================================================================== - * {{.Device}} Map Implementations - * ======================================================================== */ -{{- range .Pkg.Maps }} - -{{mapFuncImpl $.Device .}} -{{- end}} -` - -type deviceSourceData struct { - Device string - HasS2C bool - Pkg *scanner.DeviceConstants -} - -func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.Metadata) error { - pkg := md.DevicePackages[device] - data := deviceSourceData{ - Device: device, - HasS2C: common.HasWireTag(md, device, "s2c"), - Pkg: pkg, - } - out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) - t := template.Must(template.New("device.c").Funcs(tplFuncs(md)).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 -} +package cgen + +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 deviceSourceTmpl = `/* Auto-generated VIIPER - C Client Library: device source ({{.Device}}) */ + +#include "viiper.h" +#include "viiper_{{.Device}}.h" + +/* ======================================================================== + * {{.Device}} Map Implementations + * ======================================================================== */ +{{- range .Pkg.Maps }} + +{{mapFuncImpl $.Device .}} +{{- end}} +` + +type deviceSourceData struct { + Device string + HasS2C bool + Pkg *scanner.DeviceConstants +} + +func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.Metadata) error { + pkg := md.DevicePackages[device] + data := deviceSourceData{ + Device: device, + HasS2C: common.HasWireTag(md, device, "s2c"), + Pkg: pkg, + } + out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) + t := template.Must(template.New("device.c").Funcs(tplFuncs(md)).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/internal/codegen/generator/cpp/client.go b/internal/codegen/generator/cpp/client.go index 316293b4..00a9c2ca 100644 --- a/internal/codegen/generator/cpp/client.go +++ b/internal/codegen/generator/cpp/client.go @@ -1,168 +1,168 @@ -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 -#include -#include -#include - -namespace viiper { - -// ============================================================================ -// VIIPER Management API Client (thread-safe) -// ============================================================================ - -class ViiperClient { -public: - ViiperClient(std::string host, std::uint16_t port = 3242) - : host_(std::move(host)), port_(port) {} - - ~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_; } - - // ======================================================================== - // 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'; - 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'; - - 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_; - 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() - - 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 -} +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 +#include +#include +#include + +namespace viiper { + +// ============================================================================ +// VIIPER Management API Client (thread-safe) +// ============================================================================ + +class ViiperClient { +public: + ViiperClient(std::string host, std::uint16_t port = 3242) + : host_(std::move(host)), port_(port) {} + + ~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_; } + + // ======================================================================== + // 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'; + 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'; + + 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_; + 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() + + 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/json.go b/internal/codegen/generator/cpp/json.go index df2c893d..1352579b 100644 --- a/internal/codegen/generator/cpp/json.go +++ b/internal/codegen/generator/cpp/json.go @@ -1,116 +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 -} +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/socket.go b/internal/codegen/generator/cpp/socket.go index b95ba6ef..35aa035d 100644 --- a/internal/codegen/generator/cpp/socket.go +++ b/internal/codegen/generator/cpp/socket.go @@ -1,372 +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 -} +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/csharp/client.go b/internal/codegen/generator/csharp/client.go index 280c143d..d54a61d4 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -1,225 +1,225 @@ -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 bool _disposed; - - /// - /// Creates a new VIIPER client instance - /// - /// VIIPER server hostname or IP address - /// VIIPER API server port (default: 3242) - public ViiperClient(string host, int port = 3242) - { - _host = host ?? throw new ArgumentNullException(nameof(host)); - _port = port; - } -{{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; - - using var stream = client.GetStream(); - - // Build command line: "path[ optional-payload]\0" - 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; - var stream = client.GetStream(); - // 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() - - 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" -} +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 bool _disposed; + + /// + /// Creates a new VIIPER client instance + /// + /// VIIPER server hostname or IP address + /// VIIPER API server port (default: 3242) + public ViiperClient(string host, int port = 3242) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + } +{{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; + + using var stream = client.GetStream(); + + // Build command line: "path[ optional-payload]\0" + 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; + var stream = client.GetStream(); + // 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() + + 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 index 03596819..e7d9861a 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -1,437 +1,437 @@ -package csharp - -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" -) - -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 - Maps []mapData - }{ - Device: pascalDevice, - OutputSize: outputSize, - 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() - - tmpl := template.Must(template.New("constants").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 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 { - 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), - 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 shouldGenerateEnum(eg enumGroup) bool { - return len(eg.Constants) >= 3 -} - -func inferEnumType(constants []constantInfo) string { - return "uint" -} - -func isFlags(constants []constantInfo) bool { - for _, c := range constants { - if strings.HasPrefix(c.Value, "0x") { - var val uint64 - fmt.Sscanf(c.Value, "0x%x", &val) - if val > 0 && (val&(val-1)) == 0 { - return true - } - if val > 0 { - return true - } - } - } - return false -} - -func formatConstValue(value interface{}) string { - switch v := value.(type) { - case int64: - return fmt.Sprintf("0x%X", v) - case uint64: - return fmt.Sprintf("0x%X", v) - case int: - return fmt.Sprintf("0x%X", v) - case string: - return fmt.Sprintf("\"%s\"", v) - case float64: - return fmt.Sprintf("%f", 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 "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 { - if key[0] == '\'' { - return "(byte)'\\''" - } else if key[0] == '\\' { - 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) - 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) - default: - return formatConstValue(value) - } -} - -const constantsTemplate = `using System; -using System.Collections.Generic; - -namespace Viiper.Client.Devices.{{.Device}}; - -{{if gt .OutputSize 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}} -{ - public const int OutputSize = {{.OutputSize}}; -} - -{{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 {{.ValueType}} GetValueOrDefault({{.KeyType}} key, {{.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}} -` +package csharp + +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" +) + +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 + Maps []mapData + }{ + Device: pascalDevice, + OutputSize: outputSize, + 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() + + tmpl := template.Must(template.New("constants").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 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 { + 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), + 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 shouldGenerateEnum(eg enumGroup) bool { + return len(eg.Constants) >= 3 +} + +func inferEnumType(constants []constantInfo) string { + return "uint" +} + +func isFlags(constants []constantInfo) bool { + for _, c := range constants { + if strings.HasPrefix(c.Value, "0x") { + var val uint64 + fmt.Sscanf(c.Value, "0x%x", &val) + if val > 0 && (val&(val-1)) == 0 { + return true + } + if val > 0 { + return true + } + } + } + return false +} + +func formatConstValue(value interface{}) string { + switch v := value.(type) { + case int64: + return fmt.Sprintf("0x%X", v) + case uint64: + return fmt.Sprintf("0x%X", v) + case int: + return fmt.Sprintf("0x%X", v) + case string: + return fmt.Sprintf("\"%s\"", v) + case float64: + return fmt.Sprintf("%f", 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 "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 { + if key[0] == '\'' { + return "(byte)'\\''" + } else if key[0] == '\\' { + 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) + 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) + default: + return formatConstValue(value) + } +} + +const constantsTemplate = `using System; +using System.Collections.Generic; + +namespace Viiper.Client.Devices.{{.Device}}; + +{{if gt .OutputSize 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}} +{ + public const int OutputSize = {{.OutputSize}}; +} + +{{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 {{.ValueType}} GetValueOrDefault({{.KeyType}} key, {{.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 index 0cd4d1bf..370eb20f 100644 --- a/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -1,168 +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 NetworkStream _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, NetworkStream 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() - if err := tmpl.Execute(f, md); err != nil { - return fmt.Errorf("execute device template: %w", err) - } - logger.Info("Generated ViiperDevice", "file", outputFile) - return nil -} +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 NetworkStream _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, NetworkStream 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() + 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_types.go b/internal/codegen/generator/csharp/device_types.go index 3ef9fbf9..aa1ca485 100644 --- a/internal/codegen/generator/csharp/device_types.go +++ b/internal/codegen/generator/csharp/device_types.go @@ -1,217 +1,217 @@ -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" -) - -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() - - data := struct { - Device string - ClassName string - Fields []wireField - HasArray bool - ArrayInfo *arrayFieldInfo - }{ - Device: device, - ClassName: className, - } - - for _, field := range tag.Fields { - wf := wireField{ - Name: toPascalCase(field.Name), - GoType: field.Type, - CSType: mapGoTypeToCSharp(field.Type), - } - - if strings.Contains(field.Spec, "*") { - parts := strings.Split(field.Spec, "*") - if len(parts) == 2 { - data.HasArray = true - data.ArrayInfo = &arrayFieldInfo{ - FieldName: wf.Name, - CountFieldName: toPascalCase(parts[1]), - ElementType: wf.CSType, - } - wf.IsArray = true - } - } - - 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 - GoType string - CSType string - IsArray bool -} - -type arrayFieldInfo struct { - FieldName string - CountFieldName string - ElementType string -} - -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}} public required {{.CSType}}{{if .IsArray}}[]{{end}} {{.Name}} { get; set; } -{{end}} - public void Write(BinaryWriter writer) - { -{{if .HasArray}} // Write fixed fields -{{range .Fields}}{{if not .IsArray}} writer.Write({{.Name}}); -{{end}}{{end}} - // Write variable-length array - for (int i = 0; i < {{.ArrayInfo.CountFieldName}}; i++) - { - writer.Write({{.ArrayInfo.FieldName}}[i]); - } -{{else}}{{range .Fields}} writer.Write({{.Name}}); -{{end}}{{end}} } - - /// - /// Read from binary stream (for receiving output from server). - /// - public static {{.Device}}{{.ClassName}} Read(BinaryReader reader) - { -{{if .HasArray}} // Read fixed fields -{{range .Fields}}{{if not .IsArray}} var {{toCamel .Name}} = reader.Read{{readerMethod .CSType}}(); -{{end}}{{end}} - // Read variable-length array - var {{toCamel .ArrayInfo.FieldName}} = new {{.ArrayInfo.ElementType}}[{{toCamel .ArrayInfo.CountFieldName}}]; - for (int i = 0; i < {{toCamel .ArrayInfo.CountFieldName}}; i++) - { - {{toCamel .ArrayInfo.FieldName}}[i] = reader.Read{{readerMethod .ArrayInfo.ElementType}}(); - } - - return new {{.Device}}{{.ClassName}} - { -{{range .Fields}}{{if not .IsArray}} {{.Name}} = {{toCamel .Name}}, -{{end}}{{end}} {{.ArrayInfo.FieldName}} = {{toCamel .ArrayInfo.FieldName}} - }; -{{else}} return new {{.Device}}{{.ClassName}} - { -{{range .Fields}} {{.Name}} = reader.Read{{readerMethod .CSType}}(), -{{end}} }; -{{end}} } -} -` +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" +) + +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() + + data := struct { + Device string + ClassName string + Fields []wireField + HasArray bool + ArrayInfo *arrayFieldInfo + }{ + Device: device, + ClassName: className, + } + + for _, field := range tag.Fields { + wf := wireField{ + Name: toPascalCase(field.Name), + GoType: field.Type, + CSType: mapGoTypeToCSharp(field.Type), + } + + if strings.Contains(field.Spec, "*") { + parts := strings.Split(field.Spec, "*") + if len(parts) == 2 { + data.HasArray = true + data.ArrayInfo = &arrayFieldInfo{ + FieldName: wf.Name, + CountFieldName: toPascalCase(parts[1]), + ElementType: wf.CSType, + } + wf.IsArray = true + } + } + + 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 + GoType string + CSType string + IsArray bool +} + +type arrayFieldInfo struct { + FieldName string + CountFieldName string + ElementType string +} + +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}} public required {{.CSType}}{{if .IsArray}}[]{{end}} {{.Name}} { get; set; } +{{end}} + public void Write(BinaryWriter writer) + { +{{if .HasArray}} // Write fixed fields +{{range .Fields}}{{if not .IsArray}} writer.Write({{.Name}}); +{{end}}{{end}} + // Write variable-length array + for (int i = 0; i < {{.ArrayInfo.CountFieldName}}; i++) + { + writer.Write({{.ArrayInfo.FieldName}}[i]); + } +{{else}}{{range .Fields}} writer.Write({{.Name}}); +{{end}}{{end}} } + + /// + /// Read from binary stream (for receiving output from server). + /// + public static {{.Device}}{{.ClassName}} Read(BinaryReader reader) + { +{{if .HasArray}} // Read fixed fields +{{range .Fields}}{{if not .IsArray}} var {{toCamel .Name}} = reader.Read{{readerMethod .CSType}}(); +{{end}}{{end}} + // Read variable-length array + var {{toCamel .ArrayInfo.FieldName}} = new {{.ArrayInfo.ElementType}}[{{toCamel .ArrayInfo.CountFieldName}}]; + for (int i = 0; i < {{toCamel .ArrayInfo.CountFieldName}}; i++) + { + {{toCamel .ArrayInfo.FieldName}}[i] = reader.Read{{readerMethod .ArrayInfo.ElementType}}(); + } + + return new {{.Device}}{{.ClassName}} + { +{{range .Fields}}{{if not .IsArray}} {{.Name}} = {{toCamel .Name}}, +{{end}}{{end}} {{.ArrayInfo.FieldName}} = {{toCamel .ArrayInfo.FieldName}} + }; +{{else}} return new {{.Device}}{{.ClassName}} + { +{{range .Fields}} {{.Name}} = reader.Read{{readerMethod .CSType}}(), +{{end}} }; +{{end}} } +} +` diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go index 1444f0e0..5a9e0bf8 100644 --- a/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -1,71 +1,71 @@ -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 := 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 := 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 -} +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 := 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 := 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 index ebec0309..3350cf64 100644 --- a/internal/codegen/generator/csharp/helpers.go +++ b/internal/codegen/generator/csharp/helpers.go @@ -1,50 +1,50 @@ -package csharp - -import ( - "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) - - 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 toPascalCase(base) - } -} - -func writeFileHeader() string { return common.FileHeader("//", "C#") } +package csharp + +import ( + "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) + + 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 toPascalCase(base) + } +} + +func writeFileHeader() string { return common.FileHeader("//", "C#") } diff --git a/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go index dd96fb2a..43123926 100644 --- a/internal/codegen/generator/csharp/project.go +++ b/internal/codegen/generator/csharp/project.go @@ -1,67 +1,67 @@ -package csharp - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const projectTemplate = ` - - - net8.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, md *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() - - 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 -} +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const projectTemplate = ` + + + net8.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, md *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() + + 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 index 3428aa1c..c2abdc02 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -1,86 +1,86 @@ -package csharp - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "reflect" - "strings" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const dtoTemplate = `{{writeFileHeader}}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() - - 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 == "slice" || strings.HasPrefix(typeStr, "[]") { - elemType := strings.TrimPrefix(typeStr, "[]") - csharpElemType := goTypeToCSharp(elemType) - return csharpElemType + "[]" - } - - if typeKind == "struct" { - return toPascalCase(typeStr) - } - - return goTypeToCSharp(typeStr) -} +package csharp + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" + + "github.com/Alia5/VIIPER/internal/codegen/meta" +) + +const dtoTemplate = `{{writeFileHeader}}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() + + 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 == "slice" || strings.HasPrefix(typeStr, "[]") { + elemType := strings.TrimPrefix(typeStr, "[]") + csharpElemType := goTypeToCSharp(elemType) + return csharpElemType + "[]" + } + + if typeKind == "struct" { + return toPascalCase(typeStr) + } + + return goTypeToCSharp(typeStr) +} diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index 6820614b..083b9d8b 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -1,164 +1,164 @@ -package generator - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - - cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" - "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{ - "c": cgen.Generate, - "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", "apitypes", "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), - 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("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)) - - 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" - 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)) - } - - 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 -} +package generator + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" + "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{ + "c": cgen.Generate, + "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", "apitypes", "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), + 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("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)) + + 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" + 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)) + } + + 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 +} diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index 72f141b7..763f0490 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -1,255 +1,255 @@ -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::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; - -/// VIIPER management API client (asynchronous). -#[cfg(feature = "async")] -pub struct AsyncViiperClient { - addr: SocketAddr, -} - -#[cfg(feature = "async")] -impl AsyncViiperClient { - /// Create a new async VIIPER client connecting to the specified address. - pub fn new(addr: SocketAddr) -> Self { - Self { addr } - } - - async fn do_request serde::Deserialize<'de>>( - &self, - path: &str, - payload: Option<&str>, - ) -> Result { - let mut stream = TcpStream::connect(self.addr).await?; - stream.set_nodelay(true)?; - - 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).await - } -} - -/// An async connected device stream for bidirectional communication. -/// Uses split read/write halves to allow simultaneous sending and receiving. -#[cfg(feature = "async")] -pub struct AsyncDeviceStream { - reader: std::sync::Arc>, - writer: 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) -> Result { - let mut stream = TcpStream::connect(addr).await?; - stream.set_nodelay(true)?; - let handshake = format!("bus/{}/{}\0", bus_id, dev_id); - stream.write_all(handshake.as_bytes()).await?; - let (reader, writer) = stream.into_split(); - Ok(Self { - reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), - writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), - 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 writer = self.writer.lock().await; - writer.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 writer = self.writer.lock().await; - tokio::time::timeout(timeout, writer.write_all(&bytes)) - .await - .map_err(|_| ViiperError::Timeout)? - .map_err(Into::into) - } - - /// Register a callback to receive device output asynchronously. - /// The callback receives the read half of the stream 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 reader = self.reader.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(reader.clone()) => { - match result { - Ok(()) => continue, - Err(_) => break, - } - } - } - } - if let Some(on_disconnect) = disconnect { - on_disconnect(); - } - }); - 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 writer = self.writer.lock().await; - writer.write_all(data).await?; - Ok(()) - } - - /// Read raw bytes from the device. - pub async fn read_raw(&self, buf: &mut [u8]) -> Result { - let mut reader = self.reader.lock().await; - reader.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 reader = self.reader.lock().await; - reader.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() - - 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 -} +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::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; + +/// VIIPER management API client (asynchronous). +#[cfg(feature = "async")] +pub struct AsyncViiperClient { + addr: SocketAddr, +} + +#[cfg(feature = "async")] +impl AsyncViiperClient { + /// Create a new async VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr } + } + + async fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let mut stream = TcpStream::connect(self.addr).await?; + stream.set_nodelay(true)?; + + 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).await + } +} + +/// An async connected device stream for bidirectional communication. +/// Uses split read/write halves to allow simultaneous sending and receiving. +#[cfg(feature = "async")] +pub struct AsyncDeviceStream { + reader: std::sync::Arc>, + writer: 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) -> Result { + let mut stream = TcpStream::connect(addr).await?; + stream.set_nodelay(true)?; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); + stream.write_all(handshake.as_bytes()).await?; + let (reader, writer) = stream.into_split(); + Ok(Self { + reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), + writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), + 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 writer = self.writer.lock().await; + writer.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 writer = self.writer.lock().await; + tokio::time::timeout(timeout, writer.write_all(&bytes)) + .await + .map_err(|_| ViiperError::Timeout)? + .map_err(Into::into) + } + + /// Register a callback to receive device output asynchronously. + /// The callback receives the read half of the stream 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 reader = self.reader.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(reader.clone()) => { + match result { + Ok(()) => continue, + Err(_) => break, + } + } + } + } + if let Some(on_disconnect) = disconnect { + on_disconnect(); + } + }); + 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 writer = self.writer.lock().await; + writer.write_all(data).await?; + Ok(()) + } + + /// Read raw bytes from the device. + pub async fn read_raw(&self, buf: &mut [u8]) -> Result { + let mut reader = self.reader.lock().await; + reader.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 reader = self.reader.lock().await; + reader.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() + + 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/client.go b/internal/codegen/generator/rust/client.go index 7ac1c564..75250e93 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -1,200 +1,200 @@ -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}; - -/// VIIPER management API client (synchronous). -pub struct ViiperClient { - addr: SocketAddr, -} - -impl ViiperClient { - /// Create a new VIIPER client connecting to the specified address. - pub fn new(addr: SocketAddr) -> Self { - Self { addr } - } - - fn do_request serde::Deserialize<'de>>( - &self, - path: &str, - payload: Option<&str>, - ) -> Result { - let mut stream = TcpStream::connect(self.addr)?; - stream.set_nodelay(true)?; - - 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) - } -} - -/// A connected device stream for bidirectional communication. -pub struct DeviceStream { - stream: TcpStream, - output_thread: Option>, - disconnect_callback: Option>, -} - -impl DeviceStream { - pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { - let mut stream = TcpStream::connect(addr)?; - stream.set_nodelay(true)?; - 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() - - 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 -} +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}; + +/// VIIPER management API client (synchronous). +pub struct ViiperClient { + addr: SocketAddr, +} + +impl ViiperClient { + /// Create a new VIIPER client connecting to the specified address. + pub fn new(addr: SocketAddr) -> Self { + Self { addr } + } + + fn do_request serde::Deserialize<'de>>( + &self, + path: &str, + payload: Option<&str>, + ) -> Result { + let mut stream = TcpStream::connect(self.addr)?; + stream.set_nodelay(true)?; + + 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) + } +} + +/// A connected device stream for bidirectional communication. +pub struct DeviceStream { + stream: TcpStream, + output_thread: Option>, + disconnect_callback: Option>, +} + +impl DeviceStream { + pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { + let mut stream = TcpStream::connect(addr)?; + stream.set_nodelay(true)?; + 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() + + 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 index b9951e0d..673b2669 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -1,225 +1,225 @@ -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" -) - -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 { - rustType := goTypeToRust(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() - - 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 formatConstValue(val interface{}, goType string) string { - base, _, _ := common.NormalizeGoType(goType) - - switch base { - case "string": - return fmt.Sprintf(`"%v"`, val) - 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) - } -} +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" +) + +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 { + rustType := goTypeToRust(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() + + 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 formatConstValue(val interface{}, goType string) string { + base, _, _ := common.NormalizeGoType(goType) + + switch base { + case "string": + return fmt.Sprintf(`"%v"`, val) + 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_types.go b/internal/codegen/generator/rust/device_types.go index e22b3c58..951c748e 100644 --- a/internal/codegen/generator/rust/device_types.go +++ b/internal/codegen/generator/rust/device_types.go @@ -1,180 +1,180 @@ -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" -) - -const deviceInputTemplate = `{{.Header}} -use crate::wire::DeviceInput; - -#[derive(Debug, Clone, Default)] -pub struct {{.StructName}} { -{{range .Fields}} pub {{.RustName}}: {{.RustType}}, -{{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, Default)] -pub struct {{.StructName}} { -{{range .Fields}} pub {{.RustName}}: {{.RustType}}, -{{end}}} - -impl DeviceOutput for {{.StructName}} { - fn from_bytes(buf: &[u8]) -> Result { - let mut offset = 0; -{{range .Fields}}{{if .IsArray}} // Read array (size determined by remaining bytes) - 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; - } -{{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 -} - -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 - - isArray := isWireArray(field.Spec) - var countName string - var elemType string - - if isArray { - countName = extractArrayCount(field.Spec) - idx := strings.Index(wireType, "*") - if idx > 0 { - baseType = wireType[:idx] - } - elemType = wireTypeToRust(baseType) - } - - rustType := wireTypeToRust(baseType) - if isArray { - rustType = fmt.Sprintf("Vec<%s>", elemType) - } - - fields = append(fields, rustWireField{ - Name: field.Name, - RustName: rustName, - RustType: rustType, - ElementType: elemType, - IsArray: isArray, - CountName: countName, - }) - } - - data := deviceTypeData{ - Header: writeFileHeaderRust(), - DeviceName: deviceName, - StructName: deviceName + className, - Fields: fields, - } - - funcMap := template.FuncMap{} - 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() - - if err := tmpl.Execute(f, data); err != nil { - return fmt.Errorf("execute template: %w", err) - } - - return nil -} +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" +) + +const deviceInputTemplate = `{{.Header}} +use crate::wire::DeviceInput; + +#[derive(Debug, Clone, Default)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{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, Default)] +pub struct {{.StructName}} { +{{range .Fields}} pub {{.RustName}}: {{.RustType}}, +{{end}}} + +impl DeviceOutput for {{.StructName}} { + fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; +{{range .Fields}}{{if .IsArray}} // Read array (size determined by remaining bytes) + 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; + } +{{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 +} + +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 + + isArray := isWireArray(field.Spec) + var countName string + var elemType string + + if isArray { + countName = extractArrayCount(field.Spec) + idx := strings.Index(wireType, "*") + if idx > 0 { + baseType = wireType[:idx] + } + elemType = wireTypeToRust(baseType) + } + + rustType := wireTypeToRust(baseType) + if isArray { + rustType = fmt.Sprintf("Vec<%s>", elemType) + } + + fields = append(fields, rustWireField{ + Name: field.Name, + RustName: rustName, + RustType: rustType, + ElementType: elemType, + IsArray: isArray, + CountName: countName, + }) + } + + data := deviceTypeData{ + Header: writeFileHeaderRust(), + DeviceName: deviceName, + StructName: deviceName + className, + Fields: fields, + } + + funcMap := template.FuncMap{} + 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() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} diff --git a/internal/codegen/generator/rust/error.go b/internal/codegen/generator/rust/error.go index 83b08893..669d8320 100644 --- a/internal/codegen/generator/rust/error.go +++ b/internal/codegen/generator/rust/error.go @@ -1,67 +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 -} +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 index b6674480..892aa87e 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -1,175 +1,175 @@ -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 - } - - 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 := 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, md *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; - -#[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) - - 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 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 -} +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 + } + + 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 := 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, md *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; + +#[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) + + 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 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 index dc4853ab..256a3e3d 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -1,165 +1,165 @@ -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) - - 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.ToPascalCase(base) - } - - if isSlice { - rustType = fmt.Sprintf("Vec<%s>", rustType) - } - if isPointer { - rustType = fmt.Sprintf("Option<%s>", rustType) - } - - return rustType -} - -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 isWireArray(spec string) bool { - return strings.Contains(spec, "*") -} - -func extractArrayCount(spec string) string { - parts := strings.Split(spec, "*") - if len(parts) == 2 { - return parts[1] - } - return "" -} - -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;" - } -} +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) + + 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.ToPascalCase(base) + } + + if isSlice { + rustType = fmt.Sprintf("Vec<%s>", rustType) + } + if isPointer { + rustType = fmt.Sprintf("Option<%s>", rustType) + } + + return rustType +} + +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 isWireArray(spec string) bool { + return strings.Contains(spec, "*") +} + +func extractArrayCount(spec string) string { + parts := strings.Split(spec, "*") + if len(parts) == 2 { + return parts[1] + } + return "" +} + +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 index d3cdeeda..ef0a1c18 100644 --- a/internal/codegen/generator/rust/project.go +++ b/internal/codegen/generator/rust/project.go @@ -1,101 +1,101 @@ -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" - -[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() - - 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 -} +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" + +[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() + + 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 index 70da2ef0..ce08c0ca 100644 --- a/internal/codegen/generator/rust/types.go +++ b/internal/codegen/generator/rust/types.go @@ -1,138 +1,138 @@ -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() - - 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 == "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] -} +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() + + 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 == "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 index 21413165..c38f891d 100644 --- a/internal/codegen/generator/rust/wire.go +++ b/internal/codegen/generator/rust/wire.go @@ -1,45 +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() - - if _, err := f.WriteString(wireModuleTemplate); err != nil { - return fmt.Errorf("write template: %w", err) - } - - logger.Info("Generated wire.rs", "file", outputFile) - return nil -} +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() + + 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/binary_utils.go b/internal/codegen/generator/typescript/binary_utils.go index 1128b75a..07b5425a 100644 --- a/internal/codegen/generator/typescript/binary_utils.go +++ b/internal/codegen/generator/typescript/binary_utils.go @@ -1,56 +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() - 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 -} +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() + 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 index 366aed0f..35f5313f 100644 --- a/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -1,199 +1,199 @@ -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'; - -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. - */ -export class ViiperClient { - private host: string; - private port: number; - - constructor(host: string, port: number = 3242) { - this.host = host; - this.port = port; - } -{{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 sendRequest(path: string, payload?: string | null): Promise { - return new Promise((resolve, reject) => { - const socket = new Socket(); - socket.connect(this.port, this.host, () => { - socket.setNoDelay(true); - let line = path; // preserve case - if (payload && payload.length > 0) line += ' ' + payload; - line += '\0'; - socket.write(encoder.encode(line)); - }); - - let buffer = ''; - socket.on('data', (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) { - socket.end(); - reject(e); - return; - } - if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { - socket.end(); - reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); - return; - } - socket.end(); - resolve(parsed as T); - } - }); - - socket.on('error', reject); - socket.on('end', () => {/* noop */}); - }); - } - - async connectDevice(busId: number, devId: string): Promise { - return new Promise((resolve, reject) => { - const socket = new Socket(); - socket.connect(this.port, this.host, () => { - socket.setNoDelay(true); - const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; - socket.write(encoder.encode(line)); - resolve(new ViiperDevice(socket)); - }); - 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() - 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" -} +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'; + +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. + */ +export class ViiperClient { + private host: string; + private port: number; + + constructor(host: string, port: number = 3242) { + this.host = host; + this.port = port; + } +{{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 sendRequest(path: string, payload?: string | null): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + socket.setNoDelay(true); + let line = path; // preserve case + if (payload && payload.length > 0) line += ' ' + payload; + line += '\0'; + socket.write(encoder.encode(line)); + }); + + let buffer = ''; + socket.on('data', (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) { + socket.end(); + reject(e); + return; + } + if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { + socket.end(); + reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); + return; + } + socket.end(); + resolve(parsed as T); + } + }); + + socket.on('error', reject); + socket.on('end', () => {/* noop */}); + }); + } + + async connectDevice(busId: number, devId: string): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + socket.connect(this.port, this.host, () => { + socket.setNoDelay(true); + const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; + socket.write(encoder.encode(line)); + resolve(new ViiperDevice(socket)); + }); + 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() + 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 index 6ae2bf09..b587b73c 100644 --- a/internal/codegen/generator/typescript/constants.go +++ b/internal/codegen/generator/typescript/constants.go @@ -1,232 +1,232 @@ -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 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 - Maps []tsMapData - }{Device: pascalDevice, Enums: enums, Maps: maps} - f, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("create file: %w", err) - } - defer f.Close() - 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 { - 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 formatConstValueTS(v interface{}) string { - switch t := v.(type) { - case int64: - return fmt.Sprintf("0x%X", t) - case uint64: - return fmt.Sprintf("0x%X", t) - case int: - 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 .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}} -` +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 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 + Maps []tsMapData + }{Device: pascalDevice, Enums: enums, Maps: maps} + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() + 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 { + 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 formatConstValueTS(v interface{}) string { + switch t := v.(type) { + case int64: + return fmt.Sprintf("0x%X", t) + case uint64: + return fmt.Sprintf("0x%X", t) + case int: + 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 .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_types.go b/internal/codegen/generator/typescript/device_types.go index 8c6e061b..efecd404 100644 --- a/internal/codegen/generator/typescript/device_types.go +++ b/internal/codegen/generator/typescript/device_types.go @@ -1,172 +1,172 @@ -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" -) - -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 - GoType string - Writer string - Reader string - IsArray bool - CountName string -} - -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() - data := struct { - Device string - ClassName string - Fields []tsWireField - HasArray bool - ArrayInfo *tsWireField - }{Device: device, ClassName: className} - for _, field := range tag.Fields { - wf := tsWireField{Name: common.ToPascalCase(field.Name), GoType: field.Type, Writer: writerFor(field.Type), Reader: readerFor(field.Type)} - if strings.Contains(field.Spec, "*") { - parts := strings.Split(field.Spec, "*") - if len(parts) == 2 { - data.HasArray = true - wf.IsArray = true - wf.CountName = common.ToPascalCase(parts[1]) - copy := wf - data.ArrayInfo = © - } - } - 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 .GoType "u64") (eq .GoType "i64")}}bigint{{else}}number{{end}}{{if .IsArray}}[]{{end}}; -{{end}} - constructor(init: Partial<{{.Device}}{{.ClassName}}> = {}) { - Object.assign(this, init); - } - write(writer: BinaryWriter): void { -{{if .HasArray}} // Write fixed-size fields -{{range .Fields}}{{if not .IsArray}} writer.{{.Writer}}(this.{{.Name}} as any); -{{end}}{{end}} // Write variable-length array - for (let i = 0; i < (this.{{.ArrayInfo.CountName}} as number); i++) { - writer.{{.ArrayInfo.Writer}}((this.{{.ArrayInfo.Name}} as any[])[i]); - } -{{else}}{{range .Fields}} writer.{{.Writer}}(this.{{.Name}} as any); -{{end}}{{end}} } - static read(reader: BinaryReader): {{.Device}}{{.ClassName}} { -{{if .HasArray}} // Read fixed-size fields -{{range .Fields}}{{if not .IsArray}} const {{.Name | toCamelTS}} = reader.{{.Reader}}(); -{{end}}{{end}} const {{.ArrayInfo.Name | toCamelTS}}: any[] = new Array(Number({{.ArrayInfo.CountName | toCamelTS}})); - for (let i = 0; i < Number({{.ArrayInfo.CountName | toCamelTS}}); i++) { - {{.ArrayInfo.Name | toCamelTS}}[i] = reader.{{.ArrayInfo.Reader}}(); - } - return new {{.Device}}{{.ClassName}}({ -{{range .Fields}}{{if not .IsArray}} {{.Name}}: {{.Name | toCamelTS}}, -{{end}}{{end}} {{.ArrayInfo.Name}}: {{.ArrayInfo.Name | toCamelTS}}, - }); -{{else}} return new {{.Device}}{{.ClassName}}({ -{{range .Fields}} {{.Name}}: reader.{{.Reader}}(), -{{end}} }); -{{end}} } -} -` +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" +) + +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 + GoType string + Writer string + Reader string + IsArray bool + CountName string +} + +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() + data := struct { + Device string + ClassName string + Fields []tsWireField + HasArray bool + ArrayInfo *tsWireField + }{Device: device, ClassName: className} + for _, field := range tag.Fields { + wf := tsWireField{Name: common.ToPascalCase(field.Name), GoType: field.Type, Writer: writerFor(field.Type), Reader: readerFor(field.Type)} + if strings.Contains(field.Spec, "*") { + parts := strings.Split(field.Spec, "*") + if len(parts) == 2 { + data.HasArray = true + wf.IsArray = true + wf.CountName = common.ToPascalCase(parts[1]) + copy := wf + data.ArrayInfo = © + } + } + 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 .GoType "u64") (eq .GoType "i64")}}bigint{{else}}number{{end}}{{if .IsArray}}[]{{end}}; +{{end}} + constructor(init: Partial<{{.Device}}{{.ClassName}}> = {}) { + Object.assign(this, init); + } + write(writer: BinaryWriter): void { +{{if .HasArray}} // Write fixed-size fields +{{range .Fields}}{{if not .IsArray}} writer.{{.Writer}}(this.{{.Name}} as any); +{{end}}{{end}} // Write variable-length array + for (let i = 0; i < (this.{{.ArrayInfo.CountName}} as number); i++) { + writer.{{.ArrayInfo.Writer}}((this.{{.ArrayInfo.Name}} as any[])[i]); + } +{{else}}{{range .Fields}} writer.{{.Writer}}(this.{{.Name}} as any); +{{end}}{{end}} } + static read(reader: BinaryReader): {{.Device}}{{.ClassName}} { +{{if .HasArray}} // Read fixed-size fields +{{range .Fields}}{{if not .IsArray}} const {{.Name | toCamelTS}} = reader.{{.Reader}}(); +{{end}}{{end}} const {{.ArrayInfo.Name | toCamelTS}}: any[] = new Array(Number({{.ArrayInfo.CountName | toCamelTS}})); + for (let i = 0; i < Number({{.ArrayInfo.CountName | toCamelTS}}); i++) { + {{.ArrayInfo.Name | toCamelTS}}[i] = reader.{{.ArrayInfo.Reader}}(); + } + return new {{.Device}}{{.ClassName}}({ +{{range .Fields}}{{if not .IsArray}} {{.Name}}: {{.Name | toCamelTS}}, +{{end}}{{end}} {{.ArrayInfo.Name}}: {{.ArrayInfo.Name | toCamelTS}}, + }); +{{else}} return new {{.Device}}{{.ClassName}}({ +{{range .Fields}} {{.Name}}: reader.{{.Reader}}(), +{{end}} }); +{{end}} } +} +` diff --git a/internal/codegen/generator/typescript/device_wrapper.go b/internal/codegen/generator/typescript/device_wrapper.go index b907a025..c1aec487 100644 --- a/internal/codegen/generator/typescript/device_wrapper.go +++ b/internal/codegen/generator/typescript/device_wrapper.go @@ -1,63 +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 -} +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 index 807fd612..1ce29d0c 100644 --- a/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -1,76 +1,76 @@ -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 := 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 := 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 -} +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 := 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 := 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 index 4c9520cc..4018fda1 100644 --- a/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -1,21 +1,21 @@ -package typescript - -import ( - "github.com/Alia5/VIIPER/internal/codegen/common" -) - -func goTypeToTS(goType string) string { - base, _, _ := common.NormalizeGoType(goType) - switch base { - case "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": - return "number" - case "bool": - return "boolean" - case "string": - return "string" - default: - return common.ToPascalCase(base) - } -} - -func writeFileHeaderTS() string { return common.FileHeader("//", "TypeScript") } +package typescript + +import ( + "github.com/Alia5/VIIPER/internal/codegen/common" +) + +func goTypeToTS(goType string) string { + base, _, _ := common.NormalizeGoType(goType) + switch base { + case "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": + return "number" + case "bool": + return "boolean" + case "string": + return "string" + default: + return common.ToPascalCase(base) + } +} + +func writeFileHeaderTS() string { return common.FileHeader("//", "TypeScript") } diff --git a/internal/codegen/generator/typescript/index.go b/internal/codegen/generator/typescript/index.go index 880aff01..5ea62566 100644 --- a/internal/codegen/generator/typescript/index.go +++ b/internal/codegen/generator/typescript/index.go @@ -1,77 +1,77 @@ -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'; -` - -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() - 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 - } - - f, err := os.Create(filepath.Join(deviceDir, "index.ts")) - if err != nil { - return fmt.Errorf("write device index.ts: %w", err) - } - defer f.Close() - - tmpl := template.Must(template.New("deviceIndex").Funcs(template.FuncMap{ - "writeFileHeaderTS": writeFileHeaderTS, - }).Parse(deviceIndexTemplate)) - - data := struct { - PascalName string - HasOutput bool - }{ - PascalName: pascalName, - HasOutput: hasOutput, - } - - if err := tmpl.Execute(f, data); err != nil { - return fmt.Errorf("execute device index template: %w", err) - } - return nil -} +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'; +` + +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() + 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 + } + + f, err := os.Create(filepath.Join(deviceDir, "index.ts")) + if err != nil { + return fmt.Errorf("write device index.ts: %w", err) + } + defer f.Close() + + tmpl := template.Must(template.New("deviceIndex").Funcs(template.FuncMap{ + "writeFileHeaderTS": writeFileHeaderTS, + }).Parse(deviceIndexTemplate)) + + data := struct { + PascalName string + HasOutput bool + }{ + PascalName: pascalName, + HasOutput: hasOutput, + } + + 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 index 87650516..3027de06 100644 --- a/internal/codegen/generator/typescript/project.go +++ b/internal/codegen/generator/typescript/project.go @@ -1,86 +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() -} +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 index 9571fc94..5a53cb96 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -1,68 +1,68 @@ -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() - 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 == "slice" || strings.HasPrefix(typeStr, "[]") { - elem := strings.TrimPrefix(typeStr, "[]") - return goTypeToTS(elem) + "[]" - } - if typeKind == "struct" { - return common.ToPascalCase(typeStr) - } - return goTypeToTS(typeStr) -} +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() + 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 == "slice" || strings.HasPrefix(typeStr, "[]") { + elem := strings.TrimPrefix(typeStr, "[]") + return goTypeToTS(elem) + "[]" + } + if typeKind == "struct" { + return common.ToPascalCase(typeStr) + } + return goTypeToTS(typeStr) +} diff --git a/internal/codegen/meta/meta.go b/internal/codegen/meta/meta.go index b98365fa..8e11ac94 100644 --- a/internal/codegen/meta/meta.go +++ b/internal/codegen/meta/meta.go @@ -1,13 +1,13 @@ -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 - WireTags *scanner.WireTags // parsed viiper:wire comments - CTypeNames map[string]string // DTO name -> C typedef name (e.g., "Device" -> "device_info") -} +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 + 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_test.go b/internal/codegen/scanner/constants_test.go index 87ff2884..d3ae865d 100644 --- a/internal/codegen/scanner/constants_test.go +++ b/internal/codegen/scanner/constants_test.go @@ -1,56 +1,56 @@ -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)) -} +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)) +} diff --git a/internal/codegen/scanner/dtos.go b/internal/codegen/scanner/dtos.go index ce20ff1a..31653b57 100644 --- a/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/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go index 7098b600..797ebe64 100644 --- a/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -1,92 +1,92 @@ -package scanner - -import ( - "encoding/json" - "testing" -) - -func TestScanDTOs(t *testing.T) { - // Scan the apitypes package - schemas, err := ScanDTOsInPackage("../../..//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, - "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 apitypes package + schemas, err := ScanDTOsInPackage("../../..//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, + "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 index d325b468..4b95e552 100644 --- a/internal/codegen/scanner/payload.go +++ b/internal/codegen/scanner/payload.go @@ -1,420 +1,420 @@ -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 && ident.Name == "api" && 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., apitypes.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] -} +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 && ident.Name == "api" && 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., apitypes.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/internal/codegen/scanner/returns.go b/internal/codegen/scanner/returns.go index 3869f46d..c7563221 100644 --- a/internal/codegen/scanner/returns.go +++ b/internal/codegen/scanner/returns.go @@ -1,124 +1,124 @@ -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 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 "" +} diff --git a/internal/codegen/scanner/routes.go b/internal/codegen/scanner/routes.go index 61dea35b..1c3a762b 100644 --- a/internal/codegen/scanner/routes.go +++ b/internal/codegen/scanner/routes.go @@ -1,174 +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"} - 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 -} +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 index 6b481434..6ec9116f 100644 --- a/internal/codegen/scanner/routes_test.go +++ b/internal/codegen/scanner/routes_test.go @@ -1,88 +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) }) - } -} +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/internal/codegen/scanner/wiretags.go b/internal/codegen/scanner/wiretags.go index d4ab620d..e4c24280 100644 --- a/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/configpaths/files.go b/internal/configpaths/files.go index c36cf8d6..0f8f1801 100644 --- a/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, "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 -} +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/log/rawlogger.go b/internal/log/rawlogger.go index b2bade4a..3d8d3f88 100644 --- a/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 index 31a88169..904ad743 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -1,7 +1,7 @@ -package registry - -import ( - _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler - _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler - _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler -) +package registry + +import ( + _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler + _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler + _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler +) diff --git a/internal/server/api/autoattach.go b/internal/server/api/autoattach.go index 9eb371b9..810a18e0 100644 --- a/internal/server/api/autoattach.go +++ b/internal/server/api/autoattach.go @@ -1,12 +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) -} +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 index 92491947..07ac884d 100644 --- a/internal/server/api/autoattach_linux.go +++ b/internal/server/api/autoattach_linux.go @@ -1,76 +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 -} +//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 index 400f04ac..4c433c20 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -1,279 +1,279 @@ -//go:build windows - -package api - -import ( - "context" - "fmt" - "log/slog" - "os/exec" - "strconv" - "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 ( - DIGCF_PRESENT = 0x00000002 - DIGCF_DEVICEINTERFACE = 0x00000010 -) - -type SP_DEVICE_INTERFACE_DATA struct { - CbSize uint32 - InterfaceClassGuid windows.GUID - Flags uint32 - Reserved uintptr -} - -type SP_DEVICE_INTERFACE_DETAIL_DATA 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 { - return attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger) - } - return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger) -} - -func attachViaIOCTL(ctx 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) - - 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, - "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 -} - -func getDeviceInterfacePath(guid *windows.GUID) (string, error) { - r0, _, e1 := syscall.SyscallN(procSetupDiGetClassDevsW.Addr(), - uintptr(unsafe.Pointer(guid)), - 0, - 0, - uintptr(DIGCF_PRESENT|DIGCF_DEVICEINTERFACE)) - - 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() { - syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) - }() - - var interfaceData SP_DEVICE_INTERFACE_DATA - 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 - syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), - uintptr(devInfo), - uintptr(unsafe.Pointer(&interfaceData)), - 0, - 0, - uintptr(unsafe.Pointer(&requiredSize)), - 0) - - detailData := make([]byte, requiredSize) - detailHeader := (*SP_DEVICE_INTERFACE_DETAIL_DATA)(unsafe.Pointer(&detailData[0])) - detailHeader.CbSize = uint32(unsafe.Sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA{})) - - r2, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), - uintptr(devInfo), - uintptr(unsafe.Pointer(&interfaceData)), - uintptr(unsafe.Pointer(detailHeader)), - uintptr(requiredSize), - 0, - 0) - - if r2 == 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("usbip-win2 driver not found or not installed") - logger.Warn("Native IOCTL auto-attach requires the usbip-win2 driver") - logger.Info("Download and install usbip-win2:") - 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 -} +//go:build windows + +package api + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strconv" + "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 ( + DIGCF_PRESENT = 0x00000002 + DIGCF_DEVICEINTERFACE = 0x00000010 +) + +type SP_DEVICE_INTERFACE_DATA struct { + CbSize uint32 + InterfaceClassGuid windows.GUID + Flags uint32 + Reserved uintptr +} + +type SP_DEVICE_INTERFACE_DETAIL_DATA 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 { + return attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger) + } + return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger) +} + +func attachViaIOCTL(ctx 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) + + 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, + "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 +} + +func getDeviceInterfacePath(guid *windows.GUID) (string, error) { + r0, _, e1 := syscall.SyscallN(procSetupDiGetClassDevsW.Addr(), + uintptr(unsafe.Pointer(guid)), + 0, + 0, + uintptr(DIGCF_PRESENT|DIGCF_DEVICEINTERFACE)) + + 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() { + syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) + }() + + var interfaceData SP_DEVICE_INTERFACE_DATA + 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 + syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + 0, + 0, + uintptr(unsafe.Pointer(&requiredSize)), + 0) + + detailData := make([]byte, requiredSize) + detailHeader := (*SP_DEVICE_INTERFACE_DETAIL_DATA)(unsafe.Pointer(&detailData[0])) + detailHeader.CbSize = uint32(unsafe.Sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA{})) + + r2, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + uintptr(devInfo), + uintptr(unsafe.Pointer(&interfaceData)), + uintptr(unsafe.Pointer(detailHeader)), + uintptr(requiredSize), + 0, + 0) + + if r2 == 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("usbip-win2 driver not found or not installed") + logger.Warn("Native IOCTL auto-attach requires the usbip-win2 driver") + logger.Info("Download and install usbip-win2:") + 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 index c1801507..025e4674 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -1,12 +1,12 @@ -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"` - ConnectionTimeout time.Duration `kong:"-"` - platformOpts `embed:""` -} +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"` + ConnectionTimeout time.Duration `kong:"-"` + platformOpts `embed:""` +} diff --git a/internal/server/api/config_unix.go b/internal/server/api/config_unix.go index 1ed36ab6..54d528ce 100644 --- a/internal/server/api/config_unix.go +++ b/internal/server/api/config_unix.go @@ -1,7 +1,7 @@ -//go:build !windows - -package api - -type platformOpts struct { - AutoAttachWindowsNative bool `kong:"-"` -} +//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 index 85cbe148..43ea9ad0 100644 --- a/internal/server/api/config_windows.go +++ b/internal/server/api/config_windows.go @@ -1,7 +1,7 @@ -//go:build windows - -package api - -type platformOpts struct { - AutoAttachWindowsNative bool `help:"Use native IOCTL instead of usbip.exe for auto-attach" default:"true" env:"VIIPER_API_AUTO_ATTACH_WINDOWS_NATIVE"` -} +//go:build windows + +package api + +type platformOpts struct { + AutoAttachWindowsNative bool `help:"Use native IOCTL instead of usbip.exe for auto-attach" default:"true" env:"VIIPER_API_AUTO_ATTACH_WINDOWS_NATIVE"` +} diff --git a/internal/server/api/device_registry.go b/internal/server/api/device_registry.go index 00432856..72c70b3c 100644 --- a/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -1,59 +1,59 @@ -package api - -import ( - "sync" - - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/usb" -) - -// 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(o *device.CreateOptions) usb.Device - // StreamHandler returns the handler function for long-lived connections. - 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 { - 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" +) + +// 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(o *device.CreateOptions) usb.Device + // StreamHandler returns the handler function for long-lived connections. + 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 { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 'a' - 'A' + } + } + return string(b) +} diff --git a/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go index c4f2afbd..ead67f36 100644 --- a/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -1,176 +1,176 @@ -package api_test - -import ( - "log/slog" - "net" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/internal/server/api" - th "github.com/Alia5/VIIPER/internal/testing" - "github.com/Alia5/VIIPER/usb" -) - -type mockDevice struct { - name string -} - -func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { - return nil -} -func (m *mockDevice) GetDescriptor() *usb.Descriptor { - return &usb.Descriptor{} -} - -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 { return &mockDevice{name: tt.expectedDevice} }, - 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(nil) - 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 { return &mockDevice{name: tt.registerName} }, - 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 ( + "log/slog" + "net" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api" + th "github.com/Alia5/VIIPER/internal/testing" + "github.com/Alia5/VIIPER/usb" +) + +type mockDevice struct { + name string +} + +func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { + return nil +} +func (m *mockDevice) GetDescriptor() *usb.Descriptor { + return &usb.Descriptor{} +} + +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 { return &mockDevice{name: tt.expectedDevice} }, + 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(nil) + 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 { return &mockDevice{name: tt.registerName} }, + 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/internal/server/api/device_stream_handler.go b/internal/server/api/device_stream_handler.go index 2570048c..6d1149ea 100644 --- a/internal/server/api/device_stream_handler.go +++ b/internal/server/api/device_stream_handler.go @@ -1,54 +1,54 @@ -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() - - 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., "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()) -} +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() + + 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., "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/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go index 42f2207d..cc5ce925 100644 --- a/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -1,134 +1,134 @@ -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/xbox360" - "github.com/Alia5/VIIPER/internal/log" - "github.com/Alia5/VIIPER/internal/server/api" - srvusb "github.com/Alia5/VIIPER/internal/server/usb" - htesting "github.com/Alia5/VIIPER/internal/testing" - th "github.com/Alia5/VIIPER/internal/testing" - 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 := xbox360.New(nil) - 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, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, - func(conn net.Conn, d *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(nil) - 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, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, - 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\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") - } -} +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/xbox360" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + htesting "github.com/Alia5/VIIPER/internal/testing" + th "github.com/Alia5/VIIPER/internal/testing" + 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 := xbox360.New(nil) + 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, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, d *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(nil) + 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, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + 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\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/errors.go b/internal/server/api/errors.go index faf79418..9b7b50a1 100644 --- a/internal/server/api/errors.go +++ b/internal/server/api/errors.go @@ -1,29 +1,29 @@ -package api - -import "github.com/Alia5/VIIPER/apitypes" - -// Factory helpers returning *apitypes.ApiError (single canonical error type). -func ErrBadRequest(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} -} -func ErrNotFound(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} -} -func ErrConflict(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} -} -func ErrInternal(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} -} - -// WrapError normalizes any error into *apitypes.ApiError. -func WrapError(err error) *apitypes.ApiError { - if err == nil { - return nil - } - if ae, ok := err.(*apitypes.ApiError); ok { - return ae - } - // Default wrap as internal error - return ErrInternal(err.Error()) -} +package api + +import "github.com/Alia5/VIIPER/apitypes" + +// Factory helpers returning *apitypes.ApiError (single canonical error type). +func ErrBadRequest(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} +} +func ErrNotFound(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} +} +func ErrConflict(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} +} +func ErrInternal(detail string) *apitypes.ApiError { + return &apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} +} + +// WrapError normalizes any error into *apitypes.ApiError. +func WrapError(err error) *apitypes.ApiError { + if err == nil { + return nil + } + if ae, ok := err.(*apitypes.ApiError); ok { + return ae + } + // Default wrap as internal error + return ErrInternal(err.Error()) +} diff --git a/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go index 1465d0f5..adc9aef5 100644 --- a/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -1,93 +1,93 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/internal/server/api" - "github.com/Alia5/VIIPER/internal/server/api/handler" - "github.com/Alia5/VIIPER/internal/server/usb" - handlerTest "github.com/Alia5/VIIPER/internal/testing" - "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: "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 := apiclient.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) - } - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "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: "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 := apiclient.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_test.go b/internal/server/api/handler/bus_device_add_test.go index 00e0cf3c..8378c343 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -1,181 +1,181 @@ -package handler_test - -import ( - "log/slog" - "net" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/device/xbox360" - "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" - th "github.com/Alia5/VIIPER/internal/testing" - pusb "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/virtualbus" -) - -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: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80001, "devId": "1", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, - }, - { - 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) { - 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) { - 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) { - 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(nil)); 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", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - 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)) - }) - 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.JSONEq(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) { - 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() - - testReg := th.CreateMockRegistration(t, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, - func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { return nil }, - ) - - api.RegisterDevice("xbox360", testReg) - - c := apiclient.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) -} +package handler_test + +import ( + "log/slog" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "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" + th "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/virtualbus" +) + +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: `{"type": "xbox360"}`, + expectedResponse: `{"busId":80001, "devId": "1", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + }, + { + 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) { + 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) { + 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) { + 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(nil)); 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", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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)) + }) + 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.JSONEq(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) { + 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() + + testReg := th.CreateMockRegistration(t, "xbox360", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { return nil }, + ) + + api.RegisterDevice("xbox360", testReg) + + c := apiclient.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_test.go b/internal/server/api/handler/bus_device_remove_test.go index 7b4499b6..df6a4cff 100644 --- a/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -1,94 +1,94 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Alia5/VIIPER/apiclient" - "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/internal/server/usb" - handlerTest "github.com/Alia5/VIIPER/internal/testing" - "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) - } - if _, err := b.Add(xbox360.New(nil)); 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 := 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) - if tt.expectedResponse[0] == '{' { - assert.JSONEq(t, tt.expectedResponse, line) - } else { - assert.Equal(t, tt.expectedResponse, line) - } - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "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) + } + if _, err := b.Add(xbox360.New(nil)); 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 := 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) + 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_test.go b/internal/server/api/handler/bus_devices_list_test.go index 7ae3fb30..9b2449b4 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -1,109 +1,109 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Alia5/VIIPER/apiclient" - "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/internal/server/usb" - handlerTest "github.com/Alia5/VIIPER/internal/testing" - "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) - } - if _, err := b.Add(xbox360.New(nil)); 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(nil)); err != nil { - t.Fatalf("add device 1 failed: %v", err) - } - if _, err := b.Add(xbox360.New(nil)); 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: `{"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 := 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) - if tt.expectedResponse[0] == '{' { - assert.JSONEq(t, tt.expectedResponse, line) - } else { - assert.Equal(t, tt.expectedResponse, line) - } - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "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) + } + if _, err := b.Add(xbox360.New(nil)); 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(nil)); err != nil { + t.Fatalf("add device 1 failed: %v", err) + } + if _, err := b.Add(xbox360.New(nil)); 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: `{"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 := 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) + 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_list_test.go b/internal/server/api/handler/bus_list_test.go index 04f3741f..c3051d3c 100644 --- a/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" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/internal/server/api" - "github.com/Alia5/VIIPER/internal/server/api/handler" - "github.com/Alia5/VIIPER/internal/server/usb" - handlerTest "github.com/Alia5/VIIPER/internal/testing" - "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 := 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" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "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 := 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) + }) + } +} diff --git a/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go index 37d9a8c0..d1b5032e 100644 --- a/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -1,114 +1,114 @@ -package handler_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Alia5/VIIPER/apiclient" - "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/internal/server/usb" - handlerTest "github.com/Alia5/VIIPER/internal/testing" - "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) - } - if _, err := b.Add(xbox360.New(nil)); err != nil { - t.Fatalf("add device 1 failed: %v", err) - } - if _, err := b.Add(xbox360.New(nil)); 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 := apiclient.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") - } - }) - } -} +package handler_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Alia5/VIIPER/apiclient" + "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/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" + "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) + } + if _, err := b.Add(xbox360.New(nil)); err != nil { + t.Fatalf("add device 1 failed: %v", err) + } + if _, err := b.Add(xbox360.New(nil)); 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 := apiclient.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/server_test.go b/internal/server/api/server_test.go index 4d0d2ad2..9c86a970 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -1,69 +1,69 @@ -package api_test - -import ( - "fmt" - "log/slog" - "net" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/device/xbox360" - "github.com/Alia5/VIIPER/internal/log" - "github.com/Alia5/VIIPER/internal/server/api" - srvusb "github.com/Alia5/VIIPER/internal/server/usb" - th "github.com/Alia5/VIIPER/internal/testing" - pusb "github.com/Alia5/VIIPER/usb" - "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() - - bus, err := virtualbus.NewWithBusId(70002) - require.NoError(t, err) - require.NoError(t, usbSrv.AddBus(bus)) - dev := xbox360.New(nil) - _, 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", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, - func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, - ) - - api.RegisterDevice("xbox360", 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() -} +package api_test + +import ( + "fmt" + "log/slog" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/log" + "github.com/Alia5/VIIPER/internal/server/api" + srvusb "github.com/Alia5/VIIPER/internal/server/usb" + th "github.com/Alia5/VIIPER/internal/testing" + pusb "github.com/Alia5/VIIPER/usb" + "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() + + bus, err := virtualbus.NewWithBusId(70002) + require.NoError(t, err) + require.NoError(t, usbSrv.AddBus(bus)) + dev := xbox360.New(nil) + _, 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", + func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, + ) + + api.RegisterDevice("xbox360", 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() +} diff --git a/internal/server/proxy/parser.go b/internal/server/proxy/parser.go index dda9cb22..82c9cadc 100644 --- a/internal/server/proxy/parser.go +++ b/internal/server/proxy/parser.go @@ -1,339 +1,339 @@ -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" -} +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/internal/server/proxy/server.go b/internal/server/proxy/server.go index 52464dc8..5311e1b4 100644 --- a/internal/server/proxy/server.go +++ b/internal/server/proxy/server.go @@ -1,189 +1,189 @@ -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() - - 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() + + 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") +} diff --git a/internal/server/usb/config.go b/internal/server/usb/config.go index 83432a6f..8b634da6 100644 --- a/internal/server/usb/config.go +++ b/internal/server/usb/config.go @@ -1,10 +1,10 @@ -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:"-"` -} +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:"-"` +} diff --git a/internal/testing/mocks.go b/internal/testing/mocks.go index d74127c5..44608f09 100644 --- a/internal/testing/mocks.go +++ b/internal/testing/mocks.go @@ -1,37 +1,37 @@ -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 -} - -func (m *mockRegistration) CreateDevice(o *device.CreateOptions) usb.Device { - return m.createFunc(o) -} - -func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { - return m.handlerFunc -} - -func CreateMockRegistration( - t *testing.T, - name string, - cf func(o *device.CreateOptions) usb.Device, - h api.StreamHandlerFunc, -) api.DeviceRegistration { - return &mockRegistration{ - deviceName: name, - handlerFunc: h, - createFunc: cf, - } -} +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 +} + +func (m *mockRegistration) CreateDevice(o *device.CreateOptions) usb.Device { + return m.createFunc(o) +} + +func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { + return m.handlerFunc +} + +func CreateMockRegistration( + t *testing.T, + name string, + cf func(o *device.CreateOptions) usb.Device, + h api.StreamHandlerFunc, +) api.DeviceRegistration { + return &mockRegistration{ + deviceName: name, + handlerFunc: h, + createFunc: cf, + } +} diff --git a/testing/e2e/bench_test.go b/testing/e2e/bench_test.go index 0cba95cc..062997ed 100644 --- a/testing/e2e/bench_test.go +++ b/testing/e2e/bench_test.go @@ -1,246 +1,246 @@ -package e2e_bench_test - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" - "testing" - "time" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/apitypes" - "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/internal/registry" // Register all device handlers - - "github.com/Zyko0/go-sdl3/bin/binsdl" - "github.com/Zyko0/go-sdl3/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) - } - benches := []bench{ - { - name: "1 Go-Client-Write", - 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", - 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", - 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", - 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() - } - }, - }, - } - - b.SetParallelism(1) - - defer binsdl.Load().Unload() - defer sdl.Quit() - sdl.Init(sdl.INIT_GAMEPAD) - - sdl.UpdateGamepads() - existingGamepads, _ := sdl.GetGamepads() - existingGamepadSet := make(map[sdl.JoystickID]bool) - for _, id := range existingGamepads { - existingGamepadSet[id] = true - } - - s := cmd.Server{ - UsbServerConfig: usb.ServerConfig{ - Addr: ":3241", - BusCleanupTimeout: 1 * time.Second, - }, - ApiServerConfig: api.ServerConfig{ - Addr: ":3242", - AutoAttachLocalClient: true, - DeviceHandlerConnectTimeout: time.Second * 5, - }, - 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) - } - }() - c := apiclient.New("localhost:3242") - var busResp *apitypes.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() - - var gamepad *sdl.Gamepad - for range 10 { - sdl.UpdateGamepads() - gIDs, _ := sdl.GetGamepads() - for _, id := range gIDs { - if !existingGamepadSet[id] { - gamepad, err = id.OpenGamepad() - 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.Button(sdl.GAMEPAD_BUTTON_SOUTH) - if pressed != prevPadPressed { - padChann <- pressed - prevPadPressed = pressed - } - } - }() - - for _, bench := range benches { - 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 - } - } - } -} +package e2e_bench_test + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/apitypes" + "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/internal/registry" // Register all device handlers + + "github.com/Zyko0/go-sdl3/bin/binsdl" + "github.com/Zyko0/go-sdl3/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) + } + benches := []bench{ + { + name: "1 Go-Client-Write", + 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", + 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", + 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", + 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() + } + }, + }, + } + + b.SetParallelism(1) + + defer binsdl.Load().Unload() + defer sdl.Quit() + sdl.Init(sdl.INIT_GAMEPAD) + + sdl.UpdateGamepads() + existingGamepads, _ := sdl.GetGamepads() + existingGamepadSet := make(map[sdl.JoystickID]bool) + for _, id := range existingGamepads { + existingGamepadSet[id] = true + } + + s := cmd.Server{ + UsbServerConfig: usb.ServerConfig{ + Addr: ":3241", + BusCleanupTimeout: 1 * time.Second, + }, + ApiServerConfig: api.ServerConfig{ + Addr: ":3242", + AutoAttachLocalClient: true, + DeviceHandlerConnectTimeout: time.Second * 5, + }, + 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) + } + }() + c := apiclient.New("localhost:3242") + var busResp *apitypes.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() + + var gamepad *sdl.Gamepad + for range 10 { + sdl.UpdateGamepads() + gIDs, _ := sdl.GetGamepads() + for _, id := range gIDs { + if !existingGamepadSet[id] { + gamepad, err = id.OpenGamepad() + 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.Button(sdl.GAMEPAD_BUTTON_SOUTH) + if pressed != prevPadPressed { + padChann <- pressed + prevPadPressed = pressed + } + } + }() + + for _, bench := range benches { + 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/scripts/lat_bench.go b/testing/e2e/scripts/lat_bench.go index 457f527e..ff469884 100644 --- a/testing/e2e/scripts/lat_bench.go +++ b/testing/e2e/scripts/lat_bench.go @@ -1,359 +1,359 @@ -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 -// -// 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).") -) - -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) - } - 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 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() -} +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 +// +// 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).") +) + +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) + } + 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 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() +} From c29a49569a00200472934bb039ce86b9feffdea1 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 18 Dec 2025 17:39:34 +0100 Subject: [PATCH 074/298] enforce gofmt --- .github/workflows/build_base.yml | 280 ++++++++++++++++--------------- 1 file changed, 145 insertions(+), 135 deletions(-) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index e3cc1314..9d6665e8 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -1,135 +1,145 @@ -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@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - cache: true - cache-dependency-path: | - go.sum - - - name: Show Go version - run: go version - - - name: Run tests - 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" } - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - cache: true - cache-dependency-path: | - go.sum - - - name: Generate Windows version info - if: matrix.target.goos == 'windows' - run: | - VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - git fetch --tags --force || true - if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then - VERSION="v0.0.0-dev" - fi - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest - - VER_STRIPPED=${VERSION#v} - IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" - MAJOR=${PARTS[0]:-0} - MINOR=${PARTS[1]:-0} - PATCH=${PARTS[2]:-0} - BUILD=0 - if [ ${#PARTS[@]} -gt 3 ]; then - BUILD_STR="${PARTS[3]}" - if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then - BUILD=$BUILD_STR - fi - fi - if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then - MAJOR=0; MINOR=0; PATCH=0; BUILD=0; - fi - - jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ - --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ - '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | - .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.FileVersion.Build = ($build | tonumber) | - .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | - .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | - .StringFileInfo.FileVersion = $verStr | - .StringFileInfo.ProductVersion = $prodVer' \ - versioninfo.json > versioninfo.tmp.json - - echo "Generating resource.syso for ${{ matrix.target.goarch }}"; - if [ "${{ matrix.target.goarch }}" = "amd64" ]; then - goversioninfo -64 -o cmd/viiper/resource.syso versioninfo.tmp.json - elif [ "${{ matrix.target.goarch }}" = "arm64" ]; then - # arm64 requires both -arm and -64 flags per goversioninfo flag semantics - goversioninfo -arm -64 -o cmd/viiper/resource.syso versioninfo.tmp.json - else - goversioninfo -o cmd/viiper/resource.syso versioninfo.tmp.json - fi - - - name: Build - env: - GOOS: ${{ matrix.target.goos }} - GOARCH: ${{ matrix.target.goarch }} - CGO_ENABLED: 0 - run: | - VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - mkdir -p dist - go build -trimpath -ldflags "-s -w -X github.com/Alia5/VIIPER/internal/codegen/common.Version=${VERSION}" -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@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache: true + cache-dependency-path: | + go.sum + + - name: Show Go version + run: go version + + - name: Verify gofmt + shell: bash + run: | + set -euo pipefail + files="$(gofmt -l .)" + if [ -n "$files" ]; then + echo "format check failed for:" + echo "$files" + exit 1 + fi + + - name: Run tests + 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" } + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache: true + cache-dependency-path: | + go.sum + + - name: Generate Windows version info + if: matrix.target.goos == 'windows' + run: | + VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") + git fetch --tags --force || true + if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then + VERSION="v0.0.0-dev" + fi + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + + VER_STRIPPED=${VERSION#v} + IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" + MAJOR=${PARTS[0]:-0} + MINOR=${PARTS[1]:-0} + PATCH=${PARTS[2]:-0} + BUILD=0 + if [ ${#PARTS[@]} -gt 3 ]; then + BUILD_STR="${PARTS[3]}" + if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then + BUILD=$BUILD_STR + fi + fi + if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then + MAJOR=0; MINOR=0; PATCH=0; BUILD=0; + fi + + jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ + --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ + '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | + .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.FileVersion.Build = ($build | tonumber) | + .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | + .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | + .StringFileInfo.FileVersion = $verStr | + .StringFileInfo.ProductVersion = $prodVer' \ + versioninfo.json > versioninfo.tmp.json + + echo "Generating resource.syso for ${{ matrix.target.goarch }}"; + if [ "${{ matrix.target.goarch }}" = "amd64" ]; then + goversioninfo -64 -o cmd/viiper/resource.syso versioninfo.tmp.json + elif [ "${{ matrix.target.goarch }}" = "arm64" ]; then + # arm64 requires both -arm and -64 flags per goversioninfo flag semantics + goversioninfo -arm -64 -o cmd/viiper/resource.syso versioninfo.tmp.json + else + goversioninfo -o cmd/viiper/resource.syso versioninfo.tmp.json + fi + + - name: Build + env: + GOOS: ${{ matrix.target.goos }} + GOARCH: ${{ matrix.target.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") + mkdir -p dist + go build -trimpath -ldflags "-s -w -X github.com/Alia5/VIIPER/internal/codegen/common.Version=${VERSION}" -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 From 92c11f7a27f1552e7de9f2cfff179bfd30c41a83 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 19 Dec 2025 21:45:36 +0100 Subject: [PATCH 075/298] device-states: use ptr receivers with consistent naming --- device/keyboard/inputstate.go | 38 ++++++++++++++++------------------- device/mouse/inputstate.go | 20 +++++++++--------- device/xbox360/inputstate.go | 16 +++++++-------- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/device/keyboard/inputstate.go b/device/keyboard/inputstate.go index add99273..aed801c5 100644 --- a/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -24,16 +24,16 @@ type LEDState struct { // 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 { +func (ls *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 + ls.NumLock = b&LEDNumLock != 0 + ls.CapsLock = b&LEDCapsLock != 0 + ls.ScrollLock = b&LEDScrollLock != 0 + ls.Compose = b&LEDCompose != 0 + ls.Kana = b&LEDKana != 0 return nil } @@ -44,11 +44,11 @@ func (st *LEDState) UnmarshalBinary(data []byte) error { // Byte 0: Modifiers (8 bits) // Byte 1: Reserved (0x00) // Bytes 2-33: Key bitmap (256 bits, 32 bytes) -func (st InputState) BuildReport() []byte { +func (kb *InputState) BuildReport() []byte { b := make([]byte, 34) - b[0] = st.Modifiers + b[0] = kb.Modifiers b[1] = 0x00 // Reserved - copy(b[2:34], st.KeyBitmap[:]) + copy(b[2:34], kb.KeyBitmap[:]) return b } @@ -59,20 +59,18 @@ func (st InputState) BuildReport() []byte { // 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 +func (kb *InputState) MarshalBinary() ([]byte, error) { 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(st.DY) - b[4] = byte(st.DY >> 8) - b[5] = byte(st.Wheel) - b[6] = byte(st.Wheel >> 8) - b[7] = byte(st.Pan) - b[8] = byte(st.Pan >> 8) + b[0] = m.Buttons & 0x1F // 5 buttons, mask upper bits + 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 } diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 8fee03be..85e0aad9 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -32,17 +32,17 @@ type InputState struct { // 10-11: RX (little-endian int16) // 12-13: RY (little-endian int16) // 14-19: Reserved / zero -func (st InputState) BuildReport() []byte { +func (x *InputState) BuildReport() []byte { b := make([]byte, 20) b[0] = 0x00 b[1] = 0x14 - binary.LittleEndian.PutUint16(b[2:4], uint16(st.Buttons&0xffff)) - b[4] = st.LT - b[5] = st.RT - binary.LittleEndian.PutUint16(b[6:8], uint16(st.LX)) - binary.LittleEndian.PutUint16(b[8:10], uint16(st.LY)) - binary.LittleEndian.PutUint16(b[10:12], uint16(st.RX)) - binary.LittleEndian.PutUint16(b[12:14], uint16(st.RY)) + 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)) // Remaining bytes (14-19) are left zeroed return b } From 33d32194a5256a0e51746b48ff7d3a84a246052c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 19 Dec 2025 22:24:13 +0100 Subject: [PATCH 076/298] Add "ping" endpoint Changelog(feat) --- apiclient/client.go | 15 +++++++++++ apitypes/structs.go | 5 ++++ docs/api/overview.md | 6 +++-- docs/getting-started/installation.md | 6 ++--- internal/cmd/server.go | 1 + internal/server/api/handler/ping.go | 33 ++++++++++++++++++++++++ internal/server/api/handler/ping_test.go | 32 +++++++++++++++++++++++ 7 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 internal/server/api/handler/ping.go create mode 100644 internal/server/api/handler/ping_test.go diff --git a/apiclient/client.go b/apiclient/client.go index d55905a3..625575a0 100644 --- a/apiclient/client.go +++ b/apiclient/client.go @@ -28,6 +28,21 @@ func NewWithConfig(addr string, cfg *Config) *Client { // 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() (*apitypes.PingResponse, error) { + return c.PingCtx(context.Background()) +} + +// PingCtx is the context-aware version of Ping. +func (c *Client) PingCtx(ctx context.Context) (*apitypes.PingResponse, error) { + const path = "ping" + raw, err := c.transport.DoCtx(ctx, path, nil, nil) + if err != nil { + return nil, err + } + return parse[apitypes.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) (*apitypes.BusCreateResponse, error) { diff --git a/apitypes/structs.go b/apitypes/structs.go index 65ded5bd..639b6981 100644 --- a/apitypes/structs.go +++ b/apitypes/structs.go @@ -17,6 +17,11 @@ type ApiError struct { Detail string `json:"detail"` } +type PingResponse struct { + Server string `json:"server"` + Version string `json:"version"` +} + func (e *ApiError) Error() string { if e == nil { return "" diff --git a/docs/api/overview.md b/docs/api/overview.md index e3fe5f9a..ec5fca14 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -31,9 +31,11 @@ Tip: You can experiment with `nc`/`ncat` or PowerShell’s `tcpclient` to send l !!! 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 +## Endpoints -The server registers the following commands and streams: +- `ping` + - Simple identity and version check. + - Response: `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` - `bus/list` - List all virtual bus IDs. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 3a37d112..470536bc 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -79,7 +79,8 @@ This makes VIIPER ideal for embedding in applications or distributing as part of !!! 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 + - 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 @@ -144,14 +145,13 @@ The install scripts are version-aware based on where you download them from: - **Latest _pre_-release (development snapshot):** `curl -fsSL https://alia5.github.io/VIIPER/main/install.sh | sh` - ## System Startup Configuration The `install` and `uninstall` commands configure automatic startup for the VIIPER binary. !!! info "What These Commands Do" These commands **do not copy or move** the VIIPER binary. They configure your system to automatically run the binary from its **current location** when the system boots. - + Make sure the binary is in a permanent location before running `viiper install`! ### `viiper install` diff --git a/internal/cmd/server.go b/internal/cmd/server.go index a838c2dd..862e2d10 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -54,6 +54,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger 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)) diff --git a/internal/server/api/handler/ping.go b/internal/server/api/handler/ping.go new file mode 100644 index 00000000..356d3d15 --- /dev/null +++ b/internal/server/api/handler/ping.go @@ -0,0 +1,33 @@ +package handler + +import ( + "encoding/json" + "log/slog" + + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/codegen/common" + "github.com/Alia5/VIIPER/internal/server/api" +) + +// 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 := apitypes.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..af993b1c --- /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" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/internal/server/usb" + handlerTest "github.com/Alia5/VIIPER/internal/testing" +) + +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 := apiclient.NewTransport(addr) + line, err := c.Do("ping", nil, nil) + assert.NoError(t, err) + + var out apitypes.PingResponse + err = json.Unmarshal([]byte(line), &out) + assert.NoError(t, err) + assert.Equal(t, "VIIPER", out.Server) + assert.NotEmpty(t, out.Version) +} From aa25cda6712e119f883b6d03eee049097e4c56f3 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 19 Dec 2025 22:36:12 +0100 Subject: [PATCH 077/298] go-client: relax strict JSON parsing --- apiclient/client.go | 1 - apiclient/client_test.go | 8 -------- apiclient/stream_test.go | 8 -------- 3 files changed, 17 deletions(-) diff --git a/apiclient/client.go b/apiclient/client.go index 625575a0..27046895 100644 --- a/apiclient/client.go +++ b/apiclient/client.go @@ -162,7 +162,6 @@ func parse[T any](data string) (*T, error) { } var out T dec := json.NewDecoder(bytes.NewReader([]byte(data))) - dec.DisallowUnknownFields() if err := dec.Decode(&out); err != nil { return nil, fmt.Errorf("decode: %w", err) } diff --git a/apiclient/client_test.go b/apiclient/client_test.go index 5f165677..8693ed8b 100644 --- a/apiclient/client_test.go +++ b/apiclient/client_test.go @@ -115,11 +115,3 @@ func TestContextCancellation(t *testing.T) { _, err := c.BusListCtx(ctx) assert.Error(t, err) } - -func TestStrictJSONDecode(t *testing.T) { - responses := map[string]string{} - responses["bus/list"] = `{"buses":[1,2,3],"extra":true}` // extra field should cause decode error - c := testClient(responses, nil) - _, err := c.BusList() - assert.Error(t, err) -} diff --git a/apiclient/stream_test.go b/apiclient/stream_test.go index b44e9050..c64f046e 100644 --- a/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -65,14 +65,6 @@ func TestAddDeviceAndConnect(t *testing.T) { }, wantErrSubstr: "bus 42 not found", }, - { - name: "strict JSON decode error (extra field)", - setup: func(responses map[string]string) error { - responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test","extra":true}` - return nil - }, - wantErrSubstr: "decode:", - }, } for _, tt := range tests { From 75e9d776fc4839f6eaa9cb19bcd62376d2f25de4 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 19 Dec 2025 22:44:54 +0100 Subject: [PATCH 078/298] install-scripts: detect if is update --- scripts/install.ps1 | 42 +++++++++++++++++++++++++++++++++++++----- scripts/install.sh | 22 +++++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 538196e0..73325954 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -37,17 +37,49 @@ try { $installDir = Join-Path $env:LOCALAPPDATA "VIIPER" $installPath = Join-Path $installDir "viiper.exe" + $isUpdate = Test-Path $installPath Write-Host "Installing binary to $installPath..." New-Item -ItemType Directory -Path $installDir -Force | Out-Null + + # On update, preserve the user's previous autostart preference. + # Autostart is controlled by `viiper install`/`viiper uninstall` (registry Run key). + # If we ran `install` again here we'd re-enable autostart for users who disabled it. + # So: if the binary already exists, just replace it. + if ($isUpdate) { + Write-Host "Existing VIIPER installation detected (update). Preserving startup/autostart configuration..." + # If the old binary is running, Windows will usually refuse to overwrite it. + # Stop any processes whose ExecutablePath matches the install path. + $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 { } + } + } + } + Copy-Item $tempViiper $installPath -Force - - Write-Host "Configuring system startup..." - & $installPath install + + if (-not $isUpdate) { + Write-Host "Configuring system startup..." + & $installPath install + } Write-Host "VIIPER installed successfully!" -ForegroundColor Green Write-Host "Binary installed to: $installPath" - Write-Host "VIIPER server is now running and will start automatically on boot." -} finally { + if ($isUpdate) { + Write-Host "Update complete. Startup/autostart configuration was left unchanged." + Write-Host "If VIIPER was running, restart it to use the updated binary." + } + else { + Write-Host "VIIPER server is now running and will start automatically on boot." + } +} +finally { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } diff --git a/scripts/install.sh b/scripts/install.sh index 2cb17efa..d98f150b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -48,14 +48,30 @@ chmod +x viiper INSTALL_DIR="/usr/local/bin" INSTALL_PATH="$INSTALL_DIR/viiper" +IS_UPDATE=0 +if [ -f "$INSTALL_PATH" ]; then + IS_UPDATE=1 +fi + echo "Installing binary to $INSTALL_PATH..." sudo mkdir -p "$INSTALL_DIR" sudo cp viiper "$INSTALL_PATH" sudo chmod +x "$INSTALL_PATH" -echo "Configuring system startup..." -sudo "$INSTALL_PATH" install +if [ "$IS_UPDATE" -eq 1 ]; then + echo "Existing VIIPER installation detected (update). Preserving startup/autostart configuration..." + # On update, do NOT run `viiper install` (it would enable/restart the systemd service). + # We only replace the binary so the previous enable/disable choice remains intact. +else + echo "Configuring system startup..." + sudo "$INSTALL_PATH" install +fi echo "VIIPER installed successfully!" echo "Binary installed to: $INSTALL_PATH" -echo "VIIPER server is now running and will start automatically on boot." +if [ "$IS_UPDATE" -eq 1 ]; then + echo "Update complete. Startup/autostart configuration was left unchanged." + echo "If VIIPER is running, restart it to use the updated binary." +else + echo "VIIPER server is now running and will start automatically on boot." +fi From 2d27c29c9b76330863a431912f2f336b03feed20 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 19 Dec 2025 22:52:40 +0100 Subject: [PATCH 079/298] Update README / docs --- README.md | 16 ++++++++-------- docs/index.md | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9c5e1c5e..67fda73c 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ **Virtual** **I**nput over **IP** **E**mulato**R** -VIIPER creates virtual USB input devices using USBIP via a simple API. -These virtual devices appear as real hardware, indistinguishable to the operating system and applications, allowing you to emulate controllers, keyboards, and other input devices as you see fit. +VIIPER lets developers create virtual USB input devices (like game controllers, keyboards, and mice) that can be controlled programmatically (even over a network!) (using USBIP under the hood). +These virtual devices are indistinguishable from real hardware to the operating system and applications, enabling seamless integration for testing, automation, and remote control scenarios. - VIIPER abstracts away all USB / USBIP details. - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. -- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. +- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. VIIPER _currently_ comes in a single flavor: @@ -37,20 +37,20 @@ For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -### ✨ Features +### ✨🛣️ Features / Roadmap - ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation (virtual device); see [Devices › Xbox 360 Controller](docs/devices/xbox360.md) + - ✅ Xbox 360 controller emulation; 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) + - 🔜 Xbox One / Series(?) controller emulation + - 🔜 PS4 controller emulation - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) -- ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) -- ✅ Cross-platform: works on Linux and Windows +- ✅ Cross-platform: works on Linux and Windows, **0** dependencies portable binary - ✅ Flexible logging (including raw USB packet logs) -- ✅ API server for device/bus management and controlling virtual devices programmatically - ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) MIT Licensed - 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. diff --git a/docs/index.md b/docs/index.md index fcf5598f..b6bddd7c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,9 @@
-# VIIPER Documentation +# VIIPER 🐍 -VIIPER is a tool to create virtual input devices using USBIP. +**Virtual** **I**nput over **IP** **E**mulato**R** ## Quick Links @@ -14,12 +14,12 @@ VIIPER is a tool to create virtual input devices using USBIP. ## 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. +VIIPER lets developers create virtual USB input devices (like game controllers, keyboards, and mice) that can be controlled programmatically (even over a network!) (using USBIP under the hood). +These virtual devices are indistinguishable from real hardware to the operating system and applications, enabling seamless integration for testing, automation, and remote control scenarios. - VIIPER abstracts away all USB / USBIP details. - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. -- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER runs without additional dependencies or system-wide installation. +- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. VIIPER _currently_ comes in a single flavor: @@ -30,21 +30,21 @@ For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -### ✨ Features +### ✨🛣️ Features / Roadmap - ✅ 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) - - 🔜 ??? + - ✅ Xbox 360 controller emulation; 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) + - 🔜 Xbox One / Series(?) controller emulation + - 🔜 PS4 controller emulation + - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) -- ✅ USBIP server mode: expose virtual devices to remote clients - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) -- ✅ Cross-platform: works on Linux and Windows +- ✅ Cross-platform: works on Linux and Windows, **0** dependencies portable binary - ✅ Flexible logging (including raw USB packet logs) -- ✅ API server for device/bus management and controlling virtual devices programmatically -- ✅ Multiple client libraries for easy integration; see [Client Libraries](api/overview.md) +- ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) MIT Licensed - 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. From 2e10932b880a150cb308f8812e5663ac4b2ae589 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 20 Dec 2025 04:05:12 +0100 Subject: [PATCH 080/298] Fix excessive CPU usage Changelog(fix) --- internal/server/usb/server.go | 112 ++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 489c53b7..9e01c252 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -1,6 +1,7 @@ package usb import ( + "bufio" "bytes" "encoding/binary" "errors" @@ -20,6 +21,101 @@ import ( "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 + writeBatcherFlushEvery = 1 * time.Millisecond + 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 @@ -450,6 +546,9 @@ func (lc *logConn) Write(p []byte) (int, error) { func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { _ = conn.SetDeadline(time.Time{}) + bw := newBatchingWriter(conn, writeBatcherBufferSize, writeBatcherFlushEvery, writeBatcherFlushAtBytes) + defer func() { _ = bw.Close() }() + var owningBus *virtualbus.VirtualBus for _, b := range s.busses { devices := b.Devices() @@ -522,7 +621,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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) + _ = ret.Write(bw) continue } if cmd != usbip.CmdSubmitCode { @@ -551,15 +650,18 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { ErrorCount: 0, } var out bytes.Buffer + out.Grow(retSubmitHeaderSize) 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 { + if _, err := bw.Write(out.Bytes()); err != nil { return fmt.Errorf("write RET_SUBMIT: %w", err) } + if len(respData) > 0 { + if _, err := bw.Write(respData); err != nil { + return fmt.Errorf("write RET_SUBMIT payload: %w", err) + } + } _ = xferFlags _ = devid } From 6d448019c0ae554bed288d2bf9a38f73d41d811e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 20 Dec 2025 04:35:28 +0100 Subject: [PATCH 081/298] Mouse/Keyboard: decrease endpoint interval --- device/keyboard/device.go | 4 ++-- device/mouse/device.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 8348820c..63781b67 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -190,13 +190,13 @@ var defaultDescriptor = usb.Descriptor{ BEndpointAddress: 0x81, BMAttributes: 0x03, // Interrupt WMaxPacketSize: 0x0040, - BInterval: 0x0A, // 10 ms + BInterval: 0x05, // 5 ms }, { BEndpointAddress: 0x01, BMAttributes: 0x03, // Interrupt WMaxPacketSize: 0x0008, - BInterval: 0x0A, // 10 ms + BInterval: 0x05, // 5 ms }, }, }, diff --git a/device/mouse/device.go b/device/mouse/device.go index e10ee897..339719e7 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -157,7 +157,7 @@ var defaultDescriptor = usb.Descriptor{ BEndpointAddress: 0x81, BMAttributes: 0x03, // Interrupt WMaxPacketSize: 0x0010, // 16 bytes (9 needed) - BInterval: 0x0A, // 10 ms + BInterval: 0x05, // 5 ms }, }, }, From b112f585420fd553ec9a2e9884ace95e7aa8ca7e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 20 Dec 2025 04:42:15 +0100 Subject: [PATCH 082/298] Update docs/README --- README.md | 6 ++++-- docs/index.md | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 67fda73c..29c74352 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,10 @@ Useful for reverse engineering USB protocols and understanding how devices commu ### 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](docs/testing/e2e_latency.md). +End-to-end input latency for virtual devices created with VIIPER could be 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/index.md b/docs/index.md index b6bddd7c..23d053f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,4 +124,6 @@ Useful for reverse engineering USB protocols and understanding how devices commu ### 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). +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. From 207a7caa9ebda5c8faa4f353a21ff876dca85983 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 21 Dec 2025 00:50:17 +0100 Subject: [PATCH 083/298] Update docs --- docs/getting-started/installation.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 470536bc..58b1bb2e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,6 +11,21 @@ For more information, see [FAQ](https://github.com/Alia5/VIIPER#why-is-this-a-st 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. + + **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 #### Ubuntu/Debian @@ -29,10 +44,6 @@ 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). - ### Linux Kernel Module Setup !!! info "USBIP Client Requirement" From 809b9ce76d0b407caf4af6f5f1c3e5cd3f1867c3 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 21 Dec 2025 16:05:12 +0100 Subject: [PATCH 084/298] Fix install scripts and docs Changelog(fix) --- docs/getting-started/installation.md | 7 +++---- internal/cmd/install_linux.go | 2 +- scripts/install.sh | 5 ++++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 58b1bb2e..f8149610 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -25,7 +25,6 @@ You must have a USBIP-Client implementation available on your system to use VIIP [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 @@ -123,7 +122,7 @@ The following scripts will download a VIIPER release, install it to a system loc **Linux:** ```bash -curl -fsSL https://alia5.github.io/VIIPER/install.sh | sh +curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh ``` Installs to: `/usr/local/bin/viiper` @@ -131,7 +130,7 @@ Installs to: `/usr/local/bin/viiper` **Windows (PowerShell):** ```powershell -irm https://alia5.github.io/VIIPER/install.ps1 | iex +irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex ``` Installs to: `%LOCALAPPDATA%\VIIPER\viiper.exe` @@ -148,7 +147,7 @@ The scripts will: The install scripts are version-aware based on where you download them from: - **Latest stable release:** - `curl -fsSL https://alia5.github.io/VIIPER/install.sh | sh` + `curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh` - **Specific version (e.g., v0.2.2):** `curl -fsSL https://alia5.github.io/VIIPER/0.2.2/install.sh | sh` diff --git a/internal/cmd/install_linux.go b/internal/cmd/install_linux.go index b42ea07f..c37a5c96 100644 --- a/internal/cmd/install_linux.go +++ b/internal/cmd/install_linux.go @@ -80,7 +80,7 @@ Wants=network-online.target [Service] Type=simple ExecStart=%q server -WorkingDirectory=%q +WorkingDirectory=%s Restart=on-failure [Install] diff --git a/scripts/install.sh b/scripts/install.sh index d98f150b..4dde1333 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,7 +9,10 @@ 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=$(echo "$RELEASE_DATA" | grep -o '"tag_name":"[^"]*' | cut -d'"' -f4) +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" >&2 From cdc61bab79ca01107f540264dbd01af4c5ab0090 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 21 Dec 2025 22:51:01 +0100 Subject: [PATCH 085/298] attempt to make viiper install more steamos friendly --- scripts/install.sh | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 4dde1333..0e1b9fa0 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -61,15 +61,69 @@ sudo mkdir -p "$INSTALL_DIR" sudo cp viiper "$INSTALL_PATH" sudo chmod +x "$INSTALL_PATH" + +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 +} + +ensure_modules_persist() { + local conf="/etc/modules-load.d/viiper.conf" + echo "Ensuring vhci_hcd loads at boot..." + if echo "vhci_hcd" | sudo tee "$conf" >/dev/null; then + echo "Configured module persistence: $conf" + else + echo "Warning: failed to write $conf. VHCI may not load automatically after reboot." + fi +} + +modprobe_vhci() { + echo "Loading vhci_hcd module now..." + if sudo modprobe vhci_hcd; then + echo "vhci_hcd loaded." + else + echo "Warning: failed to load vhci_hcd. VIIPER may still run if the module is already present." + fi +} + +STEAMOS_RW_TOGGLED=0 +if is_steamos; then + echo "SteamOS detected: checking read-only root state..." + if steamos-readonly status | grep -q "enabled"; then + echo "Read-only root is enabled. Temporarily disabling for installation..." + if steamos-readonly disable; then + STEAMOS_RW_TOGGLED=1 + else + echo "Warning: could not disable read-only filesystem. Proceeding; persistence may fail." + fi + else + echo "Read-only root is already disabled. Proceeding with installation." + fi +fi + if [ "$IS_UPDATE" -eq 1 ]; then echo "Existing VIIPER installation detected (update). Preserving startup/autostart configuration..." # On update, do NOT run `viiper install` (it would enable/restart the systemd service). # We only replace the binary so the previous enable/disable choice remains intact. + ensure_modules_persist + modprobe_vhci else echo "Configuring system startup..." + ensure_modules_persist + modprobe_vhci 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 "VIIPER installed successfully!" echo "Binary installed to: $INSTALL_PATH" if [ "$IS_UPDATE" -eq 1 ]; then From d4ccaa2a6df797b348336edea9858212fbbdf18e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 22 Dec 2025 03:15:10 +0100 Subject: [PATCH 086/298] Linux install script: handle updates more gracefully Changelog(misc) --- scripts/install.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 0e1b9fa0..585b19fa 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -56,6 +56,11 @@ if [ -f "$INSTALL_PATH" ]; then IS_UPDATE=1 fi +if [ "$IS_UPDATE" -eq 1 ]; then + echo "Stopping VIIPER service if running..." + sudo systemctl stop viiper.service || true +fi + echo "Installing binary to $INSTALL_PATH..." sudo mkdir -p "$INSTALL_DIR" sudo cp viiper "$INSTALL_PATH" @@ -116,9 +121,12 @@ else echo "Configuring system startup..." ensure_modules_persist modprobe_vhci - sudo "$INSTALL_PATH" install fi + +echo "Creating systemd service..." +sudo "$INSTALL_PATH" install + 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." From 9fe2c1ebaa8cf14296eb4ea095f9dbe6bc68b4f2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 22 Dec 2025 03:15:17 +0100 Subject: [PATCH 087/298] fmt --- .github/workflows/docs-deploy.yml | 233 +++++++++++++++--------------- 1 file changed, 116 insertions(+), 117 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 87067b01..c2c33a22 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,124 +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: Inject version into install scripts - if: startsWith(github.ref, 'refs/tags/') - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - - # Validate version format (must be semantic version) - 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 + 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: 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 From 2a6bc1c5df0e42949d334c4e8a20178259b63d2c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 22 Dec 2025 15:06:56 +0100 Subject: [PATCH 088/298] Add logo to mkdocs header --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 6ab2660a..c4c6cbbf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,7 @@ repo_url: https://github.com/Alia5/VIIPER theme: name: material + logo: viiper.svg palette: # Light mode - media: "(prefers-color-scheme: light)" From 355f66e7ecde8f8e4f04def9a09c7e5b65d84660 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 23 Dec 2025 04:27:22 +0100 Subject: [PATCH 089/298] Improve install scripts and install usbip --- docs/getting-started/installation.md | 15 ++- scripts/install.ps1 | 169 ++++++++++++++++++++---- scripts/install.sh | 189 ++++++++++++++++++++------- 3 files changed, 295 insertions(+), 78 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f8149610..7c6d049e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -115,9 +115,13 @@ The following scripts will download a VIIPER release, install it to a system loc 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. -!!! warning "USBIP not included" - The install scripts do **not** install/setup USBIP. - Make sure a USBIP-client is installed and configured before installing VIIPER. +!!! 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. **Linux:** @@ -139,8 +143,9 @@ The scripts will: 1. Download the specified VIIPER binary version 2. Install it to the system location -3. Configure automatic startup (Registry RunKey on Windows, systemd service on Linux) -4. Start the VIIPER server +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 **Version-Specific Installation:** diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 73325954..88bcb984 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -32,53 +32,176 @@ 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 2>$null + $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 } + } + $tempViiper = Join-Path $tempDir "viiper.exe" Invoke-WebRequest -Uri $downloadUrl -OutFile $tempViiper -ErrorAction Stop + + $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 - - Write-Host "Installing binary to $installPath..." - New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $skipInstall = $false - # On update, preserve the user's previous autostart preference. - # Autostart is controlled by `viiper install`/`viiper uninstall` (registry Run key). - # If we ran `install` again here we'd re-enable autostart for users who disabled it. - # So: if the binary already exists, just replace it. + $oldVersion = "unknown" if ($isUpdate) { - Write-Host "Existing VIIPER installation detected (update). Preserving startup/autostart configuration..." - # If the old binary is running, Windows will usually refuse to overwrite it. - # Stop any processes whose ExecutablePath matches the install path. - $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 + 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 { } } - catch { } } } + + Copy-Item $tempViiper $installPath -Force } - Copy-Item $tempViiper $installPath -Force + Write-Host "" + Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan + + $driverInstalled = Get-PnpDevice -Class USB -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -like '*usbip*' } + + $needsReboot = $false + + if (-not $driverInstalled) { + Write-Host "USBIP drivers not found. Installing..." -ForegroundColor Yellow + Write-Host "This requires administrator privileges." -ForegroundColor Yellow + + $driverUrl = "https://github.com/OSSign/vadimgrn--usbip-win2/releases/download/0.9.7.5-preview" + $driverFiles = @( + "usbip2_filter.cat", + "usbip2_filter.inf", + "usbip2_filter.sys", + "usbip2_ude.cat", + "usbip2_ude.inf", + "usbip2_ude.sys" + ) + + $driverDir = Join-Path $tempDir "usbip_drivers" + New-Item -ItemType Directory -Path $driverDir -Force | Out-Null + + foreach ($file in $driverFiles) { + Write-Host " Downloading $file..." -ForegroundColor Cyan + $fileUrl = "$driverUrl/$file" + $filePath = Join-Path $driverDir $file + try { + Invoke-WebRequest -Uri $fileUrl -OutFile $filePath -ErrorAction Stop + } + catch { + Write-Host " Warning: Failed to download $file - $($_.Exception.Message)" -ForegroundColor Yellow + } + } + + $filterInf = Join-Path $driverDir "usbip2_filter.inf" + $udeInf = Join-Path $driverDir "usbip2_ude.inf" + + if ((Test-Path $filterInf) -and (Test-Path $udeInf)) { + Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow + + $installScript = @" +Set-Location '$driverDir' +pnputil.exe /add-driver usbip2_filter.inf /install +pnputil.exe /add-driver usbip2_ude.inf /install +"@ + + try { + Start-Process powershell -Verb RunAs -ArgumentList "-NoProfile", "-Command", $installScript -Wait + Write-Host "USBIP drivers installed 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/OSSign/vadimgrn--usbip-win2/releases" -ForegroundColor Yellow + } + } + else { + Write-Host "Warning: Could not download all required driver files" -ForegroundColor Yellow + Write-Host "Please install usbip-win2 manually from:" -ForegroundColor Yellow + Write-Host " https://github.com/OSSign/vadimgrn--usbip-win2/releases" -ForegroundColor Yellow + } + } + else { + Write-Host "USBIP drivers already installed" -ForegroundColor Green + } - if (-not $isUpdate) { - Write-Host "Configuring system startup..." + if (-not $skipInstall) { + if (-not $isUpdate) { + Write-Host "Configuring system startup..." + } & $installPath install } Write-Host "VIIPER installed successfully!" -ForegroundColor Green Write-Host "Binary installed to: $installPath" if ($isUpdate) { - Write-Host "Update complete. Startup/autostart configuration was left unchanged." - Write-Host "If VIIPER was running, restart it to use the updated binary." + if ($skipInstall) { + Write-Host "Binary already at correct version or newer. Skipping installation." + } + 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." } + + 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 index 585b19fa..4eda1480 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,7 +15,7 @@ VERSION=$(printf '%s' "$RELEASE_DATA" \ | cut -d'"' -f4) if [ -z "$VERSION" ]; then - echo "Error: Could not fetch VIIPER release" >&2 + echo "Error: Could not fetch VIIPER release" exit 1 fi @@ -27,8 +27,8 @@ case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) - echo "Error: Unsupported architecture: $ARCH" >&2 - echo "Supported: x86_64 (amd64), aarch64/arm64" >&2 + echo "Error: Unsupported architecture: $ARCH" + echo "Supported: x86_64 (amd64), aarch64/arm64" exit 1 ;; esac @@ -42,101 +42,190 @@ trap "rm -rf $TEMP_DIR" EXIT cd "$TEMP_DIR" if ! curl -fsSL -o viiper "$DOWNLOAD_URL"; then - echo "Error: Could not download VIIPER binary" >&2 + echo "Error: Could not download VIIPER binary" exit 1 fi 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 ]; then +if [ "$IS_UPDATE" -eq 1 ] && [ "$SKIP_INSTALL" -eq 0 ]; then echo "Stopping VIIPER service if running..." sudo systemctl stop viiper.service || true fi -echo "Installing binary to $INSTALL_PATH..." -sudo mkdir -p "$INSTALL_DIR" -sudo cp viiper "$INSTALL_PATH" -sudo chmod +x "$INSTALL_PATH" +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 -is_steamos() { - if command -v steamos-readonly >/dev/null; then +detect_package_manager() { + if command -v pacman >/dev/null ; then + echo "pacman" return 0 fi - if [ -r /etc/os-release ] && grep -qi '^ID=steamos' /etc/os-release; then + 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 } -ensure_modules_persist() { - local conf="/etc/modules-load.d/viiper.conf" - echo "Ensuring vhci_hcd loads at boot..." - if echo "vhci_hcd" | sudo tee "$conf" >/dev/null; then - echo "Configured module persistence: $conf" - else - echo "Warning: failed to write $conf. VHCI may not load automatically after reboot." +is_steamos() { + if command -v steamos-readonly >/dev/null; then + return 0 fi -} - -modprobe_vhci() { - echo "Loading vhci_hcd module now..." - if sudo modprobe vhci_hcd; then - echo "vhci_hcd loaded." - else - echo "Warning: failed to load vhci_hcd. VIIPER may still run if the module is already present." + if [ -r /etc/os-release ] && grep -qi '^ID=steamos' /etc/os-release; then + return 0 fi + return 1 } STEAMOS_RW_TOGGLED=0 -if is_steamos; then - echo "SteamOS detected: checking read-only root state..." - if steamos-readonly status | grep -q "enabled"; then - echo "Read-only root is enabled. Temporarily disabling for installation..." - if steamos-readonly disable; then - STEAMOS_RW_TOGGLED=1 - else - echo "Warning: could not disable read-only filesystem. Proceeding; persistence may fail." + +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 - else - echo "Read-only root is already disabled. Proceeding with installation." 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 (update). Preserving startup/autostart configuration..." - # On update, do NOT run `viiper install` (it would enable/restart the systemd service). - # We only replace the binary so the previous enable/disable choice remains intact. - ensure_modules_persist - modprobe_vhci + echo "Existing VIIPER installation detected. Preserving startup/autostart configuration..." else echo "Configuring system startup..." - ensure_modules_persist - modprobe_vhci 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 -echo "Creating systemd service..." -sudo "$INSTALL_PATH" install +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 ]; then - echo "Update complete. Startup/autostart configuration was left unchanged." - echo "If VIIPER is running, restart it to use the updated binary." -else + +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 From b2ea5032a5d6b0c4b02ab62a65157b08c98beece Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 04:09:29 +0100 Subject: [PATCH 090/298] Fix duplicated "\" on windows autorun registry entry --- internal/cmd/install_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 033fe416..5e1e450a 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -33,7 +33,7 @@ func install(logger *slog.Logger) error { return err } - value := fmt.Sprintf("%q server", exePath) + value := fmt.Sprintf("\"%s\" server", exePath) key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.ALL_ACCESS) if err != nil { return err From 21089324692a2eca6fe4906db3dab720a26385b2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 04:09:59 +0100 Subject: [PATCH 091/298] windows-install: always re-configure startup / launch --- scripts/install.ps1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 88bcb984..0156b384 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -175,23 +175,21 @@ pnputil.exe /add-driver usbip2_ude.inf /install Write-Host "USBIP drivers already installed" -ForegroundColor Green } - if (-not $skipInstall) { - if (-not $isUpdate) { - Write-Host "Configuring system startup..." - } - & $installPath install + if (-not $isUpdate) { + Write-Host "Configuring system startup..." } + & $installPath 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 installation." + 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." } + Write-Host "VIIPER service has been restarted." } else { Write-Host "VIIPER server is now running and will start automatically on boot." From 5308b8c1d8225646f9f5af6b187fed579e9f6a07 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 18:46:02 +0100 Subject: [PATCH 092/298] Windows: Detect gui launches and auto-run server in BG Changelog(feat) --- cmd/viiper/startup.go | 3 + cmd/viiper/startup_windows.go | 24 +++++++ internal/cmd/server.go | 13 ++++ internal/util/util.go | 16 +++++ internal/util/util_windows.go | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 cmd/viiper/startup.go create mode 100644 cmd/viiper/startup_windows.go create mode 100644 internal/util/util.go create mode 100644 internal/util/util_windows.go diff --git a/cmd/viiper/startup.go b/cmd/viiper/startup.go new file mode 100644 index 00000000..bcfa5654 --- /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..80fd50ee --- /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 || args[1] != "server" { + 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/internal/cmd/server.go b/internal/cmd/server.go index 862e2d10..e728c62a 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -13,6 +13,7 @@ import ( "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/internal/util" ) type Server struct { @@ -76,9 +77,21 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger 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...") + var b []byte = 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 { diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 00000000..8f345a1b --- /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..e95220b1 --- /dev/null +++ b/internal/util/util_windows.go @@ -0,0 +1,117 @@ +//go:build windows + +package util + +import ( + "log/slog" + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + user32 = windows.NewLazySystemDLL("user32.dll") + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procShowWindow = user32.NewProc("ShowWindow") + procFreeConsole = kernel32.NewProc("FreeConsole") +) + +func IsRunFromGUI() bool { + hwnd, _, _ := procGetConsoleWindow.Call() + hasConsole := hwnd != 0 + + parentName := getParentProcessName() + isCliParent := isCliProcess(parentName) + + slog.Debug("Parent Process Info", "parentName", parentName, "hasConsole", hasConsole, "isCliParent", isCliParent) + + if !hasConsole { + return true + } + + if isCliParent { + return false + } + + return strings.EqualFold(parentName, "explorer.exe") +} + +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 getParentProcessName() string { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return "" + } + defer windows.CloseHandle(snapshot) + + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + + currentPID := uint32(os.Getpid()) + var parentPID uint32 + + if err := windows.Process32First(snapshot, &pe); err != nil { + return "" + } + + for { + if pe.ProcessID == currentPID { + parentPID = pe.ParentProcessID + break + } + if err := windows.Process32Next(snapshot, &pe); err != nil { + return "" + } + } + + if parentPID == 0 { + return "" + } + + if err := windows.Process32First(snapshot, &pe); err != nil { + return "" + } + + for { + if pe.ProcessID == parentPID { + return windows.UTF16ToString(pe.ExeFile[:]) + } + if err := windows.Process32Next(snapshot, &pe); err != nil { + break + } + } + + return "" +} + +func isCliProcess(name string) bool { + cliProcesses := []string{ + "cmd.exe", + "powershell.exe", + "pwsh.exe", + "wt.exe", + "conhost.exe", + "windowsterminal.exe", + } + + nameLower := strings.ToLower(name) + for _, cli := range cliProcesses { + if nameLower == cli { + return true + } + } + return false +} From f5c9a089e966baec6cce5e51dc21ae3c275548fb Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 18:48:17 +0100 Subject: [PATCH 093/298] gofmt --- cmd/viiper/startup.go | 6 +- cmd/viiper/startup_windows.go | 48 +++---- internal/util/util.go | 32 ++--- internal/util/util_windows.go | 234 +++++++++++++++++----------------- 4 files changed, 160 insertions(+), 160 deletions(-) diff --git a/cmd/viiper/startup.go b/cmd/viiper/startup.go index bcfa5654..dd7538d7 100644 --- a/cmd/viiper/startup.go +++ b/cmd/viiper/startup.go @@ -1,3 +1,3 @@ -//go:build !windows - -package main +//go:build !windows + +package main diff --git a/cmd/viiper/startup_windows.go b/cmd/viiper/startup_windows.go index 80fd50ee..08781a36 100644 --- a/cmd/viiper/startup_windows.go +++ b/cmd/viiper/startup_windows.go @@ -1,24 +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 || args[1] != "server" { - 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 - } - } -} +//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 || args[1] != "server" { + 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/internal/util/util.go b/internal/util/util.go index 8f345a1b..7b569213 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -1,16 +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 -} +//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 index e95220b1..dde9ad1c 100644 --- a/internal/util/util_windows.go +++ b/internal/util/util_windows.go @@ -1,117 +1,117 @@ -//go:build windows - -package util - -import ( - "log/slog" - "os" - "strings" - "unsafe" - - "golang.org/x/sys/windows" -) - -var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - user32 = windows.NewLazySystemDLL("user32.dll") - procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") - procShowWindow = user32.NewProc("ShowWindow") - procFreeConsole = kernel32.NewProc("FreeConsole") -) - -func IsRunFromGUI() bool { - hwnd, _, _ := procGetConsoleWindow.Call() - hasConsole := hwnd != 0 - - parentName := getParentProcessName() - isCliParent := isCliProcess(parentName) - - slog.Debug("Parent Process Info", "parentName", parentName, "hasConsole", hasConsole, "isCliParent", isCliParent) - - if !hasConsole { - return true - } - - if isCliParent { - return false - } - - return strings.EqualFold(parentName, "explorer.exe") -} - -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 getParentProcessName() string { - snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) - if err != nil { - return "" - } - defer windows.CloseHandle(snapshot) - - var pe windows.ProcessEntry32 - pe.Size = uint32(unsafe.Sizeof(pe)) - - currentPID := uint32(os.Getpid()) - var parentPID uint32 - - if err := windows.Process32First(snapshot, &pe); err != nil { - return "" - } - - for { - if pe.ProcessID == currentPID { - parentPID = pe.ParentProcessID - break - } - if err := windows.Process32Next(snapshot, &pe); err != nil { - return "" - } - } - - if parentPID == 0 { - return "" - } - - if err := windows.Process32First(snapshot, &pe); err != nil { - return "" - } - - for { - if pe.ProcessID == parentPID { - return windows.UTF16ToString(pe.ExeFile[:]) - } - if err := windows.Process32Next(snapshot, &pe); err != nil { - break - } - } - - return "" -} - -func isCliProcess(name string) bool { - cliProcesses := []string{ - "cmd.exe", - "powershell.exe", - "pwsh.exe", - "wt.exe", - "conhost.exe", - "windowsterminal.exe", - } - - nameLower := strings.ToLower(name) - for _, cli := range cliProcesses { - if nameLower == cli { - return true - } - } - return false -} +//go:build windows + +package util + +import ( + "log/slog" + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + user32 = windows.NewLazySystemDLL("user32.dll") + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procShowWindow = user32.NewProc("ShowWindow") + procFreeConsole = kernel32.NewProc("FreeConsole") +) + +func IsRunFromGUI() bool { + hwnd, _, _ := procGetConsoleWindow.Call() + hasConsole := hwnd != 0 + + parentName := getParentProcessName() + isCliParent := isCliProcess(parentName) + + slog.Debug("Parent Process Info", "parentName", parentName, "hasConsole", hasConsole, "isCliParent", isCliParent) + + if !hasConsole { + return true + } + + if isCliParent { + return false + } + + return strings.EqualFold(parentName, "explorer.exe") +} + +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 getParentProcessName() string { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return "" + } + defer windows.CloseHandle(snapshot) + + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + + currentPID := uint32(os.Getpid()) + var parentPID uint32 + + if err := windows.Process32First(snapshot, &pe); err != nil { + return "" + } + + for { + if pe.ProcessID == currentPID { + parentPID = pe.ParentProcessID + break + } + if err := windows.Process32Next(snapshot, &pe); err != nil { + return "" + } + } + + if parentPID == 0 { + return "" + } + + if err := windows.Process32First(snapshot, &pe); err != nil { + return "" + } + + for { + if pe.ProcessID == parentPID { + return windows.UTF16ToString(pe.ExeFile[:]) + } + if err := windows.Process32Next(snapshot, &pe); err != nil { + break + } + } + + return "" +} + +func isCliProcess(name string) bool { + cliProcesses := []string{ + "cmd.exe", + "powershell.exe", + "pwsh.exe", + "wt.exe", + "conhost.exe", + "windowsterminal.exe", + } + + nameLower := strings.ToLower(name) + for _, cli := range cliProcesses { + if nameLower == cli { + return true + } + } + return false +} From 6342a6b502c1ca5c3ea9ca978d80f26bce865d28 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 18:57:57 +0100 Subject: [PATCH 094/298] Windows-Install: Fix VIIPER server closing upon terminal close Changelog(fix) --- scripts/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0156b384..26a4a4cb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -178,7 +178,7 @@ pnputil.exe /add-driver usbip2_ude.inf /install if (-not $isUpdate) { Write-Host "Configuring system startup..." } - & $installPath install + Start-Process -WindowStyle Hidden "$installPath" -ArgumentList "install" Write-Host "VIIPER installed successfully!" -ForegroundColor Green Write-Host "Binary installed to: $installPath" From 32aa8d1b923b1f0f30745118c01072e2d0ab4938 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 24 Dec 2025 22:02:32 +0100 Subject: [PATCH 095/298] Update docs --- docs/getting-started/installation.md | 130 +++++++++++++++------------ 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 7c6d049e..8897f7a8 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -8,72 +8,74 @@ For more information, see [FAQ](https://github.com/Alia5/VIIPER#why-is-this-a-st ## Requirements +### USBIP + VIIPER relies on USBIP. You must have a USBIP-Client implementation available on your system to use VIIPER's virtual devices. -### Windows +=== "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). + [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 😉** + **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. + !!! 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. + **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 +=== "Linux" -#### Ubuntu/Debian + #### Ubuntu/Debian -```bash -sudo apt install linux-tools-generic -``` + ```bash + sudo apt install linux-tools-generic + ``` -[Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) + [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) -#### Arch Linux + #### Arch Linux -```bash -sudo pacman -S usbip -``` + ```bash + sudo pacman -S usbip + ``` -[Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) + [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) -### Linux Kernel Module Setup + ### 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. + !!! 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 don't load it automatically. + Most Linux distributions include this module but don't load it automatically. -#### One-Time Setup + #### One-Time Setup -To load the module automatically on boot: + To load the module automatically on boot: -```bash -echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf -sudo modprobe vhci-hcd -``` + ```bash + echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf + sudo modprobe vhci-hcd + ``` -#### Manual Loading + #### Manual Loading -To load the module for the current session only: + To load the module for the current session only: -```bash -sudo modprobe vhci-hcd -``` + ```bash + sudo modprobe vhci-hcd + ``` -#### Verification + #### Verification -Check if the module is loaded: + Check if the module is loaded: -```bash -lsmod | grep vhci_hcd -``` + ```bash + lsmod | grep vhci_hcd + ``` ## Installing VIIPER @@ -94,6 +96,10 @@ This makes VIIPER ideal for embedding in applications or distributing as part of - 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: @@ -123,29 +129,37 @@ The following scripts will download a VIIPER release, install it to a system loc If the automated USBIP setup fails, follow the [USBIP guide](usbip.md) to finish manually. -**Linux:** +=== "Windows" -```bash -curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh -``` + ```powershell + irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex + ``` -Installs to: `/usr/local/bin/viiper` + Installs to: `%LOCALAPPDATA%\VIIPER\viiper.exe` -**Windows (PowerShell):** + The scripts will: -```powershell -irm https://alia5.github.io/VIIPER/stable/install.ps1 | iex -``` + 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: `%LOCALAPPDATA%\VIIPER\viiper.exe` + Installs to: `/usr/local/bin/viiper` -The scripts will: + 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 + 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 **Version-Specific Installation:** @@ -213,8 +227,8 @@ Building from source is only necessary if you need to modify VIIPER or target an - [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` + - Linux/macOS: Usually pre-installed + - Windows: `winget install ezwinports.make` ### Build Steps From 879c418dc88246975666eb9da51217c71bc17508 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 04:46:14 +0100 Subject: [PATCH 096/298] Add Device "E2E" Tests --- device/device_attach_test.go | 102 ++++++ device/keyboard/keyboard_test.go | 214 +++++++++++ device/mouse/mouse_test.go | 194 ++++++++++ device/xbox360/xbox360_test.go | 479 +++++++++++++++++++++++++ internal/server/api/device_registry.go | 11 + internal/server/api/server.go | 13 +- internal/server/usb/server.go | 14 +- testing/test_server.go | 73 ++++ testing/usbip_client.go | 349 ++++++++++++++++++ 9 files changed, 1447 insertions(+), 2 deletions(-) create mode 100644 device/device_attach_test.go create mode 100644 device/keyboard/keyboard_test.go create mode 100644 device/mouse/mouse_test.go create mode 100644 device/xbox360/xbox360_test.go create mode 100644 testing/test_server.go create mode 100644 testing/usbip_client.go diff --git a/device/device_attach_test.go b/device/device_attach_test.go new file mode 100644 index 00000000..f54e3f04 --- /dev/null +++ b/device/device_attach_test.go @@ -0,0 +1,102 @@ +package device_test + +import ( + "context" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "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() + defer s.ApiServer.Close() + + 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() + s.UsbServer.AddBus(b) + + c := apiclient.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() + } + + 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() + } + if !assert.NotNil(t, imp.Conn) { + return + } + + }) + } + +} diff --git a/device/keyboard/keyboard_test.go b/device/keyboard/keyboard_test.go new file mode 100644 index 00000000..529d270e --- /dev/null +++ b/device/keyboard/keyboard_test.go @@ -0,0 +1,214 @@ +package keyboard_test + +import ( + "context" + "io" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + viiperTesting "github.com/Alia5/VIIPER/testing" + "github.com/Alia5/VIIPER/usbip" + "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 keyboard.InputState + expectedReport []byte + } + + cases := []testCase{ + { + name: "No keys, no modifiers", + inputState: keyboard.InputState{ + Modifiers: 0, + KeyBitmap: [32]uint8{}, + }, + expectedReport: []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + }, + { + name: "C", + inputState: keyboard.PressKey(keyboard.KeyC), + expectedReport: []byte{0x00, 0x00, 0x40, 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, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "CTRL+C", + inputState: keyboard.PressKeyWithMod(keyboard.ModLeftCtrl, keyboard.KeyC), + expectedReport: []byte{0x01, 0x00, 0x40, 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, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "SHIFT+C", + inputState: keyboard.PressKeyWithMod(keyboard.ModLeftShift, keyboard.KeyC), + expectedReport: []byte{0x02, 0x00, 0x40, 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, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "ALT+C", + inputState: keyboard.PressKeyWithMod(keyboard.ModLeftAlt, keyboard.KeyC), + expectedReport: []byte{0x04, 0x00, 0x40, 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, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "WASD", + inputState: keyboard.PressKey(keyboard.KeyW, keyboard.KeyA, keyboard.KeyS, keyboard.KeyD), + expectedReport: []byte{0x00, 0x00, 0x90, 0x00, 0x40, 0x04, 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, 0x00}, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "keyboard", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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, 750*time.Millisecond) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.expectedReport, got) + }) + } +} + +func TestLEDs(t *testing.T) { + type testCase struct { + name string + ledMask byte + outPacket []byte + } + + cases := []testCase{ + { + name: "off", + ledMask: 0x00, + outPacket: []byte{0x00}, + }, + { + name: "numlock", + ledMask: keyboard.LEDNumLock, + outPacket: []byte{keyboard.LEDNumLock}, + }, + { + name: "capslock", + ledMask: keyboard.LEDCapsLock, + outPacket: []byte{keyboard.LEDCapsLock}, + }, + { + name: "scrolllock", + ledMask: keyboard.LEDScrollLock, + outPacket: []byte{keyboard.LEDScrollLock}, + }, + { + name: "all", + ledMask: keyboard.LEDNumLock | keyboard.LEDCapsLock | keyboard.LEDScrollLock | keyboard.LEDCompose | keyboard.LEDKana, + outPacket: []byte{ + keyboard.LEDNumLock | keyboard.LEDCapsLock | keyboard.LEDScrollLock | keyboard.LEDCompose | keyboard.LEDKana, + }, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "keyboard", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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 [1]byte + _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _, err := io.ReadFull(stream, buf[:]) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.ledMask, buf[0]) + }) + } +} diff --git a/device/mouse/mouse_test.go b/device/mouse/mouse_test.go new file mode 100644 index 00000000..e676fdde --- /dev/null +++ b/device/mouse/mouse_test.go @@ -0,0 +1,194 @@ +package mouse_test + +import ( + "context" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + viiperTesting "github.com/Alia5/VIIPER/testing" + "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.Btn_Left, + 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.Btn_Right, + 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.Btn_Right | mouse.Btn_Left | mouse.Btn_Middle | mouse.Btn_Back | mouse.Btn_Forward, + 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.Btn_Left, + 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() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "mouse", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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, 750*time.Millisecond) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tc.expectedReport, got) + }) + } +} diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go new file mode 100644 index 00000000..61545798 --- /dev/null +++ b/device/xbox360/xbox360_test.go @@ -0,0 +1,479 @@ +package xbox360_test + +import ( + "context" + "io" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + viiperTesting "github.com/Alia5/VIIPER/testing" + "github.com/Alia5/VIIPER/usbip" + "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 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() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "xbox360", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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, 750*time.Millisecond) + 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() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "xbox360", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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(750 * time.Millisecond)) + _, 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) + }) + } + +} diff --git a/internal/server/api/device_registry.go b/internal/server/api/device_registry.go index 72c70b3c..82eed183 100644 --- a/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -38,6 +38,17 @@ func GetRegistration(name string) DeviceRegistration { 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 { diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 0914a72a..53297e6e 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -49,6 +49,15 @@ func (a *Server) USB() *usb.Server { return a.usbs } // Config returns the server configuration. func (a *Server) Config() ServerConfig { return a.config } +// Addr returns the actual address the server is listening on. +// If Start hasn't been called yet, it returns the configured address. +func (a *Server) Addr() string { + if a.ln != nil { + return a.ln.Addr().String() + } + return a.addr +} + // Start listens on the configured address and serves incoming API commands. func (a *Server) Start() error { ln, err := net.Listen("tcp", a.addr) @@ -56,6 +65,9 @@ func (a *Server) Start() error { return err } a.ln = ln + + a.addr = ln.Addr().String() + a.config.Addr = a.addr a.logger.Info("API listening", "addr", a.addr) go a.serve() return nil @@ -241,5 +253,4 @@ func (a *Server) handleConn(conn net.Conn) { } connLogger.Error("api unknown path", "path", path) a.writeError(w, ErrNotFound(fmt.Sprintf("unknown path: %s", path))) - return } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 9e01c252..5d6b3877 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -303,6 +303,16 @@ func (s *Server) NextFreeBusID() uint32 { } } +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) @@ -310,6 +320,7 @@ func (s *Server) ListenAndServe() error { 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 { @@ -354,7 +365,8 @@ func (s *Server) Close() error { // GetListenPort extracts and returns the port number from the server's listen address. func (s *Server) GetListenPort() uint16 { - _, portStr, err := net.SplitHostPort(s.config.Addr) + addr := s.Addr() + _, portStr, err := net.SplitHostPort(addr) if err != nil { return 0 } diff --git a/testing/test_server.go b/testing/test_server.go new file mode 100644 index 00000000..30193c5c --- /dev/null +++ b/testing/test_server.go @@ -0,0 +1,73 @@ +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" +) + +type MockServer struct { + ApiServer *api.Server + UsbServer *usb.Server +} + +func NewTestServer(t *testing.T) *MockServer { + t.Helper() + + cfg := TestServerConfig(t) + 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 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..c15d1f1d --- /dev/null +++ b/testing/usbip_client.go @@ -0,0 +1,349 @@ +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() + + 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, 750*time.Millisecond) +} + +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: 0, + 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 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: 0, + 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) { + deadline := time.Now().Add(timeout) + var last []byte + for { + got, err := c.ReadInputReport(conn) + if err != nil { + return nil, err + } + last = got + if len(got) == len(want) { + eq := true + for i := range want { + if want[i] != got[i] { + eq = false + break + } + } + if eq { + return got, nil + } + } + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + } +} From ca3c62d911592b2e7903b708d7ba853f1247f94f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 05:28:55 +0100 Subject: [PATCH 097/298] Add codecov --- .github/workflows/PR_check.yml | 17 +++++++++-------- .github/workflows/build_base.yml | 9 ++++++++- .github/workflows/release.yml | 1 + .github/workflows/snapshots.yml | 1 + README.md | 1 + 5 files changed, 20 insertions(+), 9 deletions(-) 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 9d6665e8..de7af010 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -47,7 +47,14 @@ jobs: fi - name: Run tests - run: make test + run: go test -count=1 -v -coverprofile="coverage.txt" ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: . + fail_ci_if_error: true build: name: Build binaries (${{ matrix.target.goos }}/${{ matrix.target.goarch }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 927c093d..f01b0f36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ permissions: jobs: build: uses: ./.github/workflows/build_base.yml + secrets: inherit with: artifact_suffix: "-Release" upload_artifacts: true diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index e3cbfbe3..b909d77a 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -34,6 +34,7 @@ jobs: build: needs: calculate-version uses: ./.github/workflows/build_base.yml + secrets: inherit with: artifact_suffix: "-Snapshot" upload_artifacts: true diff --git a/README.md b/README.md index 29c74352..75f81cd6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@
[![Build Status](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml/badge.svg)](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml) +[![codecov](https://codecov.io/github/Alia5/VIIPER/graph/badge.svg?token=5WTEELM3X3)](https://codecov.io/github/Alia5/VIIPER) [![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE.txt) [![Client Libraries: MIT](https://img.shields.io/badge/Client_Libraries-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) From ade60ea51d700cc3dc45d22959d29643131ec38b Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 05:55:07 +0100 Subject: [PATCH 098/298] codecov ignore --- codecov.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..72256f7e --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +ignore: + - "examples/go/**/*" + - "testing/**/*" + - "internal/testing/**/*" + - "cmd/viiper/**/*" From d9740b9112adb4406a5b59b78db7e9e5e036ff76 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 07:43:13 +0100 Subject: [PATCH 099/298] Improve HID/Class Descriptor Abstractions --- device/keyboard/device.go | 110 +++++++++++++------------- device/mouse/device.go | 104 ++++++++++++------------ device/xbox360/device.go | 32 ++++++-- internal/server/usb/server.go | 50 +++++++++--- usb/hid/constants.go | 73 +++++++++++++++++ usb/hid/hid.go | 145 ++++++++++++++++++++++++++++++++++ usb/hid/items.go | 99 +++++++++++++++++++++++ usb/usbdesc.go | 130 ++++++++++++++++++++++++------ 8 files changed, 602 insertions(+), 141 deletions(-) create mode 100644 usb/hid/constants.go create mode 100644 usb/hid/hid.go create mode 100644 usb/hid/items.go diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 63781b67..a290abd7 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -7,6 +7,7 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" "github.com/Alia5/VIIPER/usbip" ) @@ -101,50 +102,53 @@ func (k *Keyboard) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { } // 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 +var reportDescriptor = hid.Report{ + 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. @@ -175,16 +179,16 @@ var defaultDescriptor = usb.Descriptor{ 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 + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, // Length auto-filled from Report + }, + }, + Report: reportDescriptor, }, - HIDReport: hidReportDescriptor, Endpoints: []usb.EndpointDescriptor{ { BEndpointAddress: 0x81, diff --git a/device/mouse/device.go b/device/mouse/device.go index 339719e7..9e16d691 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -7,6 +7,7 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" "github.com/Alia5/VIIPER/usbip" ) @@ -72,46 +73,51 @@ func (m *Mouse) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { // 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) - 0x16, 0x00, 0x80, // Logical Minimum (-32768) - 0x26, 0xFF, 0x7F, // Logical Maximum (32767) - 0x75, 0x10, // Report Size (16) - 0x95, 0x02, // Report Count (2) - 0x81, 0x06, // Input (Data, Variable, Relative) - 0x09, 0x38, // Usage (Wheel) - 0x16, 0x00, 0x80, // Logical Minimum (-32768) - 0x26, 0xFF, 0x7F, // Logical Maximum (32767) - 0x75, 0x10, // Report Size (16) - 0x95, 0x01, // Report Count (1) - 0x81, 0x06, // Input (Data, Variable, Relative) - 0x05, 0x0C, // Usage Page (Consumer) - 0x0A, 0x38, 0x02, // Usage (AC Pan) - 0x16, 0x00, 0x80, // Logical Minimum (-32768) - 0x26, 0xFF, 0x7F, // Logical Maximum (32767) - 0x75, 0x10, // Report Size (16) - 0x95, 0x01, // Report Count (1) - 0x81, 0x06, // Input (Data, Variable, Relative) - 0xC0, // End Collection - 0xC0, // End Collection +var reportDescriptor = hid.Report{ + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageMouse}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + hid.Usage{Usage: hid.UsagePointer}, + hid.Collection{ + Kind: hid.CollectionPhysical, + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageButton}, + hid.UsageMinimum{Min: 0x01}, // Button 1 + hid.UsageMaximum{Max: 0x05}, // Button 5 + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportCount{Count: 5}, + hid.ReportSize{Bits: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportCount{Count: 1}, + hid.ReportSize{Bits: 3}, + hid.Input{Flags: hid.MainConst}, + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageX}, + hid.Usage{Usage: hid.UsageY}, + hid.LogicalMinimum{Min: -32768}, + hid.LogicalMaximum{Max: 32767}, + hid.ReportSize{Bits: 16}, + hid.ReportCount{Count: 2}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainRel}, + hid.Usage{Usage: hid.UsageWheel}, + hid.LogicalMinimum{Min: -32768}, + hid.LogicalMaximum{Max: 32767}, + hid.ReportSize{Bits: 16}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainRel}, + hid.UsagePage{Page: hid.UsagePageConsumer}, + hid.Usage{Usage: hid.UsageACPan}, + hid.LogicalMinimum{Min: -32768}, + hid.LogicalMaximum{Max: 32767}, + hid.ReportSize{Bits: 16}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainRel}, + }, + }, + }}, + }, } // Descriptor defines the static USB descriptor for the mouse. @@ -142,16 +148,16 @@ var defaultDescriptor = usb.Descriptor{ 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 + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, + }, + }, + Report: reportDescriptor, }, - HIDReport: hidReportDescriptor, Endpoints: []usb.EndpointDescriptor{ { BEndpointAddress: 0x81, diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 9332ae34..befd2387 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -112,7 +112,12 @@ var defaultDescriptor = usb.Descriptor{ BInterfaceProtocol: 0x01, IInterface: 0x00, }, - HIDDescriptor: []byte{0x11, 0x21, 0x00, 0x01, 0x01, 0x25, 0x81, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 0x08, 0x00, 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}, @@ -129,7 +134,12 @@ var defaultDescriptor = usb.Descriptor{ 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}, + 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}, @@ -148,9 +158,19 @@ var defaultDescriptor = usb.Descriptor{ BInterfaceProtocol: 0x02, IInterface: 0x00, }, - HIDDescriptor: []byte{0x09, 0x21, 0x00, 0x01, 0x01, 0x22, 0x84, 0x07, 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}, + { + BEndpointAddress: 0x84, + BMAttributes: 0x03, + WMaxPacketSize: 0x0020, + BInterval: 0x10, + }, }, }, // Interface 3: ff/fd/13 with vendor-specific descriptor @@ -164,7 +184,9 @@ var defaultDescriptor = usb.Descriptor{ BInterfaceProtocol: 0x13, IInterface: 0x04, }, - VendorData: []byte{0x06, 0x41, 0x00, 0x01, 0x01, 0x03}, + ClassDescriptors: []usb.ClassSpecificDescriptor{ + {DescriptorType: 0x41, Payload: usb.Data{0x00, 0x01, 0x01, 0x03}}, + }, }, }, Strings: map[uint8]string{ diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 5d6b3877..2eb834f0 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -739,7 +739,7 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by case usbDescTypeDevice: data = desc.Bytes() case usbDescTypeConfiguration: - data = buildConfigDescriptor(desc) + data = s.buildConfigDescriptor(desc) case usbDescTypeString: if s, ok := desc.Strings[dindex]; ok { data = usb.EncodeStringDescriptor(s) @@ -759,11 +759,31 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by 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 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 { @@ -779,7 +799,7 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by return nil } -func buildConfigDescriptor(desc *usb.Descriptor) []byte { +func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { var b bytes.Buffer h := usb.ConfigHeader{ WTotalLength: 0, // to be patched @@ -792,15 +812,21 @@ func buildConfigDescriptor(desc *usb.Descriptor) []byte { h.Write(&b) for _, iface := range desc.Interfaces { iface.Descriptor.Write(&b) - if len(iface.HIDDescriptor) > 0 { - b.Write(iface.HIDDescriptor) + 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) } - if len(iface.VendorData) > 0 { - b.Write(iface.VendorData) - } } data := b.Bytes() 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..e743b7b4 --- /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 +} + +// Report is a complete HID report descriptor (type 0x22). +type Report struct { + Items []Item +} + +// Bytes encodes the report descriptor. +func (r Report) 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..45c7d26f --- /dev/null +++ b/usb/hid/items.go @@ -0,0 +1,99 @@ +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)}) +} diff --git a/usb/usbdesc.go b/usb/usbdesc.go index a9a4149e..59c19a6c 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -4,6 +4,9 @@ package usb import ( "bytes" "encoding/binary" + "fmt" + + "github.com/Alia5/VIIPER/usb/hid" ) // USB descriptor type constants @@ -25,6 +28,8 @@ const ( HIDDescLen = 9 ) +type Data []uint8 + // Descriptor holds all static descriptor/config data for a device. type Descriptor struct { Device DeviceDescriptor @@ -34,11 +39,21 @@ type Descriptor struct { // InterfaceConfig holds all descriptors for a single interface for bus management. type InterfaceConfig struct { - Descriptor InterfaceDescriptor - Endpoints []EndpointDescriptor - HIDDescriptor []byte // optional HID class descriptor (0x21) - HIDReport []byte // optional HID report descriptor (0x22) - VendorData []byte // optional vendor-specific bytes + 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. @@ -161,32 +176,103 @@ func (e EndpointDescriptor) Write(b *bytes.Buffer) { } -// HIDDescriptor (class descriptor, 0x21) with one subordinate report descriptor (0x22). +// 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 - BNumDescriptors uint8 - ClassDescType uint8 // 0x22 (report) - WDescriptorLength uint16 // LE, report descriptor length + 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) { - b.WriteByte(HIDDescLen) +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(h.BNumDescriptors) - b.WriteByte(h.ClassDescType) - _ = binary.Write(b, binary.LittleEndian, h.WDescriptorLength) + 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) } -// ReportDescriptor is a container for HID report descriptor bytes (0x22). -// Builders can populate Data to emit via Bytes(). -type ReportDescriptor struct { - Data []byte +// HIDFunction bundles the HID class descriptor (0x21) and the report descriptor (0x22) +// for a HID-class interface. +type HIDFunction struct { + Descriptor HIDDescriptor + Report hid.Report +} + +func (f HIDFunction) reportLen() (uint16, error) { + rb, err := f.Report.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 } -func (r ReportDescriptor) Bytes() []byte { - return r.Data +// 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) { + rb, err := f.Report.Bytes() + if err != nil { + return nil, err + } + return Data(rb), nil } From 2f296b4ed7cbca64fb154adead0e38d768fa6e38 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 08:50:12 +0100 Subject: [PATCH 100/298] SteamDeck (Jupiter) Controller emulation Changelog(feat) --- device/steamdeck/const.go | 99 +++++++ device/steamdeck/device.go | 423 +++++++++++++++++++++++++++++ device/steamdeck/deviceState.go | 175 ++++++++++++ device/steamdeck/handler.go | 66 +++++ device/steamdeck/steamdeck_test.go | 321 ++++++++++++++++++++++ examples/go/steam_deck/main.go | 126 +++++++++ internal/registry/devices.go | 7 +- internal/server/usb/server.go | 22 +- testing/usbip_client.go | 3 +- usb/device.go | 20 +- 10 files changed, 1254 insertions(+), 8 deletions(-) create mode 100644 device/steamdeck/const.go create mode 100644 device/steamdeck/device.go create mode 100644 device/steamdeck/deviceState.go create mode 100644 device/steamdeck/handler.go create mode 100644 device/steamdeck/steamdeck_test.go create mode 100644 examples/go/steam_deck/main.go diff --git a/device/steamdeck/const.go b/device/steamdeck/const.go new file mode 100644 index 00000000..35c49f65 --- /dev/null +++ b/device/steamdeck/const.go @@ -0,0 +1,99 @@ +package steamdeck + +const ( + ValveUSBVID = 0x28DE + JupiterPID = 0x1205 +) + +// (ApiCLient) Stream frame sizes. +const ( + InputStateSize = 52 + HapticStateSize = 4 +) + +// Valve/Steam Deck input report (interrupt IN) constants. +// +// SDL`controller_structs.h`. +const ( + ValveInReportMsgVersion uint16 = 0x0001 + + ValveInReportTypeControllerDeckState uint8 = 0x09 + ValveInReportLength uint8 = 64 + + ValveInReportHeaderSize = 4 + ValveInReportPacketNumOff = 4 + ValveInReportPayloadOff = 8 +) + +// Steam Deck feature report sizing. +// SDL uses 65-byte buffers on Windows: ReportID (0) + 64 bytes payload. +const ( + HIDFeatureReportBytes = 64 + HIDFeatureReportWinSize = 65 +) + +// Feature message IDs used by the Steam Deck controller protocol. +// Values from SDL's `steam/controller_constants.h`. +const ( + FeatureIDClearDigitalMappings = 0x81 + FeatureIDGetAttributesValues = 0x83 + FeatureIDSetSettingsValues = 0x87 + FeatureIDLoadDefaultSettings = 0x8E + FeatureIDGetStringAttribute = 0xAE + + FeatureIDTriggerHapticCmd = 0xEA + FeatureIDTriggerRumbleCmd = 0xEB +) + +// ControllerAttributes enum values (SDL `ControllerAttributes`). +const ( + AttribUniqueID = 0 + AttribProductID = 1 + AttribCapabilities = 2 + AttribFirmwareVersion = 3 + AttribFirmwareBuild = 4 +) + +// ControllerStringAttributes enum values (SDL `ControllerStringAttributes`). +const ( + AttribStrBoardSerial = 0 + AttribStrUnitSerial = 1 +) + +// Steam Deck button bitmasks. +// +// Values from SDL's `SDL_hidapi_steamdeck.c`. +const ( + ButtonR2 uint64 = 0x00000001 + ButtonL2 uint64 = 0x00000002 + ButtonRB uint64 = 0x00000004 + ButtonLB uint64 = 0x00000008 + + ButtonY uint64 = 0x00000010 + ButtonB uint64 = 0x00000020 + ButtonX uint64 = 0x00000040 + ButtonA uint64 = 0x00000080 + + ButtonDPadUp uint64 = 0x00000100 + ButtonDPadRight uint64 = 0x00000200 + ButtonDPadLeft uint64 = 0x00000400 + ButtonDPadDown uint64 = 0x00000800 + + ButtonView uint64 = 0x00001000 + ButtonSteam uint64 = 0x00002000 + ButtonMenu uint64 = 0x00004000 + + ButtonL5 uint64 = 0x00008000 + ButtonR5 uint64 = 0x00010000 + + ButtonLeftPadClick uint64 = 0x00020000 + ButtonRightPadClick uint64 = 0x00040000 + + ButtonL3 uint64 = 0x00400000 + ButtonR3 uint64 = 0x04000000 + + // High 32-bit button flags (ulButtonsH) shifted into the uint64. + ButtonL4 uint64 = 0x00000200 << 32 + ButtonR4 uint64 = 0x00000400 << 32 + ButtonQAM uint64 = 0x00040000 << 32 +) diff --git a/device/steamdeck/device.go b/device/steamdeck/device.go new file mode 100644 index 00000000..079ebd33 --- /dev/null +++ b/device/steamdeck/device.go @@ -0,0 +1,423 @@ +// Package steamdeck provides a minimal Steam Deck (Jupiter/LCD) controller HID implementation. +package steamdeck + +import ( + "encoding/binary" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" + "github.com/Alia5/VIIPER/usbip" +) + +type SteamDeck struct { + tick uint64 + + stateMu sync.Mutex + inputState *InputState + + featureMu sync.Mutex + featureResponse []byte // cached 65-byte (or 64-byte) feature response + + hapticFunc func(HapticState) + descriptor usb.Descriptor +} + +// New returns a new SteamDeck Controller device. +func New(o *device.CreateOptions) *SteamDeck { + d := &SteamDeck{ + descriptor: defaultDescriptor, + } + // Is any1 ever gonna use a patched SDL for this? Ignore custom VID/PIDS for now + // 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 +} + +// SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. +func (s *SteamDeck) SetRumbleCallback(f func(HapticState)) { + s.hapticFunc = f +} + +// UpdateInputState updates the controller's current input state (thread-safe). +// +// The latest state is used to build the 64-byte interrupt IN report. +func (s *SteamDeck) UpdateInputState(st InputState) { + s.stateMu.Lock() + if s.inputState == nil { + s.inputState = &InputState{} + } + *s.inputState = st + s.stateMu.Unlock() +} + +func (s *SteamDeck) getInputStateSnapshot() InputState { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if s.inputState == nil { + return InputState{} + } + return *s.inputState +} + +// HandleTransfer implements interrupt IN for the Steam Deck controller interface. +func (s *SteamDeck) HandleTransfer(ep uint32, dir uint32, _ []byte) []byte { + if dir != usbip.DirIn { + return nil + } + switch ep { + case 1: // 0x81 - controller input reports + seq := uint32(atomic.AddUint64(&s.tick, 1)) + st := s.getInputStateSnapshot() + return buildInReport(seq, st) + default: + return nil + } +} + +// HandleControl implements EP0 class requests for HID feature reports. +// +// The Steam Deck Controlelr uses feature reports to disable lizard mode, feed +// a watchdog, and trigger rumble. +func (s *SteamDeck) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { + const ( + hidReqGetReport = 0x01 + hidReqSetReport = 0x09 + + hidReqTypeClassInToInterface = 0xA1 + hidReqTypeClassOutToInterface = 0x21 + + hidReportTypeInput = 0x01 + hidReportTypeOutput = 0x02 + hidReportTypeFeature = 0x03 + ) + + // Steam / SDL expect the controller interface iface 2 + // Our descriptor includes placeholder interfaces for kb/M and the actual controller + // HID interface at idx 2. + iface := uint8(wIndex & 0xFF) + if iface != 2 { + return nil, false + } + + reportType := uint8(wValue >> 8) + // reportID := uint8(wValue & 0xFF) // We don't use report IDs (but may see 0). + _ = uint8(wValue & 0xFF) + + switch { + case bmRequestType == hidReqTypeClassInToInterface && bRequest == hidReqGetReport: + want := int(wLength) + if want <= 0 { + return nil, true + } + + switch reportType { + case hidReportTypeInput: + st := s.getInputStateSnapshot() + report := buildInReport(0, st) + if want == 65 { + resp := make([]byte, 65) + resp[0] = 0x00 + copy(resp[1:], report) + return resp, true + } + return report, true + case hidReportTypeFeature: + return s.getFeatureResponse(want), true + case hidReportTypeOutput: + return make([]byte, want), true + default: + return make([]byte, want), true + } + + case bmRequestType == hidReqTypeClassOutToInterface && bRequest == hidReqSetReport: + if reportType == hidReportTypeFeature { + s.handleFeatureReport(data) + return nil, true + } + // Ignore other report types for now, but ACK them + return nil, true + } + + return nil, false +} + +func (s *SteamDeck) getFeatureResponse(want int) []byte { + if want <= 0 { + return nil + } + + s.featureMu.Lock() + resp := append([]byte(nil), s.featureResponse...) + s.featureMu.Unlock() + + if len(resp) == 0 { + return make([]byte, want) + } + + // If host asks for 64 bytes but we have a 65-byte report (ReportID + payload) return the payload + if want == 64 && len(resp) == 65 { + return append([]byte(nil), resp[1:]...) + } + if len(resp) >= want { + return append([]byte(nil), resp[:want]...) + } + out := make([]byte, want) + copy(out, resp) + return out +} + +func (s *SteamDeck) setFeatureResponse(resp []byte) { + s.featureMu.Lock() + defer s.featureMu.Unlock() + if resp == nil { + s.featureResponse = nil + return + } + s.featureResponse = append([]byte(nil), resp...) +} + +func (s *SteamDeck) handleFeatureReport(data []byte) { + if len(data) == 65 && data[0] == 0x00 { + data = data[1:] + } + if len(data) < 2 { + return + } + + msgType := data[0] + // msgLen := data[1] + _ = data[1] + + // If this request expects a response, queue it so the following GET_REPORT(feature) + // returns exactly what SDL's ReadResponse() expects. + switch msgType { + case FeatureIDGetAttributesValues: + s.setFeatureResponse(buildGetAttributesResponse()) + case FeatureIDGetStringAttribute: + var tag uint8 = AttribStrUnitSerial + if len(data) >= 3 { + // FeatureReportMsg header is [type,length], so first payload byte is data[2] + tag = data[2] + } + s.setFeatureResponse(buildGetStringAttributeResponse(tag)) + case FeatureIDClearDigitalMappings, FeatureIDSetSettingsValues, FeatureIDLoadDefaultSettings: + // No-op but report protocol-level success; queue an empty response matching + // the request ID so a follow-up GET_FEATURE doesn't fuck up SDL. + s.setFeatureResponse(buildEmptyResponse(msgType)) + } + + if msgType != FeatureIDTriggerRumbleCmd { + return + } + + // FeatureReportMsg: + // [0]=type, [1]=length, [2..]=payload + // MsgSimpleRumbleCmd payload starts at offset 2: + // [2]=unRumbleType + // [3:5]=unIntensity + // [5:7]=unLeftMotorSpeed + // [7:9]=unRightMotorSpeed + if len(data) < 9 { + return + } + left := binary.LittleEndian.Uint16(data[5:7]) + right := binary.LittleEndian.Uint16(data[7:9]) + + if s.hapticFunc != nil { + s.hapticFunc(HapticState{LeftMotor: left, RightMotor: right}) + } +} + +func buildEmptyResponse(msgType uint8) []byte { + resp := make([]byte, 65) + resp[0] = 0x00 + resp[1] = msgType + resp[2] = 0 // header.length + return resp +} + +func buildFeatureResponse(msgType uint8, payload []byte) []byte { + resp := make([]byte, 65) + resp[0] = 0x00 + resp[1] = msgType + if payload == nil { + resp[2] = 0 + return resp + } + if len(payload) > 62 { + payload = payload[:62] + } + resp[2] = uint8(len(payload)) + copy(resp[3:], payload) + return resp +} + +func buildGetAttributesResponse() []byte { + // copied from real device + const ( + productID = uint32(0x1205) + capabilities = uint32(0x0160BFFF) + firmwareVersion = uint32(1752616979) + firmwareBuildTime = uint32(1752616979) + ) + + payload := make([]byte, 0, 5*5) + appendAttr := func(tag uint8, value uint32) { + b := make([]byte, 5) + b[0] = tag + binary.LittleEndian.PutUint32(b[1:5], value) + payload = append(payload, b...) + } + appendAttr(AttribUniqueID, 0) + appendAttr(AttribProductID, productID) + appendAttr(AttribCapabilities, capabilities) + appendAttr(AttribFirmwareVersion, firmwareVersion) + appendAttr(AttribFirmwareBuild, firmwareBuildTime) + + return buildFeatureResponse(FeatureIDGetAttributesValues, payload) +} + +func buildGetStringAttributeResponse(requestedTag uint8) []byte { + // MsgGetStringAttribute: { uint8 attributeTag; char attributeValue[20]; } + payload := make([]byte, 21) + payload[0] = requestedTag + + const serial = "VIIPER000001" + copy(payload[1:], []byte(serial)) + + return buildFeatureResponse(FeatureIDGetStringAttribute, payload) +} + +func buildInReport(packetNum uint32, st InputState) []byte { + buf := make([]byte, 64) + // ValveInReportHeader_t (packed): + // uint16 unReportVersion (LE) + // uint8 ucType + // uint8 ucLength + buf[0] = byte(ValveInReportMsgVersion) + buf[1] = byte(ValveInReportMsgVersion >> 8) + buf[2] = ValveInReportTypeControllerDeckState + buf[3] = ValveInReportLength + binary.LittleEndian.PutUint32(buf[4:8], packetNum) + + payload, _ := st.MarshalBinary() // fixed-size, cannot fail + copy(buf[8:], payload) + return buf +} + +var defaultDescriptor = usb.Descriptor{ + Device: usb.DeviceDescriptor{ + BcdUSB: 0x0200, + BDeviceClass: 0x00, + BDeviceSubClass: 0x00, + BDeviceProtocol: 0x00, + BMaxPacketSize0: 0x40, + IDVendor: ValveUSBVID, + IDProduct: JupiterPID, + BcdDevice: 0x0200, + IManufacturer: 0x01, + IProduct: 0x02, + ISerialNumber: 0x03, + BNumConfigurations: 0x01, + Speed: 2, // Full speed + }, + // Steam's SDL Steam Controller driver expects the controller interface to be + // interface 2. + // add two dummy interfaces and ONLY emulate the actual + // "controller" *cough* device at iface 2 + // We can SKIP the actual "lizard-mode kb/m (dummies)" + // We can also SKIP the CDC interfaces + // they are (AFAIK) only used for FW updates + Interfaces: []usb.InterfaceConfig{ + { + // Placeholder interface 0 (no endpoints) + // IS needed for steam to open the device + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0xFF, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + }, + { + // Placeholder interface 1 (no endpoints) + // IS needed for steam to open the device + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x01, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0xFF, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + }, + // Actual "controller" descriptor + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x00, + BNumEndpoints: 0x01, + BInterfaceClass: 0x03, + BInterfaceSubClass: 0x00, + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{ + {Type: usb.ReportDescType}, + }, + }, + Report: hid.Report{ + Items: []hid.Item{ + hid.UsagePage{Page: 0xFFFF}, + hid.Usage{Usage: 0x0001}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 64}, + hid.Usage{Usage: 0x0001}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.Usage{Usage: 0x0001}, + hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + }}, + }, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: 0x81, + BMAttributes: 0x03, + WMaxPacketSize: 0x0040, + BInterval: 0x01, + }, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\x04\x09", // LangID: en-US (0x0409) + 1: "Valve Software", + 2: "Steam Deck Controller", + 3: "VIIPER-Deck-Jupiter", + }, +} + +func (s *SteamDeck) GetDescriptor() *usb.Descriptor { + return &s.descriptor +} diff --git a/device/steamdeck/deviceState.go b/device/steamdeck/deviceState.go new file mode 100644 index 00000000..481e79bc --- /dev/null +++ b/device/steamdeck/deviceState.go @@ -0,0 +1,175 @@ +package steamdeck + +import ( + "encoding/binary" + "fmt" + "io" +) + +// InputState is the client-facing input state for a Steam Deck (Jupiter/LCD) +// controller. +// +// This struct mirrors SDL's `SteamDeckStatePacket_t` fields (minus unPacketNum) +// +// Wire format (client -> device stream): fixed 52 bytes, little-endian. +// viiper:wire steamdeck c2s buttons:u64 lpX:i16 lpY:i16 rpX:i16 rpY:i16 ax:i16 ay:i16 az:i16 gx:i16 gy:i16 gz:i16 qw:i16 qx:i16 qy:i16 qz:i16 tl:u16 tr:u16 lsx:i16 lsy:i16 rsx:i16 rsy:i16 pl:u16 pr:u16 +type InputState struct { + Buttons uint64 + + LeftPadX int16 + LeftPadY int16 + RightPadX int16 + RightPadY int16 + + AccelX int16 + AccelY int16 + AccelZ int16 + + GyroX int16 + GyroY int16 + GyroZ int16 + + GyroQuatW int16 + GyroQuatX int16 + GyroQuatY int16 + GyroQuatZ int16 + + TriggerRawL uint16 + TriggerRawR uint16 + + LeftStickX int16 + LeftStickY int16 + + RightStickX int16 + RightStickY int16 + + PressurePadLeft uint16 + PressurePadRight uint16 +} + +// MarshalBinary encodes InputState to the fixed 52-byte wire format. +func (s InputState) MarshalBinary() ([]byte, error) { + b := make([]byte, InputStateSize) + o := 0 + + binary.LittleEndian.PutUint64(b[o:o+8], s.Buttons) + o += 8 + + putI16 := func(v int16) { + binary.LittleEndian.PutUint16(b[o:o+2], uint16(v)) + o += 2 + } + putU16 := func(v uint16) { + binary.LittleEndian.PutUint16(b[o:o+2], v) + o += 2 + } + + putI16(s.LeftPadX) + putI16(s.LeftPadY) + putI16(s.RightPadX) + putI16(s.RightPadY) + + putI16(s.AccelX) + putI16(s.AccelY) + putI16(s.AccelZ) + + putI16(s.GyroX) + putI16(s.GyroY) + putI16(s.GyroZ) + + putI16(s.GyroQuatW) + putI16(s.GyroQuatX) + putI16(s.GyroQuatY) + putI16(s.GyroQuatZ) + + putU16(s.TriggerRawL) + putU16(s.TriggerRawR) + + putI16(s.LeftStickX) + putI16(s.LeftStickY) + putI16(s.RightStickX) + putI16(s.RightStickY) + + putU16(s.PressurePadLeft) + putU16(s.PressurePadRight) + + return b, nil +} + +// UnmarshalBinary decodes InputState from the fixed 52-byte wire format. +func (s *InputState) UnmarshalBinary(data []byte) error { + if len(data) < InputStateSize { + return io.ErrUnexpectedEOF + } + o := 0 + + s.Buttons = binary.LittleEndian.Uint64(data[o : o+8]) + o += 8 + + getI16 := func() int16 { + v := int16(binary.LittleEndian.Uint16(data[o : o+2])) + o += 2 + return v + } + getU16 := func() uint16 { + v := binary.LittleEndian.Uint16(data[o : o+2]) + o += 2 + return v + } + + s.LeftPadX = getI16() + s.LeftPadY = getI16() + s.RightPadX = getI16() + s.RightPadY = getI16() + + s.AccelX = getI16() + s.AccelY = getI16() + s.AccelZ = getI16() + + s.GyroX = getI16() + s.GyroY = getI16() + s.GyroZ = getI16() + + s.GyroQuatW = getI16() + s.GyroQuatX = getI16() + s.GyroQuatY = getI16() + s.GyroQuatZ = getI16() + + s.TriggerRawL = getU16() + s.TriggerRawR = getU16() + + s.LeftStickX = getI16() + s.LeftStickY = getI16() + s.RightStickX = getI16() + s.RightStickY = getI16() + + s.PressurePadLeft = getU16() + s.PressurePadRight = getU16() + + return nil +} + +// HapticState is the client-facing representation of Steam Deck haptic feedback. +// Values are 16-bit "motor" speeds as used by SDL's Steam Deck HID driver. +type HapticState struct { + LeftMotor uint16 + RightMotor uint16 +} + +// MarshalBinary encodes HapticState as 4 bytes (2x uint16 little-endian). +func (r HapticState) MarshalBinary() ([]byte, error) { + b := make([]byte, 4) + binary.LittleEndian.PutUint16(b[0:2], r.LeftMotor) + binary.LittleEndian.PutUint16(b[2:4], r.RightMotor) + return b, nil +} + +// UnmarshalBinary decodes HapticState from 4 bytes (2x uint16 little-endian). +func (r *HapticState) UnmarshalBinary(data []byte) error { + if len(data) < 4 { + return fmt.Errorf("Invalid haptic packet len, got %d, want 4", len(data)) + } + r.LeftMotor = binary.LittleEndian.Uint16(data[0:2]) + r.RightMotor = binary.LittleEndian.Uint16(data[2:4]) + return nil +} diff --git a/device/steamdeck/handler.go b/device/steamdeck/handler.go new file mode 100644 index 00000000..41915e3a --- /dev/null +++ b/device/steamdeck/handler.go @@ -0,0 +1,66 @@ +package steamdeck + +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("steamdeck", &handler{}) +} + +type handler struct{} + +func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { 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") + } + sdev, ok := (*devPtr).(*SteamDeck) + if !ok { + return fmt.Errorf("device is not steamdeck") + } + + // Device -> client: rumble feedback (2x uint16 little-endian) + sdev.SetRumbleCallback(func(r HapticState) { + data, err := r.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) + } + }) + + // Client -> device: raw 64-byte controller reports + // Client -> device: InputState frames (fixed-size, little-endian) + 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) + } + + // Copy buf because ReadFull reuses it. + frame := make([]byte, InputStateSize) + copy(frame, buf) + var st InputState + if err := st.UnmarshalBinary(frame); err != nil { + return fmt.Errorf("decode input state: %w", err) + } + sdev.UpdateInputState(st) + } + } +} diff --git a/device/steamdeck/steamdeck_test.go b/device/steamdeck/steamdeck_test.go new file mode 100644 index 00000000..bb6d6f4a --- /dev/null +++ b/device/steamdeck/steamdeck_test.go @@ -0,0 +1,321 @@ +package steamdeck_test + +import ( + "context" + "encoding/binary" + "io" + "math" + "testing" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/steamdeck" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + viiperTesting "github.com/Alia5/VIIPER/testing" + "github.com/Alia5/VIIPER/usbip" + "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 steamdeck.InputState + expectedReport []byte + } + + cases := []testCase{ + { + name: "no inputs", + inputState: steamdeck.InputState{}, + expectedReport: []byte{ + 0x01, 0x00, 0x09, 0x40, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + name: "buttons a+b", + inputState: steamdeck.InputState{ + Buttons: steamdeck.ButtonA | steamdeck.ButtonB, + }, + expectedReport: []byte{ + 0x01, 0x00, 0x09, 0x40, 0x00, 0x00, 0x00, 0x00, + 0xA0, 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, 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, 0x00, + }, + }, + { + name: "left stick only", + inputState: steamdeck.InputState{ + LeftStickX: 1234, + LeftStickY: -2345, + }, + expectedReport: []byte{ + 0x01, 0x00, 0x09, 0x40, 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, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD2, 0x04, 0xD7, 0xF6, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + name: "buttons and left stick", + inputState: steamdeck.InputState{ + Buttons: steamdeck.ButtonDPadUp | steamdeck.ButtonSteam, + LeftStickX: -32768, + LeftStickY: 32767, + }, + expectedReport: []byte{ + 0x01, 0x00, 0x09, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x21, 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, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "steamdeck", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Len(t, tc.expectedReport, int(steamdeck.ValveInReportLength)) + assert.Equal(t, byte(steamdeck.ValveInReportMsgVersion), tc.expectedReport[0]) + assert.Equal(t, byte(steamdeck.ValveInReportMsgVersion>>8), tc.expectedReport[1]) + assert.Equal(t, steamdeck.ValveInReportTypeControllerDeckState, tc.expectedReport[2]) + assert.Equal(t, steamdeck.ValveInReportLength, tc.expectedReport[3]) + + if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { + return + } + + deadline := time.Now().Add(750 * time.Millisecond) + var last []byte + for { + got, err := usbipClient.ReadInputReport(imp.Conn) + if !assert.NoError(t, err) { + return + } + last = got + if steamDeckReportsEqualIgnoringPacketCounter(tc.expectedReport, got) { + break + } + if time.Now().After(deadline) { + assert.Failf(t, "timed out waiting for matching report", "last=%x want=%x", last, tc.expectedReport) + return + } + time.Sleep(1 * time.Millisecond) + } + }) + } + +} + +func TestHaptics(t *testing.T) { + + type testCase struct { + name string + hapticState steamdeck.HapticState + outPacket []byte + } + cases := []testCase{ + { + name: "off", + hapticState: steamdeck.HapticState{ + LeftMotor: 0, + RightMotor: 0, + }, + outPacket: []byte{ + 0x00, 0xEB, 0x07, 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, + 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, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + name: "mid", + hapticState: steamdeck.HapticState{ + LeftMotor: math.MaxUint16 / 2, + RightMotor: math.MaxUint16 / 2, + }, + outPacket: []byte{ + 0x00, 0xEB, 0x07, 0x00, 0x00, 0x00, 0xFF, 0x7F, 0xFF, 0x7F, + 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, 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, 0x00, + }, + }, + { + name: "full", + hapticState: steamdeck.HapticState{ + LeftMotor: math.MaxUint16, + RightMotor: math.MaxUint16, + }, + outPacket: []byte{ + 0x00, 0xEB, 0x07, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 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, 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, 0x00, + }, + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "steamdeck", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setup := steamDeckSetReportFeatureSetup(uint16(len(tc.outPacket))) + if !assert.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 0, tc.outPacket, &setup)) { + return + } + var buf [4]byte + _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _, err := io.ReadFull(stream, buf[:]) + if !assert.NoError(t, err) { + return + } + got := steamdeck.HapticState{ + LeftMotor: binary.LittleEndian.Uint16(buf[0:2]), + RightMotor: binary.LittleEndian.Uint16(buf[2:4]), + } + assert.Equal(t, tc.hapticState, got) + }) + } + +} + +func steamDeckReportsEqualIgnoringPacketCounter(want, got []byte) bool { + if len(want) != len(got) { + return false + } + for i := range want { + if i >= steamdeck.ValveInReportPacketNumOff && i < steamdeck.ValveInReportPayloadOff { + continue + } + if want[i] != got[i] { + return false + } + } + return true +} + +func steamDeckSetReportFeatureSetup(wLength uint16) [8]byte { + + var setup [8]byte + setup[0] = 0x21 + setup[1] = 0x09 + binary.LittleEndian.PutUint16(setup[2:4], 0x0300) + binary.LittleEndian.PutUint16(setup[4:6], 0x0002) + binary.LittleEndian.PutUint16(setup[6:8], wLength) + return setup +} diff --git a/examples/go/steam_deck/main.go b/examples/go/steam_deck/main.go new file mode 100644 index 00000000..da5e10d0 --- /dev/null +++ b/examples/go/steam_deck/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "math" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/steamdeck" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: steamdeck_client ") + fmt.Println("Example: steamdeck_client localhost:3242") + os.Exit(1) + } + + addr := os.Args[1] + ctx := context.Background() + api := apiclient.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, "steamdeck", nil) + if err != nil { + fmt.Printf("AddDeviceAndConnect error: %v\n", err) + if createdBus { + _, _ = api.BusRemoveCtx(ctx, busID) + } + os.Exit(1) + } + defer stream.Close() + fmt.Println("Added device", addResp) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(16 * time.Millisecond) + defer ticker.Stop() + + var frame uint64 + for { + select { + case <-ticker.C: + frame++ + var buttons uint64 + switch (frame / 60) % 4 { + case 0: + buttons = steamdeck.ButtonA + case 1: + buttons = 0 + case 2: + buttons = steamdeck.ButtonX + default: + buttons = steamdeck.ButtonY + } + + inputState := &steamdeck.InputState{ + Buttons: buttons, + PressurePadLeft: 0, + PressurePadRight: 0, + LeftPadX: 0, + LeftPadY: 0, + RightPadX: 0, + RightPadY: 0, + LeftStickX: 0, + LeftStickY: 0, + RightStickX: 0, + RightStickY: 0, + AccelX: 0, + AccelY: 0, + AccelZ: 0, + GyroX: 0, + GyroY: 0, + GyroZ: 0, + GyroQuatW: 0, + GyroQuatX: 0, + GyroQuatY: 0, + GyroQuatZ: 0, + TriggerRawL: uint16((frame*20)%math.MaxUint16 + 1), + TriggerRawR: uint16((frame*20)%math.MaxUint16 + 1), + } + if err := stream.WriteBinary(inputState); 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, inputState.Buttons, inputState.TriggerRawL, inputState.TriggerRawR) + } + + case <-sigCh: + fmt.Println("Signal received, stopping…") + return + } + } + +} diff --git a/internal/registry/devices.go b/internal/registry/devices.go index 904ad743..dcacced5 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -1,7 +1,8 @@ package registry import ( - _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler - _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler - _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler + _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler + _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler + _ "github.com/Alia5/VIIPER/device/steamdeck" // Register steamdeck device handler + _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler ) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 2eb834f0..357dcab1 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -653,10 +653,15 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { respData := s.processSubmit(dev, ep, dir, setup, outPayload) + actualLen := uint32(len(respData)) + if dir == usbip.DirOut { + actualLen = uint32(len(outPayload)) + } + ret := usbip.RetSubmit{ Basic: usbip.HeaderBasic{Command: usbip.RetSubmitCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, Status: 0, - ActualLength: uint32(len(respData)), + ActualLength: actualLen, StartFrame: 0, NumberOfPackets: 0, ErrorCount: 0, @@ -794,8 +799,19 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by } return data } - _ = wIndex - _ = out + + 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 + } + } + return nil } diff --git a/testing/usbip_client.go b/testing/usbip_client.go index c15d1f1d..a3247645 100644 --- a/testing/usbip_client.go +++ b/testing/usbip_client.go @@ -260,7 +260,8 @@ func (c *TestUsbIpClient) SubmitWithTimeout(conn net.Conn, dir uint32, ep uint32 if status != 0 { return fmt.Errorf("ret status %d", status) } - if actual > 0 { + + if dir == usbip.DirIn && actual > 0 { discard := make([]byte, int(actual)) if err := usbip.ReadExactly(conn, discard); err != nil { return err diff --git a/usb/device.go b/usb/device.go index 74b7e937..2112d05e 100644 --- a/usb/device.go +++ b/usb/device.go @@ -4,8 +4,26 @@ package usb // 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. + // ep is the endpoint number (without direction). dir is usbip.DirIn or usbip.DirOut. // For IN transfers, return the payload to send; for OUT, consume 'out' and return nil. HandleTransfer(ep uint32, dir uint32, out []byte) []byte GetDescriptor() *Descriptor } + +// 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) +} From 807fb2ce8f640d9377ae711ada19202463d6e130 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 20:28:42 +0100 Subject: [PATCH 101/298] Fix SteamDeck dummy interfaces showing up "broken" in Windows --- device/steamdeck/device.go | 153 +++++++++++++++++++++++++++++++++---- 1 file changed, 140 insertions(+), 13 deletions(-) diff --git a/device/steamdeck/device.go b/device/steamdeck/device.go index 079ebd33..daf652af 100644 --- a/device/steamdeck/device.go +++ b/device/steamdeck/device.go @@ -74,6 +74,14 @@ func (s *SteamDeck) HandleTransfer(ep uint32, dir uint32, _ []byte) []byte { return nil } switch ep { + case 2: // 0x82 - dummy keyboard interface (iface 0) + // Keep Windows HID stack happy + // no actual lizard-mode behavior. + atomic.AddUint64(&s.tick, 1) + return make([]byte, 8) + case 3: // 0x83 - dummy mouse interface (iface 1) + atomic.AddUint64(&s.tick, 1) + return make([]byte, 4) case 1: // 0x81 - controller input reports seq := uint32(atomic.AddUint64(&s.tick, 1)) st := s.getInputStateSnapshot() @@ -104,14 +112,39 @@ func (s *SteamDeck) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, // Our descriptor includes placeholder interfaces for kb/M and the actual controller // HID interface at idx 2. iface := uint8(wIndex & 0xFF) - if iface != 2 { - return nil, false - } reportType := uint8(wValue >> 8) // reportID := uint8(wValue & 0xFF) // We don't use report IDs (but may see 0). _ = uint8(wValue & 0xFF) + // HID-class handling for the dummy interfaces so Windows doesn't flag them as malfunctioning. + if iface == 0 || iface == 1 { + want := int(wLength) + switch { + case bmRequestType == hidReqTypeClassInToInterface && bRequest == hidReqGetReport: + if want <= 0 { + return nil, true + } + return make([]byte, want), true + case bmRequestType == hidReqTypeClassOutToInterface && (bRequest == hidReqSetReport || bRequest == 0x0A || bRequest == 0x0B): + return nil, true + case bmRequestType == hidReqTypeClassInToInterface && (bRequest == 0x02 || bRequest == 0x03): + if want <= 0 { + return nil, true + } + resp := make([]byte, want) + if bRequest == 0x03 { + resp[0] = 0x01 + } + return resp, true + } + return nil, false + } + + if iface != 2 { + return nil, false + } + switch { case bmRequestType == hidReqTypeClassInToInterface && bRequest == hidReqGetReport: want := int(wLength) @@ -150,6 +183,68 @@ func (s *SteamDeck) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, return nil, false } +var dummyKeyboardReportDescriptor = hid.Report{ + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageKeyboard}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageKeyboard}, + hid.UsageMinimum{Min: 0xE0}, + hid.UsageMaximum{Max: 0xE7}, + 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}, + + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainConst}, + + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.UsageMinimum{Min: 0x00}, + hid.UsageMaximum{Max: 0xFF}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 6}, + hid.Input{Flags: hid.MainData | hid.MainArray | hid.MainAbs}, + }}, + }, +} + +var dummyMouseReportDescriptor = hid.Report{ + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageMouse}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + hid.Usage{Usage: hid.UsagePointer}, + hid.Collection{Kind: hid.CollectionPhysical, Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageButton}, + hid.UsageMinimum{Min: 0x01}, + hid.UsageMaximum{Max: 0x03}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportSize{Bits: 1}, + hid.ReportCount{Count: 3}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + hid.ReportSize{Bits: 5}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainConst}, + + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageX}, + hid.Usage{Usage: hid.UsageY}, + hid.Usage{Usage: hid.UsageWheel}, + hid.LogicalMinimum{Min: -127}, + hid.LogicalMaximum{Max: 127}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 3}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainRel}, + }}, + }}, + }, +} + func (s *SteamDeck) getFeatureResponse(want int) []byte { if want <= 0 { return nil @@ -339,30 +434,62 @@ var defaultDescriptor = usb.Descriptor{ // they are (AFAIK) only used for FW updates Interfaces: []usb.InterfaceConfig{ { - // Placeholder interface 0 (no endpoints) + // Placeholder interface 0 // IS needed for steam to open the device Descriptor: usb.InterfaceDescriptor{ BInterfaceNumber: 0x00, BAlternateSetting: 0x00, - BNumEndpoints: 0x00, - BInterfaceClass: 0xFF, - BInterfaceSubClass: 0x00, - BInterfaceProtocol: 0x00, + BNumEndpoints: 0x01, + BInterfaceClass: 0x03, + BInterfaceSubClass: 0x01, + BInterfaceProtocol: 0x01, IInterface: 0x00, }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{{Type: usb.ReportDescType}}, + }, + Report: dummyKeyboardReportDescriptor, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: 0x82, + BMAttributes: 0x03, + WMaxPacketSize: 0x0008, + BInterval: 0x0A, + }, + }, }, { - // Placeholder interface 1 (no endpoints) + // Placeholder interface 1 // IS needed for steam to open the device Descriptor: usb.InterfaceDescriptor{ BInterfaceNumber: 0x01, BAlternateSetting: 0x00, - BNumEndpoints: 0x00, - BInterfaceClass: 0xFF, - BInterfaceSubClass: 0x00, - BInterfaceProtocol: 0x00, + BNumEndpoints: 0x01, + BInterfaceClass: 0x03, + BInterfaceSubClass: 0x01, + BInterfaceProtocol: 0x02, IInterface: 0x00, }, + HID: &usb.HIDFunction{ + Descriptor: usb.HIDDescriptor{ + BcdHID: 0x0111, + BCountryCode: 0x00, + Descriptors: []usb.HIDSubDescriptor{{Type: usb.ReportDescType}}, + }, + Report: dummyMouseReportDescriptor, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: 0x83, + BMAttributes: 0x03, + WMaxPacketSize: 0x0004, + BInterval: 0x0A, + }, + }, }, // Actual "controller" descriptor { From d8cd7c7f38aced6a7a1f1202e1f8546eb3fced24 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 20:44:07 +0100 Subject: [PATCH 102/298] Update README --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 75f81cd6..4796ccec 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio - ✅ Xbox 360 controller emulation; 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) + - ✅ Steam Deck (jupiter) controller emulation - 🔜 Xbox One / Series(?) controller emulation - 🔜 PS4 controller emulation - 🔜 ??? @@ -214,4 +215,15 @@ along with this program. If not, see . ## Credits / Inspiration -[REDACTED-Bus aka ViGEmBus](https://github.com/nefarius/ViGEmBus) - (Retired) Windows kernel-mode driver emulating well-known USB game controllers. +- [REDACTED-Bus aka ViGEmBus](https://github.com/nefarius/ViGEmBus) + (Retired, but still widely used) Windows kernel-mode driver emulating well-known USB game controllers + Shoutout and thank you to @nefarius for paving the way and always being a super decent guy! +- [Valve Software](https://www.valvesoftware.com/) + For creating the OG Steam Controller (2015) and Steam Input (and the way it, understandably, works...) + that sent me down this rabbit hole in the first place + I kinda hate you guys... in good way(?) ;) +- **USBIP** without VIIPER would not be possible. + - [USBIP](https://usbip.sourceforge.net/) + - [USBIP-Win2](https://github.com/vadimgrn/usbip-win2) +- [SDL](https://www.libsdl.org/) + For their excellent work on input device handling, reducing reversing efforts to a minimum. From 425c0fca0cf539e9e2761e283d7cff3db41d5daf Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 20:56:31 +0100 Subject: [PATCH 103/298] Fix SDK-Gen when using shift ops in device consts --- .../codegen/generator/csharp/constants.go | 51 ++++++++++++++++++- internal/codegen/scanner/constants.go | 51 ++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index e7d9861a..15665cc6 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -151,7 +151,56 @@ func shouldGenerateEnum(eg enumGroup) bool { } func inferEnumType(constants []constantInfo) string { - return "uint" + + max := uint64(0) + parsedAny := false + for _, c := range constants { + var v uint64 + var n int + var err error + if strings.HasPrefix(c.Value, "0x") { + n, err = fmt.Sscanf(c.Value, "0x%x", &v) + } else { + n, err = fmt.Sscanf(c.Value, "%d", &v) + } + if err == nil && n == 1 { + parsedAny = true + if v > max { + max = v + } + } + } + if parsedAny { + switch { + case max <= 0xFF: + return "byte" + case max <= 0xFFFF: + return "ushort" + case max <= 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 { diff --git a/internal/codegen/scanner/constants.go b/internal/codegen/scanner/constants.go index 46c26e60..84f3abc8 100644 --- a/internal/codegen/scanner/constants.go +++ b/internal/codegen/scanner/constants.go @@ -224,7 +224,56 @@ func extractValue(expr ast.Expr) interface{} { case *ast.Ident: return e.Name case *ast.BinaryExpr: - return fmt.Sprintf("%s %s %s", extractValue(e.X), e.Op.String(), extractValue(e.Y)) + lx := extractValue(e.X) + ry := extractValue(e.Y) + + toU64 := func(v interface{}) (uint64, bool) { + switch n := v.(type) { + case uint64: + return n, true + case int64: + if n < 0 { + return 0, false + } + return uint64(n), true + case int: + if n < 0 { + return 0, false + } + return uint64(n), true + default: + return 0, false + } + } + + lu, lok := toU64(lx) + ru, rok := toU64(ry) + if lok && rok { + switch e.Op { + case token.SHL: + if ru < 64 { + return lu << ru + } + case token.SHR: + if ru < 64 { + return lu >> ru + } + case token.OR: + return lu | ru + case token.AND: + return lu & ru + case token.XOR: + return lu ^ ru + case token.ADD: + return lu + ru + case token.SUB: + if lu >= ru { + return lu - ru + } + } + } + + return fmt.Sprintf("%v %s %v", lx, e.Op.String(), ry) case *ast.UnaryExpr: return fmt.Sprintf("%s%v", e.Op.String(), extractValue(e.X)) } From 406ce0f3fb9eb64360ef7434866d6dd22f54d847 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 21:08:11 +0100 Subject: [PATCH 104/298] Fix steamdeck viiper:wire annotations --- device/steamdeck/deviceState.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/device/steamdeck/deviceState.go b/device/steamdeck/deviceState.go index 481e79bc..5baf3ad6 100644 --- a/device/steamdeck/deviceState.go +++ b/device/steamdeck/deviceState.go @@ -12,7 +12,7 @@ import ( // This struct mirrors SDL's `SteamDeckStatePacket_t` fields (minus unPacketNum) // // Wire format (client -> device stream): fixed 52 bytes, little-endian. -// viiper:wire steamdeck c2s buttons:u64 lpX:i16 lpY:i16 rpX:i16 rpY:i16 ax:i16 ay:i16 az:i16 gx:i16 gy:i16 gz:i16 qw:i16 qx:i16 qy:i16 qz:i16 tl:u16 tr:u16 lsx:i16 lsy:i16 rsx:i16 rsy:i16 pl:u16 pr:u16 +// viiper:wire steamdeck c2s buttons:u64 leftPadX:i16 leftPadY:i16 rightPadX:i16 rightPadY:i16 accelX:i16 accelY:i16 accelZ:i16 gyroX:i16 gyroY:i16 gyroZ:i16 gyroQuatW:i16 gyroQuatX:i16 gyroQuatY:i16 gyroQuatZ:i16 triggerL:u16 triggerR:u16 leftStickX:i16 leftStickY:i16 rightStickX:i16 rightStickY:i16 pressurePadLeft:u16 pressurePadRight:u16 type InputState struct { Buttons uint64 @@ -151,6 +151,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { // HapticState is the client-facing representation of Steam Deck haptic feedback. // Values are 16-bit "motor" speeds as used by SDL's Steam Deck HID driver. +// viiper:wire steamdeck s2c leftMotor:u16 rightMotor:u16 type HapticState struct { LeftMotor uint16 RightMotor uint16 From e20e0dcadb0bfdd737e9e6c4cced67a6d58f4588 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 21:12:10 +0100 Subject: [PATCH 105/298] Update docs --- docs/devices/steamdeck.md | 35 +++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 36 insertions(+) create mode 100644 docs/devices/steamdeck.md diff --git a/docs/devices/steamdeck.md b/docs/devices/steamdeck.md new file mode 100644 index 00000000..f290590e --- /dev/null +++ b/docs/devices/steamdeck.md @@ -0,0 +1,35 @@ +# Steam Deck Controller (Jupiter) + +Steam Deck (Jupiter/LCD) virtual controller. + +- Device type id (for API add): `steamdeck` + +## Client library support + +All supported client libraries generate strongly-typed structs/classes for the Steam Deck wire protocol from the `viiper:wire` annotations in `/device/steamdeck/deviceState.go`. + +## Device stream protocol (client-facing) + +The device stream is a bidirectional, raw TCP connection. + +### Client → server (input) + +- Fixed **52-byte** packet, little-endian. +- Fields (in order): + - `buttons` (u64) + - `leftPadX`, `leftPadY`, `rightPadX`, `rightPadY` (i16) + - `accelX`, `accelY`, `accelZ` (i16) + - `gyroX`, `gyroY`, `gyroZ` (i16) + - `gyroQuatW`, `gyroQuatX`, `gyroQuatY`, `gyroQuatZ` (i16) + - `triggerRawL`, `triggerRawR` (u16) + - `leftStickX`, `leftStickY`, `rightStickX`, `rightStickY` (i16) + - `pressurePadLeft`, `pressurePadRight` (u16) + +### Server → client (haptics) + +- Fixed **4-byte** packet, little-endian. +- Fields: + - `leftMotor` (u16) + - `rightMotor` (u16) + +See: `/device/steamdeck/deviceState.go` and `/device/steamdeck/handler.go`. diff --git a/mkdocs.yml b/mkdocs.yml index c4c6cbbf..a7d6288a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - TypeScript Client Library: clients/typescript.md - Devices: - Xbox 360 Controller: devices/xbox360.md + - Steam Deck Controller: devices/steamdeck.md - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md - Changelog: changelog/ From 49dd96a31bcec235976d6aab2bed40a39fa0f9b9 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 21:35:32 +0100 Subject: [PATCH 106/298] Fix busCreate with id 0 not choosing next free bus Changelog(fix) --- internal/server/api/handler/bus_create.go | 5 +++++ internal/server/api/handler/bus_create_test.go | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go index 0f0ae211..96b67f33 100644 --- a/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -21,6 +21,11 @@ func BusCreate(s *usb.Server) api.HandlerFunc { if err != nil { return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } + + if busId == 0 { + busId = uint64(s.NextFreeBusID()) + } + b, err := virtualbus.NewWithBusId(uint32(busId)) if err != nil { return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) diff --git a/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go index adc9aef5..c09f4003 100644 --- a/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -63,6 +63,12 @@ func TestBusCreate(t *testing.T) { 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, From 376bbbf7ffe9ab7ec0e611bb35815ecbacd7209c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 22:24:44 +0100 Subject: [PATCH 107/298] Fix incorrect coverage reports --- .github/workflows/build_base.yml | 2 +- {testing => _testing}/e2e/bench_test.go | 0 {testing => _testing}/e2e/scripts/lat_bench.go | 12 ++++++------ {testing => _testing}/test_server.go | 0 {testing => _testing}/usbip_client.go | 0 apiclient/stream_test.go | 2 +- codecov.yml | 3 +-- device/device_attach_test.go | 2 +- device/keyboard/keyboard_test.go | 2 +- device/mouse/mouse_test.go | 2 +- device/steamdeck/steamdeck_test.go | 2 +- device/xbox360/xbox360_test.go | 2 +- docs/testing/e2e_latency.md | 2 +- internal/{testing => _testing}/api_test_helpers.go | 0 internal/{testing => _testing}/mocks.go | 0 internal/server/api/device_registry_test.go | 2 +- internal/server/api/device_stream_handler_test.go | 4 ++-- internal/server/api/handler/bus_create_test.go | 2 +- internal/server/api/handler/bus_device_add_test.go | 2 +- .../server/api/handler/bus_device_remove_test.go | 2 +- internal/server/api/handler/bus_devices_list_test.go | 2 +- internal/server/api/handler/bus_list_test.go | 2 +- internal/server/api/handler/bus_remove_test.go | 2 +- internal/server/api/handler/ping_test.go | 2 +- internal/server/api/server_test.go | 2 +- 25 files changed, 26 insertions(+), 27 deletions(-) rename {testing => _testing}/e2e/bench_test.go (100%) rename {testing => _testing}/e2e/scripts/lat_bench.go (95%) rename {testing => _testing}/test_server.go (100%) rename {testing => _testing}/usbip_client.go (100%) rename internal/{testing => _testing}/api_test_helpers.go (100%) rename internal/{testing => _testing}/mocks.go (100%) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index de7af010..eeaf4a45 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -47,7 +47,7 @@ jobs: fi - name: Run tests - run: go test -count=1 -v -coverprofile="coverage.txt" ./... + run: go test -count=1 -v -coverpkg="./..." -coverprofile="coverage.txt" ./... - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/testing/e2e/bench_test.go b/_testing/e2e/bench_test.go similarity index 100% rename from testing/e2e/bench_test.go rename to _testing/e2e/bench_test.go diff --git a/testing/e2e/scripts/lat_bench.go b/_testing/e2e/scripts/lat_bench.go similarity index 95% rename from testing/e2e/scripts/lat_bench.go rename to _testing/e2e/scripts/lat_bench.go index ff469884..830b3230 100644 --- a/testing/e2e/scripts/lat_bench.go +++ b/_testing/e2e/scripts/lat_bench.go @@ -8,22 +8,22 @@ package main // // Usage examples: // # Run benchmarks (count=5) and emit markdown -// go run ./testing/e2e/scripts/lat_bench.go -format markdown -count 5 > latency.md +// 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 +// 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 +// 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 +// 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 +// 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 +// go run ./_testing/e2e/scripts/lat_bench.go -format table # implicit -benchtime=1000x // // The tool always: // * Groups repeated benchmark cycles when count > 1 diff --git a/testing/test_server.go b/_testing/test_server.go similarity index 100% rename from testing/test_server.go rename to _testing/test_server.go diff --git a/testing/usbip_client.go b/_testing/usbip_client.go similarity index 100% rename from testing/usbip_client.go rename to _testing/usbip_client.go diff --git a/apiclient/stream_test.go b/apiclient/stream_test.go index c64f046e..faec318c 100644 --- a/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -12,11 +12,11 @@ import ( apitypes "github.com/Alia5/VIIPER/apitypes" "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" handler "github.com/Alia5/VIIPER/internal/server/api/handler" "github.com/Alia5/VIIPER/internal/server/usb" - htesting "github.com/Alia5/VIIPER/internal/testing" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/virtualbus" diff --git a/codecov.yml b/codecov.yml index 72256f7e..1d882aa6 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,4 @@ ignore: - "examples/go/**/*" - - "testing/**/*" - - "internal/testing/**/*" + - "**/_testing/**/*.go" - "cmd/viiper/**/*" diff --git a/device/device_attach_test.go b/device/device_attach_test.go index f54e3f04..ee29a226 100644 --- a/device/device_attach_test.go +++ b/device/device_attach_test.go @@ -11,7 +11,7 @@ import ( "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" - viiperTesting "github.com/Alia5/VIIPER/testing" + viiperTesting "github.com/Alia5/VIIPER/_testing" _ "github.com/Alia5/VIIPER/internal/registry" // Register devices ) diff --git a/device/keyboard/keyboard_test.go b/device/keyboard/keyboard_test.go index 529d270e..c466f6f7 100644 --- a/device/keyboard/keyboard_test.go +++ b/device/keyboard/keyboard_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" + viiperTesting "github.com/Alia5/VIIPER/_testing" "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/keyboard" "github.com/Alia5/VIIPER/internal/server/api" "github.com/Alia5/VIIPER/internal/server/api/handler" - viiperTesting "github.com/Alia5/VIIPER/testing" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" diff --git a/device/mouse/mouse_test.go b/device/mouse/mouse_test.go index e676fdde..f80a95f2 100644 --- a/device/mouse/mouse_test.go +++ b/device/mouse/mouse_test.go @@ -5,11 +5,11 @@ import ( "testing" "time" + viiperTesting "github.com/Alia5/VIIPER/_testing" "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/mouse" "github.com/Alia5/VIIPER/internal/server/api" "github.com/Alia5/VIIPER/internal/server/api/handler" - viiperTesting "github.com/Alia5/VIIPER/testing" "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" diff --git a/device/steamdeck/steamdeck_test.go b/device/steamdeck/steamdeck_test.go index bb6d6f4a..7830c896 100644 --- a/device/steamdeck/steamdeck_test.go +++ b/device/steamdeck/steamdeck_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" + viiperTesting "github.com/Alia5/VIIPER/_testing" "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/steamdeck" "github.com/Alia5/VIIPER/internal/server/api" "github.com/Alia5/VIIPER/internal/server/api/handler" - viiperTesting "github.com/Alia5/VIIPER/testing" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go index 61545798..783e35eb 100644 --- a/device/xbox360/xbox360_test.go +++ b/device/xbox360/xbox360_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" + viiperTesting "github.com/Alia5/VIIPER/_testing" "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/xbox360" "github.com/Alia5/VIIPER/internal/server/api" "github.com/Alia5/VIIPER/internal/server/api/handler" - viiperTesting "github.com/Alia5/VIIPER/testing" "github.com/Alia5/VIIPER/usbip" "github.com/Alia5/VIIPER/virtualbus" "github.com/stretchr/testify/assert" diff --git a/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 2d943b3d..033247d6 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -1,6 +1,6 @@ # 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). +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. diff --git a/internal/testing/api_test_helpers.go b/internal/_testing/api_test_helpers.go similarity index 100% rename from internal/testing/api_test_helpers.go rename to internal/_testing/api_test_helpers.go diff --git a/internal/testing/mocks.go b/internal/_testing/mocks.go similarity index 100% rename from internal/testing/mocks.go rename to internal/_testing/mocks.go diff --git a/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go index ead67f36..7c7a3408 100644 --- a/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/Alia5/VIIPER/device" + th "github.com/Alia5/VIIPER/internal/_testing" "github.com/Alia5/VIIPER/internal/server/api" - th "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/usb" ) diff --git a/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go index cc5ce925..e0a47b60 100644 --- a/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -12,11 +12,11 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/xbox360" + htesting "github.com/Alia5/VIIPER/internal/_testing" + th "github.com/Alia5/VIIPER/internal/_testing" "github.com/Alia5/VIIPER/internal/log" "github.com/Alia5/VIIPER/internal/server/api" srvusb "github.com/Alia5/VIIPER/internal/server/usb" - htesting "github.com/Alia5/VIIPER/internal/testing" - th "github.com/Alia5/VIIPER/internal/testing" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go index c09f4003..da6d00e6 100644 --- a/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/Alia5/VIIPER/apiclient" + 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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index 8378c343..2b62e3f0 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -12,11 +12,11 @@ import ( "github.com/Alia5/VIIPER/apiclient" "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" - th "github.com/Alia5/VIIPER/internal/testing" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_device_remove_test.go b/internal/server/api/handler/bus_device_remove_test.go index df6a4cff..ac58c794 100644 --- a/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -7,10 +7,10 @@ import ( "github.com/Alia5/VIIPER/apiclient" "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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go index 9b2449b4..996e8e7a 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -7,10 +7,10 @@ import ( "github.com/Alia5/VIIPER/apiclient" "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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_list_test.go b/internal/server/api/handler/bus_list_test.go index c3051d3c..ae16c83d 100644 --- a/internal/server/api/handler/bus_list_test.go +++ b/internal/server/api/handler/bus_list_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/Alia5/VIIPER/apiclient" + 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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go index d1b5032e..a2357635 100644 --- a/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -7,10 +7,10 @@ import ( "github.com/Alia5/VIIPER/apiclient" "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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" "github.com/Alia5/VIIPER/virtualbus" ) diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index af993b1c..e2e61e5b 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -8,10 +8,10 @@ import ( "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/apitypes" + 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" - handlerTest "github.com/Alia5/VIIPER/internal/testing" ) func TestPing(t *testing.T) { diff --git a/internal/server/api/server_test.go b/internal/server/api/server_test.go index 9c86a970..907c8a04 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -11,10 +11,10 @@ import ( "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" srvusb "github.com/Alia5/VIIPER/internal/server/usb" - th "github.com/Alia5/VIIPER/internal/testing" pusb "github.com/Alia5/VIIPER/usb" "github.com/Alia5/VIIPER/virtualbus" ) From 24f875fa35025c0134959687977c5aeac8faea0e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 25 Dec 2025 22:59:22 +0100 Subject: [PATCH 108/298] Update docs --- docs/index.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/index.md b/docs/index.md index 23d053f0..d515f984 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,24 +30,6 @@ For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -### ✨🛣️ Features / Roadmap - -- ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation; 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) - - 🔜 Xbox One / Series(?) controller emulation - - 🔜 PS4 controller emulation - - 🔜 ??? - 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) -- ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) -- ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) -- ✅ Cross-platform: works on Linux and Windows, **0** dependencies portable binary -- ✅ Flexible logging (including raw USB packet logs) -- ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) - MIT Licensed -- 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. - --- ## 🥫 Feeder application development From 86005ba5c7b969610cb271fb631fe34476010625 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 29 Dec 2025 16:11:56 +0100 Subject: [PATCH 109/298] Fix faulty steamdeck re-tx logic --- device/steamdeck/device.go | 48 ++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/device/steamdeck/device.go b/device/steamdeck/device.go index daf652af..5364f415 100644 --- a/device/steamdeck/device.go +++ b/device/steamdeck/device.go @@ -3,8 +3,10 @@ package steamdeck import ( "encoding/binary" + "log/slog" "sync" "sync/atomic" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -15,8 +17,9 @@ import ( type SteamDeck struct { tick uint64 - stateMu sync.Mutex - inputState *InputState + stateMu sync.Mutex + inputState *InputState + lastReportSent time.Time featureMu sync.Mutex featureResponse []byte // cached 65-byte (or 64-byte) feature response @@ -25,6 +28,8 @@ type SteamDeck struct { descriptor usb.Descriptor } +const USB_SEND_TIMEOUT_MS = 1000 + // New returns a new SteamDeck Controller device. func New(o *device.CreateOptions) *SteamDeck { d := &SteamDeck{ @@ -52,22 +57,11 @@ func (s *SteamDeck) SetRumbleCallback(f func(HapticState)) { // The latest state is used to build the 64-byte interrupt IN report. func (s *SteamDeck) UpdateInputState(st InputState) { s.stateMu.Lock() - if s.inputState == nil { - s.inputState = &InputState{} - } - *s.inputState = st + newState := st + s.inputState = &newState s.stateMu.Unlock() } -func (s *SteamDeck) getInputStateSnapshot() InputState { - s.stateMu.Lock() - defer s.stateMu.Unlock() - if s.inputState == nil { - return InputState{} - } - return *s.inputState -} - // HandleTransfer implements interrupt IN for the Steam Deck controller interface. func (s *SteamDeck) HandleTransfer(ep uint32, dir uint32, _ []byte) []byte { if dir != usbip.DirIn { @@ -83,9 +77,24 @@ func (s *SteamDeck) HandleTransfer(ep uint32, dir uint32, _ []byte) []byte { atomic.AddUint64(&s.tick, 1) return make([]byte, 4) case 1: // 0x81 - controller input reports - seq := uint32(atomic.AddUint64(&s.tick, 1)) - st := s.getInputStateSnapshot() - return buildInReport(seq, st) + s.stateMu.Lock() + if s.inputState != nil { + seq := uint32(atomic.AddUint64(&s.tick, 1)) + st := *s.inputState + s.inputState = nil + s.lastReportSent = time.Now() + s.stateMu.Unlock() + return buildInReport(seq, st) + } + if time.Since(s.lastReportSent) > USB_SEND_TIMEOUT_MS*time.Millisecond { + slog.Debug("SteamDeck input timeout, sending empty report") + seq := uint32(atomic.AddUint64(&s.tick, 1)) + s.lastReportSent = time.Now() + s.stateMu.Unlock() + return buildInReport(seq, InputState{}) + } + s.stateMu.Unlock() + return nil default: return nil } @@ -154,8 +163,7 @@ func (s *SteamDeck) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, switch reportType { case hidReportTypeInput: - st := s.getInputStateSnapshot() - report := buildInReport(0, st) + report := buildInReport(0, InputState{}) if want == 65 { resp := make([]byte, 65) resp[0] = 0x00 From 6a127a12f050fd3e42fb825fcd5a131c0c2ca9b6 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 30 Dec 2025 06:23:05 +0100 Subject: [PATCH 110/298] Fix logging --- internal/log/logging.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/log/logging.go b/internal/log/logging.go index a8aa8e75..841f3b74 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -58,6 +58,7 @@ func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { handlers = append(handlers, slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})) } logger := slog.New(MultiHandler{hs: handlers}) + slog.SetDefault(logger) return logger, closeFiles, nil } From 0a4258ae5c62f61bbfcfcac9e09b0f6850784408 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 30 Dec 2025 13:41:40 +0100 Subject: [PATCH 111/298] Remove steamdeck emulation :/ --- README.md | 1 - device/steamdeck/const.go | 99 ----- device/steamdeck/device.go | 558 ----------------------------- device/steamdeck/deviceState.go | 176 --------- device/steamdeck/handler.go | 66 ---- device/steamdeck/steamdeck_test.go | 321 ----------------- docs/devices/steamdeck.md | 35 -- examples/go/steam_deck/main.go | 126 ------- internal/registry/devices.go | 7 +- mkdocs.yml | 1 - 10 files changed, 3 insertions(+), 1387 deletions(-) delete mode 100644 device/steamdeck/const.go delete mode 100644 device/steamdeck/device.go delete mode 100644 device/steamdeck/deviceState.go delete mode 100644 device/steamdeck/handler.go delete mode 100644 device/steamdeck/steamdeck_test.go delete mode 100644 docs/devices/steamdeck.md delete mode 100644 examples/go/steam_deck/main.go diff --git a/README.md b/README.md index 4796ccec..e3151f01 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio - ✅ Xbox 360 controller emulation; 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) - - ✅ Steam Deck (jupiter) controller emulation - 🔜 Xbox One / Series(?) controller emulation - 🔜 PS4 controller emulation - 🔜 ??? diff --git a/device/steamdeck/const.go b/device/steamdeck/const.go deleted file mode 100644 index 35c49f65..00000000 --- a/device/steamdeck/const.go +++ /dev/null @@ -1,99 +0,0 @@ -package steamdeck - -const ( - ValveUSBVID = 0x28DE - JupiterPID = 0x1205 -) - -// (ApiCLient) Stream frame sizes. -const ( - InputStateSize = 52 - HapticStateSize = 4 -) - -// Valve/Steam Deck input report (interrupt IN) constants. -// -// SDL`controller_structs.h`. -const ( - ValveInReportMsgVersion uint16 = 0x0001 - - ValveInReportTypeControllerDeckState uint8 = 0x09 - ValveInReportLength uint8 = 64 - - ValveInReportHeaderSize = 4 - ValveInReportPacketNumOff = 4 - ValveInReportPayloadOff = 8 -) - -// Steam Deck feature report sizing. -// SDL uses 65-byte buffers on Windows: ReportID (0) + 64 bytes payload. -const ( - HIDFeatureReportBytes = 64 - HIDFeatureReportWinSize = 65 -) - -// Feature message IDs used by the Steam Deck controller protocol. -// Values from SDL's `steam/controller_constants.h`. -const ( - FeatureIDClearDigitalMappings = 0x81 - FeatureIDGetAttributesValues = 0x83 - FeatureIDSetSettingsValues = 0x87 - FeatureIDLoadDefaultSettings = 0x8E - FeatureIDGetStringAttribute = 0xAE - - FeatureIDTriggerHapticCmd = 0xEA - FeatureIDTriggerRumbleCmd = 0xEB -) - -// ControllerAttributes enum values (SDL `ControllerAttributes`). -const ( - AttribUniqueID = 0 - AttribProductID = 1 - AttribCapabilities = 2 - AttribFirmwareVersion = 3 - AttribFirmwareBuild = 4 -) - -// ControllerStringAttributes enum values (SDL `ControllerStringAttributes`). -const ( - AttribStrBoardSerial = 0 - AttribStrUnitSerial = 1 -) - -// Steam Deck button bitmasks. -// -// Values from SDL's `SDL_hidapi_steamdeck.c`. -const ( - ButtonR2 uint64 = 0x00000001 - ButtonL2 uint64 = 0x00000002 - ButtonRB uint64 = 0x00000004 - ButtonLB uint64 = 0x00000008 - - ButtonY uint64 = 0x00000010 - ButtonB uint64 = 0x00000020 - ButtonX uint64 = 0x00000040 - ButtonA uint64 = 0x00000080 - - ButtonDPadUp uint64 = 0x00000100 - ButtonDPadRight uint64 = 0x00000200 - ButtonDPadLeft uint64 = 0x00000400 - ButtonDPadDown uint64 = 0x00000800 - - ButtonView uint64 = 0x00001000 - ButtonSteam uint64 = 0x00002000 - ButtonMenu uint64 = 0x00004000 - - ButtonL5 uint64 = 0x00008000 - ButtonR5 uint64 = 0x00010000 - - ButtonLeftPadClick uint64 = 0x00020000 - ButtonRightPadClick uint64 = 0x00040000 - - ButtonL3 uint64 = 0x00400000 - ButtonR3 uint64 = 0x04000000 - - // High 32-bit button flags (ulButtonsH) shifted into the uint64. - ButtonL4 uint64 = 0x00000200 << 32 - ButtonR4 uint64 = 0x00000400 << 32 - ButtonQAM uint64 = 0x00040000 << 32 -) diff --git a/device/steamdeck/device.go b/device/steamdeck/device.go deleted file mode 100644 index 5364f415..00000000 --- a/device/steamdeck/device.go +++ /dev/null @@ -1,558 +0,0 @@ -// Package steamdeck provides a minimal Steam Deck (Jupiter/LCD) controller HID implementation. -package steamdeck - -import ( - "encoding/binary" - "log/slog" - "sync" - "sync/atomic" - "time" - - "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usb/hid" - "github.com/Alia5/VIIPER/usbip" -) - -type SteamDeck struct { - tick uint64 - - stateMu sync.Mutex - inputState *InputState - lastReportSent time.Time - - featureMu sync.Mutex - featureResponse []byte // cached 65-byte (or 64-byte) feature response - - hapticFunc func(HapticState) - descriptor usb.Descriptor -} - -const USB_SEND_TIMEOUT_MS = 1000 - -// New returns a new SteamDeck Controller device. -func New(o *device.CreateOptions) *SteamDeck { - d := &SteamDeck{ - descriptor: defaultDescriptor, - } - // Is any1 ever gonna use a patched SDL for this? Ignore custom VID/PIDS for now - // 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 -} - -// SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. -func (s *SteamDeck) SetRumbleCallback(f func(HapticState)) { - s.hapticFunc = f -} - -// UpdateInputState updates the controller's current input state (thread-safe). -// -// The latest state is used to build the 64-byte interrupt IN report. -func (s *SteamDeck) UpdateInputState(st InputState) { - s.stateMu.Lock() - newState := st - s.inputState = &newState - s.stateMu.Unlock() -} - -// HandleTransfer implements interrupt IN for the Steam Deck controller interface. -func (s *SteamDeck) HandleTransfer(ep uint32, dir uint32, _ []byte) []byte { - if dir != usbip.DirIn { - return nil - } - switch ep { - case 2: // 0x82 - dummy keyboard interface (iface 0) - // Keep Windows HID stack happy - // no actual lizard-mode behavior. - atomic.AddUint64(&s.tick, 1) - return make([]byte, 8) - case 3: // 0x83 - dummy mouse interface (iface 1) - atomic.AddUint64(&s.tick, 1) - return make([]byte, 4) - case 1: // 0x81 - controller input reports - s.stateMu.Lock() - if s.inputState != nil { - seq := uint32(atomic.AddUint64(&s.tick, 1)) - st := *s.inputState - s.inputState = nil - s.lastReportSent = time.Now() - s.stateMu.Unlock() - return buildInReport(seq, st) - } - if time.Since(s.lastReportSent) > USB_SEND_TIMEOUT_MS*time.Millisecond { - slog.Debug("SteamDeck input timeout, sending empty report") - seq := uint32(atomic.AddUint64(&s.tick, 1)) - s.lastReportSent = time.Now() - s.stateMu.Unlock() - return buildInReport(seq, InputState{}) - } - s.stateMu.Unlock() - return nil - default: - return nil - } -} - -// HandleControl implements EP0 class requests for HID feature reports. -// -// The Steam Deck Controlelr uses feature reports to disable lizard mode, feed -// a watchdog, and trigger rumble. -func (s *SteamDeck) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { - const ( - hidReqGetReport = 0x01 - hidReqSetReport = 0x09 - - hidReqTypeClassInToInterface = 0xA1 - hidReqTypeClassOutToInterface = 0x21 - - hidReportTypeInput = 0x01 - hidReportTypeOutput = 0x02 - hidReportTypeFeature = 0x03 - ) - - // Steam / SDL expect the controller interface iface 2 - // Our descriptor includes placeholder interfaces for kb/M and the actual controller - // HID interface at idx 2. - iface := uint8(wIndex & 0xFF) - - reportType := uint8(wValue >> 8) - // reportID := uint8(wValue & 0xFF) // We don't use report IDs (but may see 0). - _ = uint8(wValue & 0xFF) - - // HID-class handling for the dummy interfaces so Windows doesn't flag them as malfunctioning. - if iface == 0 || iface == 1 { - want := int(wLength) - switch { - case bmRequestType == hidReqTypeClassInToInterface && bRequest == hidReqGetReport: - if want <= 0 { - return nil, true - } - return make([]byte, want), true - case bmRequestType == hidReqTypeClassOutToInterface && (bRequest == hidReqSetReport || bRequest == 0x0A || bRequest == 0x0B): - return nil, true - case bmRequestType == hidReqTypeClassInToInterface && (bRequest == 0x02 || bRequest == 0x03): - if want <= 0 { - return nil, true - } - resp := make([]byte, want) - if bRequest == 0x03 { - resp[0] = 0x01 - } - return resp, true - } - return nil, false - } - - if iface != 2 { - return nil, false - } - - switch { - case bmRequestType == hidReqTypeClassInToInterface && bRequest == hidReqGetReport: - want := int(wLength) - if want <= 0 { - return nil, true - } - - switch reportType { - case hidReportTypeInput: - report := buildInReport(0, InputState{}) - if want == 65 { - resp := make([]byte, 65) - resp[0] = 0x00 - copy(resp[1:], report) - return resp, true - } - return report, true - case hidReportTypeFeature: - return s.getFeatureResponse(want), true - case hidReportTypeOutput: - return make([]byte, want), true - default: - return make([]byte, want), true - } - - case bmRequestType == hidReqTypeClassOutToInterface && bRequest == hidReqSetReport: - if reportType == hidReportTypeFeature { - s.handleFeatureReport(data) - return nil, true - } - // Ignore other report types for now, but ACK them - return nil, true - } - - return nil, false -} - -var dummyKeyboardReportDescriptor = hid.Report{ - Items: []hid.Item{ - hid.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: hid.UsageKeyboard}, - hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ - hid.UsagePage{Page: hid.UsagePageKeyboard}, - hid.UsageMinimum{Min: 0xE0}, - hid.UsageMaximum{Max: 0xE7}, - 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}, - - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 1}, - hid.Input{Flags: hid.MainConst}, - - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 255}, - hid.UsageMinimum{Min: 0x00}, - hid.UsageMaximum{Max: 0xFF}, - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 6}, - hid.Input{Flags: hid.MainData | hid.MainArray | hid.MainAbs}, - }}, - }, -} - -var dummyMouseReportDescriptor = hid.Report{ - Items: []hid.Item{ - hid.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: hid.UsageMouse}, - hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ - hid.Usage{Usage: hid.UsagePointer}, - hid.Collection{Kind: hid.CollectionPhysical, Items: []hid.Item{ - hid.UsagePage{Page: hid.UsagePageButton}, - hid.UsageMinimum{Min: 0x01}, - hid.UsageMaximum{Max: 0x03}, - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 1}, - hid.ReportSize{Bits: 1}, - hid.ReportCount{Count: 3}, - hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, - hid.ReportSize{Bits: 5}, - hid.ReportCount{Count: 1}, - hid.Input{Flags: hid.MainConst}, - - hid.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: hid.UsageX}, - hid.Usage{Usage: hid.UsageY}, - hid.Usage{Usage: hid.UsageWheel}, - hid.LogicalMinimum{Min: -127}, - hid.LogicalMaximum{Max: 127}, - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 3}, - hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainRel}, - }}, - }}, - }, -} - -func (s *SteamDeck) getFeatureResponse(want int) []byte { - if want <= 0 { - return nil - } - - s.featureMu.Lock() - resp := append([]byte(nil), s.featureResponse...) - s.featureMu.Unlock() - - if len(resp) == 0 { - return make([]byte, want) - } - - // If host asks for 64 bytes but we have a 65-byte report (ReportID + payload) return the payload - if want == 64 && len(resp) == 65 { - return append([]byte(nil), resp[1:]...) - } - if len(resp) >= want { - return append([]byte(nil), resp[:want]...) - } - out := make([]byte, want) - copy(out, resp) - return out -} - -func (s *SteamDeck) setFeatureResponse(resp []byte) { - s.featureMu.Lock() - defer s.featureMu.Unlock() - if resp == nil { - s.featureResponse = nil - return - } - s.featureResponse = append([]byte(nil), resp...) -} - -func (s *SteamDeck) handleFeatureReport(data []byte) { - if len(data) == 65 && data[0] == 0x00 { - data = data[1:] - } - if len(data) < 2 { - return - } - - msgType := data[0] - // msgLen := data[1] - _ = data[1] - - // If this request expects a response, queue it so the following GET_REPORT(feature) - // returns exactly what SDL's ReadResponse() expects. - switch msgType { - case FeatureIDGetAttributesValues: - s.setFeatureResponse(buildGetAttributesResponse()) - case FeatureIDGetStringAttribute: - var tag uint8 = AttribStrUnitSerial - if len(data) >= 3 { - // FeatureReportMsg header is [type,length], so first payload byte is data[2] - tag = data[2] - } - s.setFeatureResponse(buildGetStringAttributeResponse(tag)) - case FeatureIDClearDigitalMappings, FeatureIDSetSettingsValues, FeatureIDLoadDefaultSettings: - // No-op but report protocol-level success; queue an empty response matching - // the request ID so a follow-up GET_FEATURE doesn't fuck up SDL. - s.setFeatureResponse(buildEmptyResponse(msgType)) - } - - if msgType != FeatureIDTriggerRumbleCmd { - return - } - - // FeatureReportMsg: - // [0]=type, [1]=length, [2..]=payload - // MsgSimpleRumbleCmd payload starts at offset 2: - // [2]=unRumbleType - // [3:5]=unIntensity - // [5:7]=unLeftMotorSpeed - // [7:9]=unRightMotorSpeed - if len(data) < 9 { - return - } - left := binary.LittleEndian.Uint16(data[5:7]) - right := binary.LittleEndian.Uint16(data[7:9]) - - if s.hapticFunc != nil { - s.hapticFunc(HapticState{LeftMotor: left, RightMotor: right}) - } -} - -func buildEmptyResponse(msgType uint8) []byte { - resp := make([]byte, 65) - resp[0] = 0x00 - resp[1] = msgType - resp[2] = 0 // header.length - return resp -} - -func buildFeatureResponse(msgType uint8, payload []byte) []byte { - resp := make([]byte, 65) - resp[0] = 0x00 - resp[1] = msgType - if payload == nil { - resp[2] = 0 - return resp - } - if len(payload) > 62 { - payload = payload[:62] - } - resp[2] = uint8(len(payload)) - copy(resp[3:], payload) - return resp -} - -func buildGetAttributesResponse() []byte { - // copied from real device - const ( - productID = uint32(0x1205) - capabilities = uint32(0x0160BFFF) - firmwareVersion = uint32(1752616979) - firmwareBuildTime = uint32(1752616979) - ) - - payload := make([]byte, 0, 5*5) - appendAttr := func(tag uint8, value uint32) { - b := make([]byte, 5) - b[0] = tag - binary.LittleEndian.PutUint32(b[1:5], value) - payload = append(payload, b...) - } - appendAttr(AttribUniqueID, 0) - appendAttr(AttribProductID, productID) - appendAttr(AttribCapabilities, capabilities) - appendAttr(AttribFirmwareVersion, firmwareVersion) - appendAttr(AttribFirmwareBuild, firmwareBuildTime) - - return buildFeatureResponse(FeatureIDGetAttributesValues, payload) -} - -func buildGetStringAttributeResponse(requestedTag uint8) []byte { - // MsgGetStringAttribute: { uint8 attributeTag; char attributeValue[20]; } - payload := make([]byte, 21) - payload[0] = requestedTag - - const serial = "VIIPER000001" - copy(payload[1:], []byte(serial)) - - return buildFeatureResponse(FeatureIDGetStringAttribute, payload) -} - -func buildInReport(packetNum uint32, st InputState) []byte { - buf := make([]byte, 64) - // ValveInReportHeader_t (packed): - // uint16 unReportVersion (LE) - // uint8 ucType - // uint8 ucLength - buf[0] = byte(ValveInReportMsgVersion) - buf[1] = byte(ValveInReportMsgVersion >> 8) - buf[2] = ValveInReportTypeControllerDeckState - buf[3] = ValveInReportLength - binary.LittleEndian.PutUint32(buf[4:8], packetNum) - - payload, _ := st.MarshalBinary() // fixed-size, cannot fail - copy(buf[8:], payload) - return buf -} - -var defaultDescriptor = usb.Descriptor{ - Device: usb.DeviceDescriptor{ - BcdUSB: 0x0200, - BDeviceClass: 0x00, - BDeviceSubClass: 0x00, - BDeviceProtocol: 0x00, - BMaxPacketSize0: 0x40, - IDVendor: ValveUSBVID, - IDProduct: JupiterPID, - BcdDevice: 0x0200, - IManufacturer: 0x01, - IProduct: 0x02, - ISerialNumber: 0x03, - BNumConfigurations: 0x01, - Speed: 2, // Full speed - }, - // Steam's SDL Steam Controller driver expects the controller interface to be - // interface 2. - // add two dummy interfaces and ONLY emulate the actual - // "controller" *cough* device at iface 2 - // We can SKIP the actual "lizard-mode kb/m (dummies)" - // We can also SKIP the CDC interfaces - // they are (AFAIK) only used for FW updates - Interfaces: []usb.InterfaceConfig{ - { - // Placeholder interface 0 - // IS needed for steam to open the device - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, - BAlternateSetting: 0x00, - BNumEndpoints: 0x01, - BInterfaceClass: 0x03, - BInterfaceSubClass: 0x01, - BInterfaceProtocol: 0x01, - IInterface: 0x00, - }, - HID: &usb.HIDFunction{ - Descriptor: usb.HIDDescriptor{ - BcdHID: 0x0111, - BCountryCode: 0x00, - Descriptors: []usb.HIDSubDescriptor{{Type: usb.ReportDescType}}, - }, - Report: dummyKeyboardReportDescriptor, - }, - Endpoints: []usb.EndpointDescriptor{ - { - BEndpointAddress: 0x82, - BMAttributes: 0x03, - WMaxPacketSize: 0x0008, - BInterval: 0x0A, - }, - }, - }, - { - // Placeholder interface 1 - // IS needed for steam to open the device - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x01, - BAlternateSetting: 0x00, - BNumEndpoints: 0x01, - BInterfaceClass: 0x03, - BInterfaceSubClass: 0x01, - BInterfaceProtocol: 0x02, - IInterface: 0x00, - }, - HID: &usb.HIDFunction{ - Descriptor: usb.HIDDescriptor{ - BcdHID: 0x0111, - BCountryCode: 0x00, - Descriptors: []usb.HIDSubDescriptor{{Type: usb.ReportDescType}}, - }, - Report: dummyMouseReportDescriptor, - }, - Endpoints: []usb.EndpointDescriptor{ - { - BEndpointAddress: 0x83, - BMAttributes: 0x03, - WMaxPacketSize: 0x0004, - BInterval: 0x0A, - }, - }, - }, - // Actual "controller" descriptor - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x02, - BAlternateSetting: 0x00, - BNumEndpoints: 0x01, - BInterfaceClass: 0x03, - BInterfaceSubClass: 0x00, - BInterfaceProtocol: 0x00, - IInterface: 0x00, - }, - HID: &usb.HIDFunction{ - Descriptor: usb.HIDDescriptor{ - BcdHID: 0x0111, - BCountryCode: 0x00, - Descriptors: []usb.HIDSubDescriptor{ - {Type: usb.ReportDescType}, - }, - }, - Report: hid.Report{ - Items: []hid.Item{ - hid.UsagePage{Page: 0xFFFF}, - hid.Usage{Usage: 0x0001}, - hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 255}, - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 64}, - hid.Usage{Usage: 0x0001}, - hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, - hid.Usage{Usage: 0x0001}, - hid.Feature{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, - }}, - }, - }, - }, - Endpoints: []usb.EndpointDescriptor{ - { - BEndpointAddress: 0x81, - BMAttributes: 0x03, - WMaxPacketSize: 0x0040, - BInterval: 0x01, - }, - }, - }, - }, - Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) - 1: "Valve Software", - 2: "Steam Deck Controller", - 3: "VIIPER-Deck-Jupiter", - }, -} - -func (s *SteamDeck) GetDescriptor() *usb.Descriptor { - return &s.descriptor -} diff --git a/device/steamdeck/deviceState.go b/device/steamdeck/deviceState.go deleted file mode 100644 index 5baf3ad6..00000000 --- a/device/steamdeck/deviceState.go +++ /dev/null @@ -1,176 +0,0 @@ -package steamdeck - -import ( - "encoding/binary" - "fmt" - "io" -) - -// InputState is the client-facing input state for a Steam Deck (Jupiter/LCD) -// controller. -// -// This struct mirrors SDL's `SteamDeckStatePacket_t` fields (minus unPacketNum) -// -// Wire format (client -> device stream): fixed 52 bytes, little-endian. -// viiper:wire steamdeck c2s buttons:u64 leftPadX:i16 leftPadY:i16 rightPadX:i16 rightPadY:i16 accelX:i16 accelY:i16 accelZ:i16 gyroX:i16 gyroY:i16 gyroZ:i16 gyroQuatW:i16 gyroQuatX:i16 gyroQuatY:i16 gyroQuatZ:i16 triggerL:u16 triggerR:u16 leftStickX:i16 leftStickY:i16 rightStickX:i16 rightStickY:i16 pressurePadLeft:u16 pressurePadRight:u16 -type InputState struct { - Buttons uint64 - - LeftPadX int16 - LeftPadY int16 - RightPadX int16 - RightPadY int16 - - AccelX int16 - AccelY int16 - AccelZ int16 - - GyroX int16 - GyroY int16 - GyroZ int16 - - GyroQuatW int16 - GyroQuatX int16 - GyroQuatY int16 - GyroQuatZ int16 - - TriggerRawL uint16 - TriggerRawR uint16 - - LeftStickX int16 - LeftStickY int16 - - RightStickX int16 - RightStickY int16 - - PressurePadLeft uint16 - PressurePadRight uint16 -} - -// MarshalBinary encodes InputState to the fixed 52-byte wire format. -func (s InputState) MarshalBinary() ([]byte, error) { - b := make([]byte, InputStateSize) - o := 0 - - binary.LittleEndian.PutUint64(b[o:o+8], s.Buttons) - o += 8 - - putI16 := func(v int16) { - binary.LittleEndian.PutUint16(b[o:o+2], uint16(v)) - o += 2 - } - putU16 := func(v uint16) { - binary.LittleEndian.PutUint16(b[o:o+2], v) - o += 2 - } - - putI16(s.LeftPadX) - putI16(s.LeftPadY) - putI16(s.RightPadX) - putI16(s.RightPadY) - - putI16(s.AccelX) - putI16(s.AccelY) - putI16(s.AccelZ) - - putI16(s.GyroX) - putI16(s.GyroY) - putI16(s.GyroZ) - - putI16(s.GyroQuatW) - putI16(s.GyroQuatX) - putI16(s.GyroQuatY) - putI16(s.GyroQuatZ) - - putU16(s.TriggerRawL) - putU16(s.TriggerRawR) - - putI16(s.LeftStickX) - putI16(s.LeftStickY) - putI16(s.RightStickX) - putI16(s.RightStickY) - - putU16(s.PressurePadLeft) - putU16(s.PressurePadRight) - - return b, nil -} - -// UnmarshalBinary decodes InputState from the fixed 52-byte wire format. -func (s *InputState) UnmarshalBinary(data []byte) error { - if len(data) < InputStateSize { - return io.ErrUnexpectedEOF - } - o := 0 - - s.Buttons = binary.LittleEndian.Uint64(data[o : o+8]) - o += 8 - - getI16 := func() int16 { - v := int16(binary.LittleEndian.Uint16(data[o : o+2])) - o += 2 - return v - } - getU16 := func() uint16 { - v := binary.LittleEndian.Uint16(data[o : o+2]) - o += 2 - return v - } - - s.LeftPadX = getI16() - s.LeftPadY = getI16() - s.RightPadX = getI16() - s.RightPadY = getI16() - - s.AccelX = getI16() - s.AccelY = getI16() - s.AccelZ = getI16() - - s.GyroX = getI16() - s.GyroY = getI16() - s.GyroZ = getI16() - - s.GyroQuatW = getI16() - s.GyroQuatX = getI16() - s.GyroQuatY = getI16() - s.GyroQuatZ = getI16() - - s.TriggerRawL = getU16() - s.TriggerRawR = getU16() - - s.LeftStickX = getI16() - s.LeftStickY = getI16() - s.RightStickX = getI16() - s.RightStickY = getI16() - - s.PressurePadLeft = getU16() - s.PressurePadRight = getU16() - - return nil -} - -// HapticState is the client-facing representation of Steam Deck haptic feedback. -// Values are 16-bit "motor" speeds as used by SDL's Steam Deck HID driver. -// viiper:wire steamdeck s2c leftMotor:u16 rightMotor:u16 -type HapticState struct { - LeftMotor uint16 - RightMotor uint16 -} - -// MarshalBinary encodes HapticState as 4 bytes (2x uint16 little-endian). -func (r HapticState) MarshalBinary() ([]byte, error) { - b := make([]byte, 4) - binary.LittleEndian.PutUint16(b[0:2], r.LeftMotor) - binary.LittleEndian.PutUint16(b[2:4], r.RightMotor) - return b, nil -} - -// UnmarshalBinary decodes HapticState from 4 bytes (2x uint16 little-endian). -func (r *HapticState) UnmarshalBinary(data []byte) error { - if len(data) < 4 { - return fmt.Errorf("Invalid haptic packet len, got %d, want 4", len(data)) - } - r.LeftMotor = binary.LittleEndian.Uint16(data[0:2]) - r.RightMotor = binary.LittleEndian.Uint16(data[2:4]) - return nil -} diff --git a/device/steamdeck/handler.go b/device/steamdeck/handler.go deleted file mode 100644 index 41915e3a..00000000 --- a/device/steamdeck/handler.go +++ /dev/null @@ -1,66 +0,0 @@ -package steamdeck - -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("steamdeck", &handler{}) -} - -type handler struct{} - -func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { 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") - } - sdev, ok := (*devPtr).(*SteamDeck) - if !ok { - return fmt.Errorf("device is not steamdeck") - } - - // Device -> client: rumble feedback (2x uint16 little-endian) - sdev.SetRumbleCallback(func(r HapticState) { - data, err := r.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) - } - }) - - // Client -> device: raw 64-byte controller reports - // Client -> device: InputState frames (fixed-size, little-endian) - 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) - } - - // Copy buf because ReadFull reuses it. - frame := make([]byte, InputStateSize) - copy(frame, buf) - var st InputState - if err := st.UnmarshalBinary(frame); err != nil { - return fmt.Errorf("decode input state: %w", err) - } - sdev.UpdateInputState(st) - } - } -} diff --git a/device/steamdeck/steamdeck_test.go b/device/steamdeck/steamdeck_test.go deleted file mode 100644 index 7830c896..00000000 --- a/device/steamdeck/steamdeck_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package steamdeck_test - -import ( - "context" - "encoding/binary" - "io" - "math" - "testing" - "time" - - viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device/steamdeck" - "github.com/Alia5/VIIPER/internal/server/api" - "github.com/Alia5/VIIPER/internal/server/api/handler" - "github.com/Alia5/VIIPER/usbip" - "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 steamdeck.InputState - expectedReport []byte - } - - cases := []testCase{ - { - name: "no inputs", - inputState: steamdeck.InputState{}, - expectedReport: []byte{ - 0x01, 0x00, 0x09, 0x40, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - { - name: "buttons a+b", - inputState: steamdeck.InputState{ - Buttons: steamdeck.ButtonA | steamdeck.ButtonB, - }, - expectedReport: []byte{ - 0x01, 0x00, 0x09, 0x40, 0x00, 0x00, 0x00, 0x00, - 0xA0, 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, 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, 0x00, - }, - }, - { - name: "left stick only", - inputState: steamdeck.InputState{ - LeftStickX: 1234, - LeftStickY: -2345, - }, - expectedReport: []byte{ - 0x01, 0x00, 0x09, 0x40, 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, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xD2, 0x04, 0xD7, 0xF6, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - { - name: "buttons and left stick", - inputState: steamdeck.InputState{ - Buttons: steamdeck.ButtonDPadUp | steamdeck.ButtonSteam, - LeftStickX: -32768, - LeftStickY: 32767, - }, - expectedReport: []byte{ - 0x01, 0x00, 0x09, 0x40, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x21, 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, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - } - - s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() - - 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() - _ = s.UsbServer.AddBus(b) - - client := apiclient.New(s.ApiServer.Addr()) - stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "steamdeck", nil) - if !assert.NoError(t, err) { - return - } - defer stream.Close() - - 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() - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Len(t, tc.expectedReport, int(steamdeck.ValveInReportLength)) - assert.Equal(t, byte(steamdeck.ValveInReportMsgVersion), tc.expectedReport[0]) - assert.Equal(t, byte(steamdeck.ValveInReportMsgVersion>>8), tc.expectedReport[1]) - assert.Equal(t, steamdeck.ValveInReportTypeControllerDeckState, tc.expectedReport[2]) - assert.Equal(t, steamdeck.ValveInReportLength, tc.expectedReport[3]) - - if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { - return - } - - deadline := time.Now().Add(750 * time.Millisecond) - var last []byte - for { - got, err := usbipClient.ReadInputReport(imp.Conn) - if !assert.NoError(t, err) { - return - } - last = got - if steamDeckReportsEqualIgnoringPacketCounter(tc.expectedReport, got) { - break - } - if time.Now().After(deadline) { - assert.Failf(t, "timed out waiting for matching report", "last=%x want=%x", last, tc.expectedReport) - return - } - time.Sleep(1 * time.Millisecond) - } - }) - } - -} - -func TestHaptics(t *testing.T) { - - type testCase struct { - name string - hapticState steamdeck.HapticState - outPacket []byte - } - cases := []testCase{ - { - name: "off", - hapticState: steamdeck.HapticState{ - LeftMotor: 0, - RightMotor: 0, - }, - outPacket: []byte{ - 0x00, 0xEB, 0x07, 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, - 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, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - { - name: "mid", - hapticState: steamdeck.HapticState{ - LeftMotor: math.MaxUint16 / 2, - RightMotor: math.MaxUint16 / 2, - }, - outPacket: []byte{ - 0x00, 0xEB, 0x07, 0x00, 0x00, 0x00, 0xFF, 0x7F, 0xFF, 0x7F, - 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, 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, 0x00, - }, - }, - { - name: "full", - hapticState: steamdeck.HapticState{ - LeftMotor: math.MaxUint16, - RightMotor: math.MaxUint16, - }, - outPacket: []byte{ - 0x00, 0xEB, 0x07, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, - 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, 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, 0x00, - }, - }, - } - - s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() - - 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() - _ = s.UsbServer.AddBus(b) - - client := apiclient.New(s.ApiServer.Addr()) - stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "steamdeck", nil) - if !assert.NoError(t, err) { - return - } - defer stream.Close() - - 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() - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - setup := steamDeckSetReportFeatureSetup(uint16(len(tc.outPacket))) - if !assert.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 0, tc.outPacket, &setup)) { - return - } - var buf [4]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) - _, err := io.ReadFull(stream, buf[:]) - if !assert.NoError(t, err) { - return - } - got := steamdeck.HapticState{ - LeftMotor: binary.LittleEndian.Uint16(buf[0:2]), - RightMotor: binary.LittleEndian.Uint16(buf[2:4]), - } - assert.Equal(t, tc.hapticState, got) - }) - } - -} - -func steamDeckReportsEqualIgnoringPacketCounter(want, got []byte) bool { - if len(want) != len(got) { - return false - } - for i := range want { - if i >= steamdeck.ValveInReportPacketNumOff && i < steamdeck.ValveInReportPayloadOff { - continue - } - if want[i] != got[i] { - return false - } - } - return true -} - -func steamDeckSetReportFeatureSetup(wLength uint16) [8]byte { - - var setup [8]byte - setup[0] = 0x21 - setup[1] = 0x09 - binary.LittleEndian.PutUint16(setup[2:4], 0x0300) - binary.LittleEndian.PutUint16(setup[4:6], 0x0002) - binary.LittleEndian.PutUint16(setup[6:8], wLength) - return setup -} diff --git a/docs/devices/steamdeck.md b/docs/devices/steamdeck.md deleted file mode 100644 index f290590e..00000000 --- a/docs/devices/steamdeck.md +++ /dev/null @@ -1,35 +0,0 @@ -# Steam Deck Controller (Jupiter) - -Steam Deck (Jupiter/LCD) virtual controller. - -- Device type id (for API add): `steamdeck` - -## Client library support - -All supported client libraries generate strongly-typed structs/classes for the Steam Deck wire protocol from the `viiper:wire` annotations in `/device/steamdeck/deviceState.go`. - -## Device stream protocol (client-facing) - -The device stream is a bidirectional, raw TCP connection. - -### Client → server (input) - -- Fixed **52-byte** packet, little-endian. -- Fields (in order): - - `buttons` (u64) - - `leftPadX`, `leftPadY`, `rightPadX`, `rightPadY` (i16) - - `accelX`, `accelY`, `accelZ` (i16) - - `gyroX`, `gyroY`, `gyroZ` (i16) - - `gyroQuatW`, `gyroQuatX`, `gyroQuatY`, `gyroQuatZ` (i16) - - `triggerRawL`, `triggerRawR` (u16) - - `leftStickX`, `leftStickY`, `rightStickX`, `rightStickY` (i16) - - `pressurePadLeft`, `pressurePadRight` (u16) - -### Server → client (haptics) - -- Fixed **4-byte** packet, little-endian. -- Fields: - - `leftMotor` (u16) - - `rightMotor` (u16) - -See: `/device/steamdeck/deviceState.go` and `/device/steamdeck/handler.go`. diff --git a/examples/go/steam_deck/main.go b/examples/go/steam_deck/main.go deleted file mode 100644 index da5e10d0..00000000 --- a/examples/go/steam_deck/main.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "context" - "fmt" - "math" - "os" - "os/signal" - "syscall" - "time" - - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/device/steamdeck" -) - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: steamdeck_client ") - fmt.Println("Example: steamdeck_client localhost:3242") - os.Exit(1) - } - - addr := os.Args[1] - ctx := context.Background() - api := apiclient.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, "steamdeck", nil) - if err != nil { - fmt.Printf("AddDeviceAndConnect error: %v\n", err) - if createdBus { - _, _ = api.BusRemoveCtx(ctx, busID) - } - os.Exit(1) - } - defer stream.Close() - fmt.Println("Added device", addResp) - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - - ticker := time.NewTicker(16 * time.Millisecond) - defer ticker.Stop() - - var frame uint64 - for { - select { - case <-ticker.C: - frame++ - var buttons uint64 - switch (frame / 60) % 4 { - case 0: - buttons = steamdeck.ButtonA - case 1: - buttons = 0 - case 2: - buttons = steamdeck.ButtonX - default: - buttons = steamdeck.ButtonY - } - - inputState := &steamdeck.InputState{ - Buttons: buttons, - PressurePadLeft: 0, - PressurePadRight: 0, - LeftPadX: 0, - LeftPadY: 0, - RightPadX: 0, - RightPadY: 0, - LeftStickX: 0, - LeftStickY: 0, - RightStickX: 0, - RightStickY: 0, - AccelX: 0, - AccelY: 0, - AccelZ: 0, - GyroX: 0, - GyroY: 0, - GyroZ: 0, - GyroQuatW: 0, - GyroQuatX: 0, - GyroQuatY: 0, - GyroQuatZ: 0, - TriggerRawL: uint16((frame*20)%math.MaxUint16 + 1), - TriggerRawR: uint16((frame*20)%math.MaxUint16 + 1), - } - if err := stream.WriteBinary(inputState); 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, inputState.Buttons, inputState.TriggerRawL, inputState.TriggerRawR) - } - - case <-sigCh: - fmt.Println("Signal received, stopping…") - return - } - } - -} diff --git a/internal/registry/devices.go b/internal/registry/devices.go index dcacced5..904ad743 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -1,8 +1,7 @@ package registry import ( - _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler - _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler - _ "github.com/Alia5/VIIPER/device/steamdeck" // Register steamdeck device handler - _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler + _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler + _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler + _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler ) diff --git a/mkdocs.yml b/mkdocs.yml index a7d6288a..c4c6cbbf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,7 +77,6 @@ nav: - TypeScript Client Library: clients/typescript.md - Devices: - Xbox 360 Controller: devices/xbox360.md - - Steam Deck Controller: devices/steamdeck.md - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md - Changelog: changelog/ From 05846e9b629ae585c0cd99d0a9bd5fafcfeb68c6 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 9 Jan 2026 11:20:33 +0100 Subject: [PATCH 112/298] Windows: Install-Script: Fix updating Changelog(fix) --- scripts/install.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 26a4a4cb..4c908717 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -99,6 +99,7 @@ try { } catch { } } + Start-Sleep -Milliseconds 500 } } From 36ae526211b9c94ec2e7d04799aa28f894f9a7fa Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 9 Jan 2026 11:32:01 +0100 Subject: [PATCH 113/298] Windows: log to file in autorun config Changelog(misc) --- internal/cmd/install_windows.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 5e1e450a..0e94585a 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/Alia5/VIIPER/internal/configpaths" "golang.org/x/sys/windows/registry" ) @@ -33,7 +34,16 @@ func install(logger *slog.Logger) error { return err } - value := fmt.Sprintf("\"%s\" server", exePath) + 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 @@ -50,11 +60,11 @@ func install(logger *slog.Logger) error { } } - if err := exec.Command(exePath, "server").Start(); err != nil { + 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) + logger.Info("VIIPER install completed for Windows autorun", "exe", exePath, "logFile", logFile) return nil } From 9ccf21c7fe088b3e7473f58239f9ab85d560a536 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 13 Jan 2026 15:24:18 +0100 Subject: [PATCH 114/298] Return API err if autoattach enabled and fails --- internal/server/api/handler/bus_device_add.go | 3 ++ .../server/api/handler/bus_device_add_test.go | 49 ++++++++++++++++--- internal/server/api/server.go | 7 +-- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 92c27eaa..d18be6a8 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -93,6 +93,9 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { if err != nil { logger.Error("failed to auto-attach localhost client", "error", err) } + return api.ErrConflict(fmt.Sprintf( + "Failed to auto-attach device: %v", err, + )) } payload, err := json.Marshal(apitypes.Device{ diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index 2b62e3f0..6a220bcd 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -1,6 +1,7 @@ package handler_test import ( + "encoding/json" "log/slog" "net" "testing" @@ -24,14 +25,15 @@ import ( func TestBusDeviceAdd(t *testing.T) { tests := []struct { name string - setup func(t *testing.T, s *usb.Server) + 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) { + 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) @@ -60,7 +62,7 @@ func TestBusDeviceAdd(t *testing.T) { }, { name: "invalid json", - setup: func(t *testing.T, s *usb.Server) { + 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) @@ -75,7 +77,7 @@ func TestBusDeviceAdd(t *testing.T) { }, { name: "invalid payload", - setup: func(t *testing.T, s *usb.Server) { + 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) @@ -90,7 +92,7 @@ func TestBusDeviceAdd(t *testing.T) { }, { name: "correct device id after add/remove", - setup: func(t *testing.T, s *usb.Server) { + 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) @@ -109,23 +111,56 @@ func TestBusDeviceAdd(t *testing.T) { payload: `{"type": "xbox360"}`, expectedResponse: `{"busId":80005, "devId": "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 := apiclient.NewTransport(addr) if tt.setup != nil { - tt.setup(t, srv) + tt.setup(t, srv, as) } line, err := c.Do("bus/{id}/add", tt.payload, tt.pathParams) assert.NoError(t, err) - assert.JSONEq(t, tt.expectedResponse, line) + + if tt.expectedResponse != "" { + assert.JSONEq(t, tt.expectedResponse, line) + } + + if tt.extraChecks != nil { + tt.extraChecks(t, line, srv, as) + } }) } } diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 53297e6e..03cf86d6 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -25,16 +25,17 @@ type Server struct { ln net.Listener logger *slog.Logger router *Router - config ServerConfig + 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 { + cfg := config a := &Server{ usbs: s, addr: addr, logger: logger, - config: config, + config: &cfg, } a.router = NewRouter() return a @@ -47,7 +48,7 @@ func (a *Server) Router() *Router { return a.router } func (a *Server) USB() *usb.Server { return a.usbs } // Config returns the server configuration. -func (a *Server) Config() ServerConfig { return a.config } +func (a *Server) Config() *ServerConfig { return a.config } // Addr returns the actual address the server is listening on. // If Start hasn't been called yet, it returns the configured address. From f17e92b5d059f74975a51bd0652fc9415a60a69f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 14 Jan 2026 12:06:25 +0100 Subject: [PATCH 115/298] Fix "typo" --- internal/server/api/handler/bus_device_add.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index d18be6a8..2b7200ed 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -92,10 +92,10 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { ) if err != nil { logger.Error("failed to auto-attach localhost client", "error", err) + return api.ErrConflict(fmt.Sprintf( + "Failed to auto-attach device: %v", err, + )) } - return api.ErrConflict(fmt.Sprintf( - "Failed to auto-attach device: %v", err, - )) } payload, err := json.Marshal(apitypes.Device{ From b18bcbd52d7584b9f901f00db46cb69496e5e0b2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 14 Jan 2026 12:10:14 +0100 Subject: [PATCH 116/298] DS4 Emulation Changelog(feat) --- README.md | 2 +- device/dualshock4/const.go | 132 ++++ device/dualshock4/device.go | 400 ++++++++++ device/dualshock4/dualshock4_test.go | 471 ++++++++++++ device/dualshock4/handler.go | 60 ++ device/dualshock4/helpers.go | 41 ++ device/dualshock4/inputstate.go | 120 +++ docs/clients/generator.md | 1 - docs/devices/dualshock4.md | 131 ++++ examples/go/virtual_ds4/main.go | 165 +++++ examples/go/virtual_ds4_cli/main.go | 682 ++++++++++++++++++ internal/codegen/generator/c/header_common.go | 1 + internal/codegen/generator/c/header_device.go | 2 +- internal/codegen/generator/c/helpers.go | 42 ++ .../codegen/generator/csharp/constants.go | 102 ++- internal/codegen/generator/rust/constants.go | 26 + internal/registry/devices.go | 7 +- mkdocs.yml | 1 + 18 files changed, 2358 insertions(+), 28 deletions(-) create mode 100644 device/dualshock4/const.go create mode 100644 device/dualshock4/device.go create mode 100644 device/dualshock4/dualshock4_test.go create mode 100644 device/dualshock4/handler.go create mode 100644 device/dualshock4/helpers.go create mode 100644 device/dualshock4/inputstate.go create mode 100644 docs/devices/dualshock4.md create mode 100644 examples/go/virtual_ds4/main.go create mode 100644 examples/go/virtual_ds4_cli/main.go diff --git a/README.md b/README.md index e3151f01..ecf31fe1 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio - ✅ Xbox 360 controller emulation; 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) + - ✅ PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) - 🔜 Xbox One / Series(?) controller emulation - - 🔜 PS4 controller emulation - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) diff --git a/device/dualshock4/const.go b/device/dualshock4/const.go new file mode 100644 index 00000000..d790a36a --- /dev/null +++ b/device/dualshock4/const.go @@ -0,0 +1,132 @@ +package dualshock4 + +const ( + DefaultVID = 0x054C + DefaultPID = 0x05C4 +) + +const ( + EndpointIn = 0x84 + EndpointOut = 0x03 +) + +const ( + ReportIDInput = 0x01 + ReportIDOutput = 0x05 + ReportIDFeature = 0x02 +) + +const ( + InputReportSize = 64 + OutputReportSize = 32 +) + +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 + + CounterMask = 0xFC + 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 ( + TouchpadMinX uint16 = 0 + TouchpadMaxX uint16 = 1920 + TouchpadMinY uint16 = 0 + TouchpadMaxY uint16 = 942 + + TouchInactiveMask uint8 = 0x80 +) + +const ( + BatteryLevelMask = 0x0F + BatteryChargingFlag = 0x10 + BatteryFullyCharged = 0x0B + BatteryDefault = 0x1B +) + +const ( + OutOffsetReportID = 0 + OutOffsetFlags = 1 + OutOffsetRumbleSmall = 4 + OutOffsetRumbleLarge = 5 + OutOffsetLedRed = 6 + OutOffsetLedGreen = 7 + OutOffsetLedBlue = 8 + OutOffsetFlashOn = 9 // Flash on time (units of 2.5ms) + OutOffsetFlashOff = 10 // Flash off time (units of 2.5ms) +) + +const ( + DefaultLedRed = 0x00 + DefaultLedGreen = 0x00 + DefaultLedBlue = 0x40 +) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go new file mode 100644 index 00000000..c18c8ce0 --- /dev/null +++ b/device/dualshock4/device.go @@ -0,0 +1,400 @@ +package dualshock4 + +import ( + "encoding/binary" + "log/slog" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usb/hid" + "github.com/Alia5/VIIPER/usbip" +) + +type DualShock4 struct { + inputState *InputState + stateMu sync.Mutex + outputFunc func(OutputState) + descriptor usb.Descriptor + + usbReportTimestamp uint32 + usbPacketCounter uint32 +} + +func New(o *device.CreateOptions) *DualShock4 { + d := &DualShock4{ + 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.inputState = &InputState{ + LX: 0, + LY: 0, + 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: DefaultAccelXRaw, + AccelY: DefaultAccelYRaw, + AccelZ: DefaultAccelZRaw, + } + + return d +} + +func (d *DualShock4) SetOutputCallback(f func(OutputState)) { + d.outputFunc = f +} + +func (d *DualShock4) UpdateInputState(state *InputState) { + d.stateMu.Lock() + defer d.stateMu.Unlock() + d.inputState = state +} + +func (d *DualShock4) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { + if dir == usbip.DirIn { + switch ep { + case 4: + d.stateMu.Lock() + st := *d.inputState + d.stateMu.Unlock() + return d.buildUSBInputReport(st) + default: + return nil + } + } + + if dir == usbip.DirOut && ep == 3 { + if len(out) >= 11 && out[OutOffsetReportID] == ReportIDOutput { + feedback := OutputState{ + RumbleSmall: out[OutOffsetRumbleSmall], + RumbleLarge: out[OutOffsetRumbleLarge], + LedRed: out[OutOffsetLedRed], + LedGreen: out[OutOffsetLedGreen], + LedBlue: out[OutOffsetLedBlue], + FlashOn: out[OutOffsetFlashOn], + FlashOff: out[OutOffsetFlashOff], + } + if d.outputFunc != nil { + d.outputFunc(feedback) + } + } + } + + return nil +} + +func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, _ /* wIndex */, wLength uint16, data []byte) ([]byte, bool) { + const ( + hidGetReport = 0x01 + hidSetReport = 0x09 + ) + + const ( + reportTypeInput = 0x01 + reportTypeOutput = 0x02 + reportTypeFeature = 0x03 + ) + + reportType := uint8(wValue >> 8) + reportID := uint8(wValue & 0xFF) + + if bmRequestType == 0xA1 && bRequest == hidGetReport { + if reportType == reportTypeInput && reportID == ReportIDInput { + d.stateMu.Lock() + st := *d.inputState + d.stateMu.Unlock() + report := d.buildUSBInputReport(st) + if wLength > 0 && int(wLength) < len(report) { + return report[:wLength], true + } + return report, true + } + + if reportType == reportTypeFeature { + switch reportID { + case 0x02: // Gyro calibration + return make([]byte, 37), true + case 0x03: // Device capabilities + return make([]byte, 48), true + case 0x05: // Gyro calibration + return make([]byte, 41), true + case 0x12: // Serial number + return make([]byte, 16), true + } + } + } + + if bmRequestType == 0x21 && bRequest == hidSetReport { + if reportType == reportTypeOutput && reportID == ReportIDOutput && len(data) >= 11 { + feedback := OutputState{ + RumbleSmall: data[OutOffsetRumbleSmall], + RumbleLarge: data[OutOffsetRumbleLarge], + LedRed: data[OutOffsetLedRed], + LedGreen: data[OutOffsetLedGreen], + LedBlue: data[OutOffsetLedBlue], + FlashOn: data[OutOffsetFlashOn], + FlashOff: data[OutOffsetFlashOff], + } + if d.outputFunc != nil { + d.outputFunc(feedback) + } + return nil, true + } + } + + slog.Warn("Unsupported control request", + "bmRequestType", bmRequestType, + "bRequest", bRequest) + + return nil, false +} + +func (d *DualShock4) GetDescriptor() *usb.Descriptor { + return &d.descriptor +} + +func (d *DualShock4) buildUSBInputReport(s InputState) []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) + if s.DPad&DPadUp != 0 && s.DPad&DPadRight != 0 { + usbDPad = DPadUSBUpRight + } else if s.DPad&DPadUp != 0 && s.DPad&DPadLeft != 0 { + usbDPad = DPadUSBUpLeft + } else if s.DPad&DPadDown != 0 && s.DPad&DPadRight != 0 { + usbDPad = DPadUSBDownRight + } else if s.DPad&DPadDown != 0 && s.DPad&DPadLeft != 0 { + usbDPad = DPadUSBDownLeft + } else if s.DPad&DPadUp != 0 { + usbDPad = DPadUSBUp + } else if s.DPad&DPadDown != 0 { + usbDPad = DPadUSBDown + } else if s.DPad&DPadLeft != 0 { + usbDPad = DPadUSBLeft + } else if 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< TouchpadMaxX { + x = TouchpadMaxX + } + if y > TouchpadMaxY { + y = TouchpadMaxY + } + + b[0] = uint8(x & 0xFF) + b[1] = uint8((x>>8)&0x0F) | uint8((y&0x0F)<<4) + b[2] = uint8(y >> 4) +} + +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, + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + 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}, + }, + }, + Report: hid.Report{ + Items: []hid.Item{ + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: hid.UsageGamePad}, + hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ + + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x08, Data: hid.Data{0x01}}, + + 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.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: 0x39}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 7}, + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x3, Data: hid.Data{0x00}}, + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x4, Data: hid.Data{0x3B, 0x01}}, + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x6, Data: hid.Data{0x14}}, + hid.ReportSize{Bits: 4}, + hid.ReportCount{Count: 1}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs | hid.MainNullState}, + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x6, Data: hid.Data{0x00}}, + + hid.UsagePage{Page: hid.UsagePageButton}, + hid.UsageMinimum{Min: 0x01}, + hid.UsageMaximum{Max: 0x0E}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 1}, + hid.ReportCount{Count: 14}, + hid.ReportSize{Bits: 1}, + 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.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.UsagePage{Page: hid.UsagePageGenericDesktop}, + hid.Usage{Usage: 0x32}, + hid.Usage{Usage: 0x35}, + 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: 0x20}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 54}, + hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, + + hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x08, Data: hid.Data{0x05}}, + + hid.UsagePage{Page: 0xFF00}, + hid.Usage{Usage: 0x21}, + hid.LogicalMinimum{Min: 0}, + hid.LogicalMaximum{Max: 255}, + hid.ReportSize{Bits: 8}, + hid.ReportCount{Count: 31}, + hid.Output{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: "\x04\x09", + 1: "Sony Interactive Entertainment", + 2: "Wireless Controller", + }, +} diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go new file mode 100644 index 00000000..e8ce25d0 --- /dev/null +++ b/device/dualshock4/dualshock4_test.go @@ -0,0 +1,471 @@ +package dualshock4_test + +import ( + "context" + "encoding/binary" + "io" + "testing" + "time" + + viiperTesting "github.com/Alia5/VIIPER/_testing" + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/internal/server/api" + "github.com/Alia5/VIIPER/internal/server/api/handler" + "github.com/Alia5/VIIPER/usbip" + "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 dualshock4.InputState + expectedReport []byte + } + + cases := []testCase{ + { + name: "neutral defaults", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + 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, + }, + expectedReport: []byte{ + 0x01, + 0x80, 0x80, 0x80, 0x80, + 0x08, + 0x00, + 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0b, + 0x00, 0x00, 0x00, 0x00, + 0x80, 0x00, 0x00, 0x00, + 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + }, + }, + { + name: "dpad up", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: 0, + DPad: dualshock4.DPadUp, + Touch1Active: false, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x00 + b[30] = 0x0b + b[35] = 0x80 + b[39] = 0x80 + return b + }(), + }, + { + name: "buttons - square", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: uint16(dualshock4.ButtonSquare), + DPad: 0, + Touch1Active: false, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x18 + b[30] = 0x0b + b[35] = 0x80 + b[39] = 0x80 + return b + }(), + }, + { + name: "buttons - ps", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: dualshock4.ButtonPS, + DPad: 0, + Touch1Active: false, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x08 + b[7] = 0x01 + b[30] = 0x0b + b[35] = 0x80 + b[39] = 0x80 + return b + }(), + }, + { + name: "triggers - l2/r2", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: 0, + DPad: 0, + L2: 0x12, + R2: 0xFE, + Touch1Active: false, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x08 + b[8] = 0x12 + b[9] = 0xFE + b[30] = 0x0b + b[35] = 0x80 + b[39] = 0x80 + return b + }(), + }, + { + name: "touch1 active with coords", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: 0, + DPad: 0, + Touch1X: 123, + Touch1Y: 456, + Touch1Active: true, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x08 + b[30] = 0x0b + b[35] = 0x00 + b[36] = 0x7b + b[37] = 0x80 + b[38] = 0x1c + b[39] = 0x80 + return b + }(), + }, + { + name: "sensors", + inputState: dualshock4.InputState{ + LX: 0, + LY: 0, + RX: 0, + RY: 0, + Buttons: 0, + DPad: 0, + GyroX: 1234, + GyroY: -2345, + GyroZ: 3456, + AccelX: -111, + AccelY: 222, + AccelZ: -333, + Touch1Active: false, + Touch2Active: false, + }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x08 + b[13], b[14] = 0xD2, 0x04 + b[15], b[16] = 0xD7, 0xF6 + b[17], b[18] = 0x80, 0x0D + b[19], b[20] = 0x91, 0xFF + b[21], b[22] = 0xDE, 0x00 + b[23], b[24] = 0xB3, 0xFE + b[30] = 0x0b + b[35] = 0x80 + b[39] = 0x80 + return b + }(), + }, + } + + s := viiperTesting.NewTestServer(t) + defer s.UsbServer.Close() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "dualshock4", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + var seq uint32 + readInputReport := func(timeout time.Duration) ([]byte, error) { + seq++ + cmd := usbip.CmdSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.CmdSubmitCode, Seqnum: seq, Devid: 0, Dir: usbip.DirIn, Ep: 4}, + TransferFlags: 0, + TransferBufferLen: 255, + StartFrame: 0, + NumberOfPackets: 0, + Interval: 0, + Setup: [8]byte{}, + } + _ = imp.Conn.SetDeadline(time.Now().Add(timeout)) + if err := cmd.Write(imp.Conn); err != nil { + return nil, err + } + var retHdr [48]byte + if err := usbip.ReadExactly(imp.Conn, retHdr[:]); err != nil { + return nil, err + } + if gotCmd := binary.BigEndian.Uint32(retHdr[0:4]); gotCmd != usbip.RetSubmitCode { + return nil, io.ErrUnexpectedEOF + } + status := int32(binary.BigEndian.Uint32(retHdr[20:24])) + actual := binary.BigEndian.Uint32(retHdr[24:28]) + if status != 0 { + return nil, io.ErrUnexpectedEOF + } + data := make([]byte, int(actual)) + if actual > 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 := dualshock4.New(nil) + dev.UpdateInputState(&tc.inputState) + built := dev.HandleTransfer(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, 750*time.Millisecond) + 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() + defer s.ApiServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + + client := apiclient.New(s.ApiServer.Addr()) + stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "dualshock4", nil) + if !assert.NoError(t, err) { + return + } + defer stream.Close() + + 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() + } + + 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(750 * time.Millisecond)) + _, 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) + }) + } +} diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go new file mode 100644 index 00000000..c5aa14ed --- /dev/null +++ b/device/dualshock4/handler.go @@ -0,0 +1,60 @@ +package dualshock4 + +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("dualshock4", &handler{}) +} + +type handler struct{} + +func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { 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") + } + ds4, ok := (*devPtr).(*DualShock4) + if !ok { + return fmt.Errorf("device is not dualshock4") + } + + 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) + } + }) + + buf := make([]byte, 31) + 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) + } + } +} diff --git a/device/dualshock4/helpers.go b/device/dualshock4/helpers.go new file mode 100644 index 00000000..37d766ed --- /dev/null +++ b/device/dualshock4/helpers.go @@ -0,0 +1,41 @@ +package dualshock4 + +import "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 clampI16(math.Round(dps * GyroCountsPerDps)) +} + +// 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 clampI16(math.Round(ms2 * AccelCountsPerMS2)) +} + +// 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 clampI16(v float64) int16 { + if v > math.MaxInt16 { + return math.MaxInt16 + } + if v < math.MinInt16 { + return math.MinInt16 + } + return int16(v) +} diff --git a/device/dualshock4/inputstate.go b/device/dualshock4/inputstate.go new file mode 100644 index 00000000..b6ab381b --- /dev/null +++ b/device/dualshock4/inputstate.go @@ -0,0 +1,120 @@ +package dualshock4 + +import ( + "encoding/binary" + "io" +) + +// 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 +} + +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 +} + +// 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 +} diff --git a/docs/clients/generator.md b/docs/clients/generator.md index a365301c..dd7ce2a6 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -220,7 +220,6 @@ Run codegen when any of these change: ## Further Reading -- [Design Document](../design.md): Architectural rationale and detailed generation strategy - [Go Client Documentation](go.md): Go reference client usage - [C Client Library Documentation](c.md): C-specific usage, build, and examples - [C# Client Library Documentation](csharp.md): C#-specific usage, async patterns, and map helpers diff --git a/docs/devices/dualshock4.md b/docs/devices/dualshock4.md new file mode 100644 index 00000000..a2299ba4 --- /dev/null +++ b/docs/devices/dualshock4.md @@ -0,0 +1,131 @@ +# 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), and touchpad finger coordinates. + +- USB IDs: VID `0x054C` (Sony), PID `0x05C4` (DualShock 4, v1) +- Device type id (for API add): `dualshock4` + +## 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 them via the device stream. + +See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) + +## Adding the device + +Using the raw API (see [API Reference](../api/overview.md) for details): + +```bash +# Create a bus +printf "bus/create\0" | nc localhost 3242 + +# Add DualShock 4 device with JSON payload +printf 'bus/1/add {"type":"dualshock4"}\0' | nc localhost 3242 +``` + +Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. + +## Device stream protocol + +The DS4 device stream is a bidirectional TCP stream with **fixed-size** packets. + +### Device Input + +- 31-byte packets (little-endian): + - `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` + +See `/device/dualshock4/inputstate.go` + +#### Touchpad coordinates + +Touch coordinates are sent as `touch{1,2}X: u16` and `touch{1,2}Y: u16` plus an explicit boolean `touch{1,2}Active`. + +VIIPER clamps touch coordinates to the DS4 range: + +- X: **0..1920** +- Y: **0..942** + +These are the bounds used by VIIPER’s DS4 implementation; see `/device/dualshock4/const.go`. + +#### Gyro + accelerometer fixed-point units + +VIIPER uses **fixed-point physical units** for IMU values on the wire (still stored as `int16`), to avoid float serialization differences across client languages. + +Constants (see `/device/dualshock4/const.go`): + +- `GyroCountsPerDps = 16` +- `AccelCountsPerMS2 = 512` + +##### Formulas + +Gyro (degrees/second): + +```text +raw_gyro = round(gyro_dps * GyroCountsPerDps) +gyro_dps = raw_gyro / GyroCountsPerDps +``` + +Accelerometer (m/s²): + +```text +raw_accel = round(accel_ms2 * AccelCountsPerMS2) +accel_ms2 = raw_accel / AccelCountsPerMS2 +``` + +#### Resolution and range + +With the default scales: + +- Gyro (`GyroCountsPerDps = 16`): + - Resolution: `1/16 = 0.0625 °/s` + - Approx max magnitude: `32767/16 ≈ 2048 °/s` +- Accelerometer (`AccelCountsPerMS2 = 512`): + - Resolution: `1/512 ≈ 0.001953125 m/s²` + - Approx max magnitude: `32767/512 ≈ 64 m/s²` (≈ 6.5 g) + +Conversions saturate to the `int16` range if inputs exceed representable values. + +#### Default (neutral) report gravity + +On device creation, VIIPER initializes the accelerometer to represent a controller lying flat on a table, with gravity "downwards": + +- `g = 9.81 m/s²` +- Default accel is: `(0, 0, -g)` + +In raw fixed-point units, this means: + +- `AccelX = 0` +- `AccelY = 0` +- `AccelZ = round(-9.81 * 512) = -5023` + +Helpers for converting between physical units and raw values are provided in `/device/dualshock4/helpers.go`. + +## Device Output + +The host sends rumble/LED updates which VIIPER parses and forwards to your client as a compact feedback packet. + +Direction: server → client (feedback) + +- 7-byte packets: + - `RumbleSmall: u8` + - `RumbleLarge: u8` + - `LedRed: u8` + - `LedGreen: u8` + - `LedBlue: u8` + - `FlashOn: u8` (units of 2.5ms) + - `FlashOff: u8` (units of 2.5ms) + +See `/device/dualshock4/inputstate.go` for the `OutputState` wire definition. + +## Examples + +A minimal example that drives a DS4 device is provided in `examples/go/virtual_ds4/`. diff --git a/examples/go/virtual_ds4/main.go b/examples/go/virtual_ds4/main.go new file mode 100644 index 00000000..a861fbe0 --- /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/apiclient" + "github.com/Alia5/VIIPER/device/dualshock4" +) + +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 := 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 { + 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() + + 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..d4ded9c9 --- /dev/null +++ b/examples/go/virtual_ds4_cli/main.go @@ -0,0 +1,682 @@ +package main + +import ( + "bufio" + "context" + "encoding" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Alia5/VIIPER/apiclient" + "github.com/Alia5/VIIPER/device/dualshock4" +) + +// 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 := apiclient.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() + + 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(uint16(dualshock4.ButtonSquare), on) + case "cross", "x": + on, err := parseBool() + if err != nil { + return err + } + setButton(uint16(dualshock4.ButtonCross), on) + case "circle": + on, err := parseBool() + if err != nil { + return err + } + setButton(uint16(dualshock4.ButtonCircle), on) + case "triangle": + on, err := parseBool() + if err != nil { + return err + } + setButton(uint16(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) error { + 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 + } + return nil +} diff --git a/internal/codegen/generator/c/header_common.go b/internal/codegen/generator/c/header_common.go index 7b5b41bf..5722d2ce 100644 --- a/internal/codegen/generator/c/header_common.go +++ b/internal/codegen/generator/c/header_common.go @@ -21,6 +21,7 @@ extern "C" { #include #include +#include /* Platform-specific exports */ #if defined(_WIN32) || defined(_WIN64) diff --git a/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go index eee07b94..743922ba 100644 --- a/internal/codegen/generator/c/header_device.go +++ b/internal/codegen/generator/c/header_device.go @@ -29,7 +29,7 @@ const deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H {{- if gt (len .Pkg.Constants) 0 }} /* {{.Device}} constants */ {{range .Pkg.Constants -}} -#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{printf "0x%X" .Value}} +#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{cConstValue .}} {{end}} {{- end}} diff --git a/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go index 11c966e3..03da59f4 100644 --- a/internal/codegen/generator/c/helpers.go +++ b/internal/codegen/generator/c/helpers.go @@ -2,6 +2,7 @@ package cgen import ( "fmt" + "strconv" "strings" "text/template" @@ -19,6 +20,7 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { "wireFields": func(device, direction string) string { return wireFieldsString(md, device, direction) }, "indent": indent, "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, + "cConstValue": cConstValue, "pathParams": common.ExtractPathParams, "join": strings.Join, "mapFuncDecl": mapFuncDecl, @@ -31,6 +33,46 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { } } +func cConstValue(c scanner.ConstantInfo) string { + base, _, _ := common.NormalizeGoType(c.Type) + + if s, ok := c.Value.(string); ok { + if base == "string" { + return fmt.Sprintf("\"%s\"", s) + } + if _, err := strconv.ParseInt(s, 0, 64); err == nil { + return s + } + if _, err := strconv.ParseUint(s, 0, 64); err == nil { + return s + } + if _, err := strconv.ParseFloat(s, 64); err == nil { + return s + } + return s + } + + switch v := c.Value.(type) { + case uint64: + if strings.HasPrefix(base, "uint") || base == "byte" { + return fmt.Sprintf("0x%X", v) + } + return fmt.Sprintf("%d", v) + case int64: + if strings.HasPrefix(base, "uint") || base == "byte" { + if v < 0 { + return fmt.Sprintf("%d", v) + } + return fmt.Sprintf("0x%X", uint64(v)) + } + return fmt.Sprintf("%d", v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + default: + return fmt.Sprintf("%v", c.Value) + } +} + func payloadCType(md *meta.Metadata, pi scanner.PayloadInfo) string { switch pi.Kind { case scanner.PayloadJSON: diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index 15665cc6..b619073a 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "text/template" @@ -127,7 +128,7 @@ func groupConstantsByPrefix(constants []scanner.ConstantInfo) []enumGroup { _, name := common.TrimPrefixAndSanitize(c.Name) groups[prefix].Constants = append(groups[prefix].Constants, constantInfo{ Name: name, - Value: formatConstValue(c.Value), + Value: formatConstValue(c.Value, c.Type), Type: mapGoConstTypeToCSharp(c.Type), }) } @@ -151,32 +152,67 @@ func shouldGenerateEnum(eg enumGroup) bool { } func inferEnumType(constants []constantInfo) string { - - max := uint64(0) + minI := int64(0) + maxI := int64(0) parsedAny := false + for _, c := range constants { - var v uint64 - var n int - var err error if strings.HasPrefix(c.Value, "0x") { - n, err = fmt.Sscanf(c.Value, "0x%x", &v) - } else { - n, err = fmt.Sscanf(c.Value, "%d", &v) + 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 } - if err == nil && n == 1 { - parsedAny = true - if v > max { - max = v + + 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 max <= 0xFF: + case uMax <= 0xFF: return "byte" - case max <= 0xFFFF: + case uMax <= 0xFFFF: return "ushort" - case max <= 0xFFFFFFFF: + case uMax <= 0xFFFFFFFF: return "uint" default: return "ulong" @@ -219,7 +255,9 @@ func isFlags(constants []constantInfo) bool { return false } -func formatConstValue(value interface{}) string { +func formatConstValue(value interface{}, goType string) string { + base, _, _ := common.NormalizeGoType(goType) + switch v := value.(type) { case int64: return fmt.Sprintf("0x%X", v) @@ -227,10 +265,30 @@ func formatConstValue(value interface{}) string { return fmt.Sprintf("0x%X", v) case int: return fmt.Sprintf("0x%X", v) - case string: - return fmt.Sprintf("\"%s\"", v) case float64: 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) } @@ -397,7 +455,7 @@ func formatMapValue(value interface{}, goType string) string { } return str } - return formatConstValue(value) + return formatConstValue(value, goType) case "bool": if b, ok := value.(bool); ok { if b { @@ -413,9 +471,9 @@ func formatMapValue(value interface{}, goType string) string { if str, ok := value.(string); ok { return fmt.Sprintf("\"%s\"", str) } - return formatConstValue(value) + return formatConstValue(value, goType) default: - return formatConstValue(value) + return formatConstValue(value, goType) } } diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go index 673b2669..71a07e05 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -5,6 +5,8 @@ import ( "log/slog" "os" "path/filepath" + "strconv" + "strings" "text/template" "github.com/Alia5/VIIPER/internal/codegen/common" @@ -166,6 +168,30 @@ func formatConstValue(val interface{}, goType string) string { 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) } diff --git a/internal/registry/devices.go b/internal/registry/devices.go index 904ad743..9e57d753 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -1,7 +1,8 @@ package registry import ( - _ "github.com/Alia5/VIIPER/device/keyboard" // Register keyboard device handler - _ "github.com/Alia5/VIIPER/device/mouse" // Register mouse device handler - _ "github.com/Alia5/VIIPER/device/xbox360" // Register xbox360 device handler + _ "github.com/Alia5/VIIPER/device/dualshock4" + _ "github.com/Alia5/VIIPER/device/keyboard" + _ "github.com/Alia5/VIIPER/device/mouse" + _ "github.com/Alia5/VIIPER/device/xbox360" ) diff --git a/mkdocs.yml b/mkdocs.yml index c4c6cbbf..ecf92993 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - TypeScript Client Library: clients/typescript.md - Devices: - Xbox 360 Controller: devices/xbox360.md + - DualShock 4 Controller: devices/dualshock4.md - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md - Changelog: changelog/ From 62d43631b32345a7233d8fc7cd7f79481551e540 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 16 Jan 2026 23:03:16 +0100 Subject: [PATCH 117/298] Update docs De-Vibe --- .markdownlint.json | 5 + docs/api/overview.md | 395 ++++++++++++++------ docs/cli/codegen.md | 43 +-- docs/cli/configuration.md | 103 +++--- docs/cli/proxy.md | 46 +-- docs/cli/server.md | 70 ++-- docs/clients/c.md | 24 +- docs/clients/cpp.md | 127 +------ docs/clients/csharp.md | 169 +-------- docs/clients/generator.md | 4 - docs/clients/go.md | 52 +-- docs/clients/rust.md | 562 ++++++++++------------------- docs/clients/typescript.md | 173 +-------- docs/devices/dualshock4.md | 143 ++++---- docs/devices/keyboard.md | 135 +++---- docs/devices/mouse.md | 93 ++--- docs/devices/xbox360.md | 75 ++-- docs/getting-started/quickstart.md | 124 ++++--- docs/getting-started/usbip.md | 72 ++++ mkdocs.yml | 2 +- 20 files changed, 1001 insertions(+), 1416 deletions(-) create mode 100644 .markdownlint.json create mode 100644 docs/getting-started/usbip.md 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/docs/api/overview.md b/docs/api/overview.md index ec5fca14..b55529b4 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -1,9 +1,64 @@ # 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 null-byte-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" - Generated client libraries are available that abstract away the protocol details described below. For most use cases, you should use one of the provided client libraries rather than implementing the raw protocol yourself: + 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: - [Go Client](../clients/go.md): Reference implementation included in the repository - [Generator Documentation](../clients/generator.md): Information about code generation @@ -14,168 +69,278 @@ VIIPER ships a lightweight TCP API for managing virtual buses/devices and for de - [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 `\0` (null byte) -- 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), followed by `\n`, then 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, followed by `\n`, then connection close +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. -Tip: You can experiment with `nc`/`ncat` or PowerShell’s `tcpclient` to send lines and read JSON back. +- **Transport**: TCP +- **Default listen address**: `:3242` (configurable via `--api.addr`) +- **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. ## Endpoints -- `ping` - - Simple identity and version check. - - Response: `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` +!!! 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/list` - - List all virtual bus IDs. - - Response: `{ "buses": [1, 2, ...] }` +
-- `bus/create [busId]` - - Create a new bus. If `busId` (numeric) is provided, VIIPER attempts to create the bus with that id; otherwise it picks the next free id. - - Payload: Optional numeric bus ID (e.g., `5`) - - Response: `{ "busId": }` +- **Bus Management** + + --- -- `bus/remove ` - - Remove a bus and all devices on it. - - Payload: Numeric bus ID (e.g., `1`) - - Response: `{ "busId": }` + Create, list, and remove virtual buses -- `bus/{id}/list` - - List devices on a bus. - - Response: `{ "devices": [{ "busId": 1, "devId": "1", "vid": "0x045e", "pid": "0x028e", "type": "xbox360" }, ...] }` + [Jump to section](#bus-management) -- `bus/{id}/add ` - - Add a device to a bus. - - Payload: JSON object with device creation parameters: `{"type": "", "idVendor": , "idProduct": }` - - Example: `{"type": "xbox360"}` or `{"type": "keyboard", "idVendor": 1234, "idProduct": 5678}` - - Response: JSON device object with fields: `{"busId": , "devId": "", "vid": "0x045e", "pid": "0x028e", "type": "xbox360"}` - - 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. - - 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 (missing tool, non-zero exit) are logged but do not affect the API response. +- **Device Management** + + --- -- `bus/{id}/remove ` - - Remove a device by its device number on that bus. - - Payload: Numeric device ID (e.g., `1` for device 1-1 on the bus) - - Response: `{ "busId": , "devId": "" }` + Add, list, and remove devices on a bus -### Streaming endpoint + [Jump to section](#device-management) -- Path: `bus/{busId}/{deviceid}` -- Handshake: Send the path followed by `\0` (null byte) (e.g., `bus/1/1\0`) -- Type: long-lived TCP connection -- 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. +- **Device Control / Feedback** + + --- -#### Xbox 360 controller stream (device type: `xbox360`) + Real-time input and feedback streams for devices -Direction: client ➜ server (input state) + [Jump to section](#device-control--feedback) -- 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) +- **Error Handling** + + --- -Direction: server ➜ client (rumble) + Error response format and common error codes -- Fixed 2-byte packets: - - `LeftMotor` uint8, `RightMotor` uint8 + [Jump to section](#error-handling) -See `/device/xbox360/protocol.go` for full details. +
-#### HID keyboard stream (device type: `keyboard`) +### Bus Management {#bus-management} -Direction: client ➜ server (keys pressed) +#### `ping` {.toc-anchor} -- Variable-length packets per frame: - - Header: Modifiers uint8, KeyCount uint8 - - Body: KeyCount bytes of HID Usage IDs for currently pressed (non-modifier) keys +??? info "ping - Simple identity and version check" + **Request:** `ping` -Direction: server ➜ client (LED state) + **Response:** `{ "server": "VIIPER", "version": "1.2.3[-dev-abcd]" }` -- 1-byte packets whenever host LED state changes: - - Bit 0 NumLock, Bit 1 CapsLock, Bit 2 ScrollLock +#### `bus/list` {.toc-anchor} -Host-facing HID input report is 34 bytes: [Modifiers (1), Reserved (1), 256-bit key bitmap (32)]. +??? info "bus/list - List all virtual bus IDs" + **Request:** `bus/list` -See `/device/keyboard/` for helpers and constants. + **Response:** `{ "buses": [1, 2, ...] }` -#### HID mouse stream (device type: `mouse`) +#### `bus/create [busId]` {.toc-anchor} -Direction: client ➜ server (motion/buttons) +??? info "bus/create - Create a new bus" + **Request:** `bus/create` or `bus/create 5` -- Fixed 5-byte packets per frame: - - Buttons uint8 (bits 0..4) - - dX int8, dY int8 - - Wheel int8, Pan int8 + **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": }` -Direction: server ➜ client +#### `bus/remove ` {.toc-anchor} -- None (mouse is input-only) +??? info "bus/remove - Remove a bus and all devices on it" + **Request:** `bus/remove 1` -Note: Motion and wheel deltas are consumed after each IN report so movement is relative. + **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" + } + ] + } + ``` + +#### `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": + } + ``` + + **Examples:** + - `{"type":"xbox360"}` + - `{"type":"keyboard","idVendor":1234,"idProduct":5678}` + + **Response:** + ```json + { + "busId": 1, + "devId": "1", + "vid": "0x045e", + "pid": "0x028e", + "type": "xbox360" + } + ``` + + !!! 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. -Note on protocol compatibility: +#### `bus/{id}/remove ` {.toc-anchor} -- 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. +??? info "bus/{id}/remove - Remove a device from a bus" + **Request:** `bus/1/remove 1` -## Example sessions + **Payload:** Numeric device ID (e.g., `1` for device 1-1 on the bus) + + **Response:** `{ "busId": , "devId": "" }` -### Using netcat (Linux/macOS) +### Device Control / Feedback {#device-control--feedback} -```bash -# List buses -printf "bus/list\0" | nc localhost 3242 +Device Control and Feedback requires an initial "handshake" request, afterwards the connection is used as a long-lived (device-specific, binary) bidirectional stream. -# Create a bus -printf "bus/create\0" | nc localhost 3242 -# → {"busId":1} +!!! info "Establish control/feedback connection/stream" + **Path:** `bus/{busId}/{deviceId}` -# Create a bus with specific ID -printf "bus/create 5\0" | nc localhost 3242 -# → {"busId":5} + **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. -# 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"} +Device control and feedback is **device-specific**. +Each device type defines it's own packet formats. -# List devices on bus 1 -printf "bus/1/list\0" | nc localhost 3242 -``` +**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. -Then, open a second TCP connection for streaming to `bus/1/1` (the API port, not the USBIP port). First send the handshake `bus/1/1\0`, then you'll write 14‑byte input packets and read 2‑byte rumble packets. Any language with raw TCP support works. +Refer to the individual [device documentation](../devices/overview.md) for details on packet formats and behavior. -### WIndows (PowerShell) +### Error Handling {#error-handling} -VIIPER includes convenience scripts for quick testing and automation: +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. -```powershell -# Source the script to load helper functions -. .\scripts\viiper-api.ps1 +If you have ever worked with HTTP APIs, the errors and status codes will feel familiar. -# Use the Invoke-ViiperApi function (or 'viiper' alias) -viiper "bus/list" -viiper "bus/create" -viiper "bus/1/add {\"type\":\"xbox360\"}" -Port 3242 -Hostname localhost +#### 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:** + +- `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 -### Go snippet (raw) +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 @@ -203,8 +368,14 @@ 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 using USBIP by `busid` (see the Server command page for syntax). +1. Create a bus +2. Add a device +3. Connect the device stream +4. Attach a USBIP client to the device 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 557dc849..9794ef0a 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -1,16 +1,9 @@ # Code Generation Command 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] -``` - -## Description - -Scans the VIIPER server codebase to extract: +It scans the VIIPER server codebase to extract: - API routes and response DTOs - Device wire formats from `viiper:wire` comment tags @@ -26,6 +19,14 @@ Then generates client libraries with: !!! 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` @@ -35,12 +36,6 @@ Output directory for generated client libraries (relative to repository root). **Default:** `clients` **Environment Variable:** `VIIPER_CODEGEN_OUTPUT` -**Example:** - -```bash -viiper codegen --output=../client-libs-output -``` - ### `--lang` Target language to generate. @@ -49,22 +44,6 @@ Target language to generate. **Default:** `all` **Environment Variable:** `VIIPER_CODEGEN_LANG` -**Examples:** - -```bash -# Generate all client libraries -viiper codegen --lang=all - -# Generate C client library only -viiper codegen --lang=c - -# Generate C# client library only -viiper codegen --lang=csharp - -# Generate TypeScript client library only -viiper codegen --lang=typescript -``` - ## Examples ### Generate All Client Libraries @@ -94,5 +73,5 @@ Run codegen when any of these change: ## See Also - [Generator Documentation](../clients/generator.md): Detailed explanation of tagging system and code generation flow -- [C Client Library 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 a582484e..09705a1c 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 @@ -38,13 +36,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: @@ -52,7 +51,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 @@ -64,52 +63,52 @@ 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", - "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" -} -``` - ## Configuration Examples -### Using 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 -``` +=== "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/proxy.md b/docs/cli/proxy.md index 626c6720..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:3240 -``` - ### `--upstream` **Required.** Upstream USBIP server address (where real devices are). **Environment Variable:** `VIIPER_PROXY_UPSTREAM` -**Example:** - -```bash -viiper proxy --upstream=192.168.1.100:3240 -``` - ### `--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:3240 --connection-timeout=60s -``` - ## Examples ### Basic Proxy @@ -121,16 +105,6 @@ Route USB traffic through VIIPER to inspect and log all operations: viiper proxy --upstream=real-server:3240 --log.level=debug --log.raw-file=traffic.log ``` -## Proxy Architecture - -``` -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 - [Server Command](server.md) - Run VIIPER as a USBIP server diff --git a/docs/cli/server.md b/docs/cli/server.md index 1aaa9c19..344287a8 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,7 @@ 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** - Management API for device/bus control +2. **VIIPER API Server** - Management API for device/bus control !!! info "Automatic Local Attachment" By default, VIIPER automatically attaches newly created devices to the local USBIP client (localhost only). @@ -31,12 +32,6 @@ 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. @@ -44,13 +39,6 @@ API server listen address. **Default:** `:3242` **Environment Variable:** `VIIPER_API_ADDR` -**Example:** - -```bash -# Enable API on custom port -viiper server --api.addr=:8080 -``` - ### `--api.device-handler-timeout` Time before auto-cleanup occurs when a device handler has no active connection. @@ -58,12 +46,6 @@ Time before auto-cleanup occurs when a device handler has no active connection. **Default:** `5s` **Environment Variable:** `VIIPER_API_DEVICE_HANDLER_TIMEOUT` -**Example:** - -```bash -viiper server --api.device-handler-timeout=10s -``` - ### `--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. @@ -86,12 +68,6 @@ Connection operation timeout for both USBIP and API servers. **Default:** `30s` **Environment Variable:** `VIIPER_CONNECTION_TIMEOUT` -**Example:** - -```bash -viiper server --connection-timeout=60s -``` - ## Examples ### Basic Server @@ -135,35 +111,35 @@ 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 index 25103899..450b3c7f 100644 --- a/docs/clients/c.md +++ b/docs/clients/c.md @@ -2,15 +2,13 @@ The VIIPER C client library provides a lightweight, dependency-free client library for interacting with VIIPER servers and controlling virtual devices. -## Overview - The C client library 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 +- **Device-agnostic streaming API**: Uniform interface for all device types !!! note "License" The C client library is licensed under the **MIT License**, providing maximum flexibility for integration into your projects. @@ -31,7 +29,7 @@ Build the client library: ```bash cd ../clients/c cmake -B build -G "Visual Studio 17 2022" # Windows -cmake -B build # POSIX +cmake -B build # LINUX cmake --build build --config Release ``` @@ -60,7 +58,7 @@ endif() - Link: `clients/c/build/Release/viiper.lib` (Windows) or `libviiper.a` (POSIX) - Runtime: Copy `viiper.dll` next to your executable (Windows) -## Quick Start +## Example ```c #include @@ -123,11 +121,11 @@ int main(void) { } ``` -## Device Stream API +## Device Control/Feedback API -### Creating a Device Stream +### Creating a Device Control/Feedback Stream -Manual approach (add device, then connect): +Manual (add device, then connect): ```c // Add device first @@ -187,7 +185,7 @@ viiper_mouse_input_t input = { int err = viiper_device_send(device, &input, sizeof(input)); ``` -### Receiving Output (Callbacks) +### Receiving Feedback (Rumble, LEDs, etc.) ```c void on_led_update(void* user_data, const void* data, size_t len) { @@ -208,9 +206,12 @@ viiper_device_on_output(device, on_led_update, NULL); viiper_device_close(device); ``` +The VIIPER server automatically removes the device when the stream is closed after a short timeout. + ## 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. +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 client library provides generated structs and constants in device-specific headers (e.g., `viiper_keyboard.h`, `viiper_mouse.h`, `viiper_xbox360.h`). @@ -228,7 +229,8 @@ typedef struct { #pragma pack(pop) ``` -**Important:** Always ensure your compiler respects packing directives. MSVC and GCC/Clang handle this correctly by default. +!!! warning "Compiler compatibility" + **Important:** Always ensure your compiler respects packing directives. MSVC and GCC/Clang handle this correctly by default. ## Troubleshooting diff --git a/docs/clients/cpp.md b/docs/clients/cpp.md index 7d3cb1db..d3c8a5ed 100644 --- a/docs/clients/cpp.md +++ b/docs/clients/cpp.md @@ -2,12 +2,10 @@ The VIIPER C++ client library provides a modern, header-only C++20 client library for interacting with VIIPER servers and controlling virtual devices. -## Overview - The C++ client library features: - **Header-only**: No separate compilation required, just include and use -- **Modern C++20**: Uses concepts, designated initializers, std::optional, smart pointers +- **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 @@ -55,12 +53,6 @@ find_package(nlohmann_json REQUIRED) target_link_libraries(your_target PRIVATE nlohmann_json::nlohmann_json) ``` -### 3. Generating from Source - -```bash -go run ./cmd/viiper codegen --lang=cpp -``` - The client library will be generated in `clients/cpp/include/viiper/`. ## JSON Parser Configuration @@ -100,7 +92,7 @@ Example with a custom library: #include ``` -## Quick Start +## Example ```cpp #define VIIPER_JSON_INCLUDE @@ -166,9 +158,9 @@ int main() { } ``` -## Device Stream API +## Device Control/Feedback -### Creating a Device Stream +### Creating a Device + Control/Feedback Stream The simplest way to add a device and connect: @@ -176,17 +168,6 @@ The simplest way to add a device and connect: auto [device_info, stream] = client.addDeviceAndConnect(bus_id, {.type = "xbox360"}).value(); ``` -With custom VID/PID: - -```cpp -viiper::Devicecreaterequest req = { - .type = "keyboard", - .idvendor = 0x1234, - .idproduct = 0x5678 -}; -auto [device_info, stream] = client.addDeviceAndConnect(bus_id, req).value(); -``` - Or manually add and connect: ```cpp @@ -238,7 +219,7 @@ viiper::xbox360::Input input = { stream->send(input); ``` -### Receiving Output (Callbacks) +### Receiving Feedback For devices that send feedback (rumble, LEDs), register a callback: @@ -292,6 +273,7 @@ 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 @@ -358,64 +340,6 @@ bool needs_shift = viiper::keyboard::SHIFT_CHARS.contains(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; - } - - // Press key - viiper::keyboard::Input down = { - .modifiers = mods, - .keys = {key}, - }; - stream.send(down); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - - // Release key - viiper::keyboard::Input up = { - .modifiers = 0, - .keys = {}, - }; - stream.send(up); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } -} - -// Usage -type_string(*stream, "Hello, World!"); -``` - ## Error Handling All API methods return `Result`, which is either a value or an error: @@ -480,41 +404,6 @@ cmake --build . --config Release ./virtual_x360_pad localhost:3242 ``` -## Project Structure - -Generated SDK layout: - -```text -clients/cpp/include/viiper/ -├── viiper.hpp # Main include (includes all others) -├── config.hpp # JSON configuration macros -├── error.hpp # Result and Error types -├── types.hpp # API request/response types -├── client.hpp # ViiperClient management API -├── device.hpp # ViiperDevice stream wrapper -├── detail/ -│ ├── socket.hpp # Cross-platform socket wrapper -│ └── json.hpp # JSON parsing helpers -└── devices/ - ├── keyboard.hpp # Keyboard constants, Input, Output - ├── mouse.hpp # Mouse constants, Input - └── xbox360.hpp # Xbox360 constants, Input, Output -``` - -## Requirements - -- **C++20** or later -- **JSON library** (nlohmann/json recommended) -- **Platform**: Windows (MSVC 2019+) or POSIX (GCC 10+, Clang 10+) - -### Windows-Specific - -The client library uses Winsock2 for networking. Link against `Ws2_32.lib` (done automatically via `#pragma comment`). - -### POSIX-specific - -Standard POSIX sockets are used. No additional libraries required. - ## Troubleshooting **Error: VIIPER_JSON_INCLUDE must be defined** @@ -554,7 +443,3 @@ viiper server --api-addr localhost:3242 - [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 - ---- - -For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 0549aeda..90f92542 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -2,8 +2,6 @@ The VIIPER C# client library provides a modern, type-safe .NET client library for interacting with VIIPER servers and controlling virtual devices. -## Overview - The C# client library features: - **Async/await support**: Full async API with cancellation token support @@ -28,7 +26,8 @@ dotnet add package Viiper.Client Package page: [Viiper.Client on NuGet](https://www.nuget.org/packages/Viiper.Client/) -> 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. +!!! 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: @@ -60,17 +59,7 @@ Use this when modifying the generator or contributing new device types: ``` -### 3. Generating from Source (Advanced / Contributors) - -Only required when enhancing VIIPER itself: - -```bash -go run ./cmd/viiper codegen --lang=csharp -cd clients/csharp -dotnet build -c Release Viiper.Client -``` - -## Quick Start +## Example ```csharp using Viiper.Client; @@ -113,9 +102,9 @@ await device.SendAsync(input); await client.BusDeviceRemoveAsync(busId, deviceResp.DevId); ``` -## Device Stream API +## Device Control/Feedback -### Creating a Device Stream +### Creating a Device + Control/Feedback Stream The simplest way to add a device and connect: @@ -125,18 +114,6 @@ var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); ``` -With custom VID/PID: - -```csharp -var deviceReq = new DeviceCreateRequest { - Type = "keyboard", - IdVendor = 0x1234, - IdProduct = 0x5678 -}; -var deviceResp = await client.BusDeviceAddAsync(busId, deviceReq); -var device = await client.ConnectDeviceAsync(busId, deviceResp.DevId); -``` - Or connect to an existing device: ```csharp @@ -163,7 +140,7 @@ var input = new Xbox360Input await device.SendAsync(input); ``` -### Receiving Output (Events) +### Receiving Feedback For devices that send feedback (rumble, LEDs), subscribe to the `OnOutput` event: @@ -204,6 +181,8 @@ device.Dispose(); 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. @@ -261,109 +240,6 @@ if (KeyName.TryGetValue((byte)Key.F1, out var name)) bool needsShift = ShiftChars.ContainsKey((byte)'A'); // true for uppercase ``` -## Practical Example: Typing Text - -Using the generated maps to type a string: - -```csharp -async Task TypeString(ViiperDevice device, string text) -{ - foreach (char c in text) - { - if (!CharToKey.TryGetValue((byte)c, out var key)) - continue; - - byte mods = ShiftChars.ContainsKey((byte)c) - ? (byte)Mod.LeftShift - : (byte)0; - - // Press - await device.SendAsync(new KeyboardInput - { - Modifiers = mods, - Count = 1, - Keys = new[] { (byte)key } - }); - await Task.Delay(50); - - // Release - await device.SendAsync(new KeyboardInput - { - Modifiers = 0, - Count = 0, - Keys = Array.Empty() - }); - await Task.Delay(50); - } -} - -// Usage -await TypeString(device, "Hello, World!"); -``` - -## Device-Specific Wire Formats - -### Keyboard Input - -```csharp -public struct KeyboardInput -{ - public byte Modifiers; // Modifier flags (Ctrl, Shift, Alt, GUI) - public byte Count; // Number of keys in Keys array - public byte[] Keys; // Key codes (max 6 for HID compliance) -} -``` - -**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) - -### Keyboard Output (LEDs) - -```csharp -// Single byte with LED flags -byte leds = data[0]; -bool numLock = (leds & (byte)LED.NumLock) != 0; -``` - -### Xbox360 Input - -```csharp -public struct Xbox360Input -{ - public ushort Buttons; // Button flags - public byte LeftTrigger; // 0-255 - public byte RightTrigger; // 0-255 - public short ThumbLX; // -32768 to 32767 - public short ThumbLY; // -32768 to 32767 - public short ThumbRX; // -32768 to 32767 - public short ThumbRY; // -32768 to 32767 -} -``` - -**Wire format:** Fixed 14 bytes, packed structure - -### Xbox360 Output (Rumble) - -```csharp -// Two bytes: left motor + right motor (0-255 each) -byte leftMotor = data[0]; -byte rightMotor = data[1]; -``` - -### Mouse Input - -```csharp -public struct MouseInput -{ - public byte Buttons; // Button flags - public sbyte X; // Relative X movement (-128 to 127) - public sbyte Y; // Relative Y movement (-128 to 127) - public sbyte Wheel; // Vertical scroll - public sbyte Pan; // Horizontal scroll -} -``` - -**Wire format:** Fixed 5 bytes, packed structure - ## Configuration and Advanced Usage ### Custom Timeouts @@ -455,31 +331,6 @@ cd examples/csharp/virtual_keyboard dotnet run -- localhost ``` -## Project Structure - -Generated client library layout: - -```text -clients/csharp/Viiper.Client/ -├── ViiperClient.cs # Management API client -├── ViiperDevice.cs # Device stream wrapper -├── Types/ -│ ├── BusListResponse.cs # API response types -│ ├── BusCreateResponse.cs -│ └── ... -└── Devices/ - ├── Keyboard/ - │ ├── KeyboardInput.cs # Wire format struct - │ └── KeyboardConstants.cs # Enums + maps - ├── Mouse/ - │ ├── MouseInput.cs - │ └── MouseConstants.cs - └── Xbox360/ - ├── Xbox360Input.cs - ├── Xbox360Output.cs - └── Xbox360Constants.cs -``` - ## Troubleshooting **Build Errors:** @@ -510,7 +361,3 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C - [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 - ---- - -For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/generator.md b/docs/clients/generator.md index dd7ce2a6..f3e1dc0f 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -224,7 +224,3 @@ Run codegen when any of these change: - [C Client Library Documentation](c.md): C-specific usage, build, and examples - [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 - ---- - -For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/go.md b/docs/clients/go.md index cd089c86..ce422174 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 `/apiclient` and `/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 @@ -76,11 +75,11 @@ func main() { } ``` -## Device Stream API +## Device Control/Feedback API ### Creating and Connecting -// The simplest way to add a device and open its stream (nil opts): +The simplest way to add a device and open its control stream (nil opts): ```go // Use default VID/PID for the device type @@ -93,35 +92,10 @@ defer stream.Close() log.Printf("Connected to device %s", resp.ID) ``` -// Or specify VID/PID using CreateOptions: - -```go -opts := &device.CreateOptions{ - IdVendor: func() *uint16 { v := uint16(0x1234); return &v }(), - IdProduct: func() *uint16 { p := uint16(0x5678); return &p }(), -} -stream2, resp2, err := client.AddDeviceAndConnect(ctx, busID, "keyboard", opts) -if err != nil { - log.Fatal(err) -} -defer stream2.Close() - -log.Printf("Connected to device %s (custom VID/PID)", resp2.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 "github.com/Alia5/VIIPER/device/xbox360" @@ -136,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: @@ -171,15 +145,18 @@ 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 `/device/` with type-safe structs and constants (e.g., `keyboard.InputState`, `keyboard.KeyA`, `mouse.Btn_Left`). @@ -226,6 +203,7 @@ Full working examples are available in the repository: - **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` +- More examples are always being added! ## See Also diff --git a/docs/clients/rust.md b/docs/clients/rust.md index bbd91541..bb9b0ff4 100644 --- a/docs/clients/rust.md +++ b/docs/clients/rust.md @@ -2,8 +2,6 @@ The VIIPER Rust client library provides a type-safe, zero-cost abstraction client library for interacting with VIIPER servers and controlling virtual devices. -## Overview - The Rust client library features: - **Sync and Async APIs**: Choose between blocking `ViiperClient` or async `AsyncViiperClient` (with `async` feature) @@ -46,170 +44,151 @@ Use this when modifying the generator or contributing new device types: viiper-client = { path = "../../clients/rust" } ``` -### 3. Generating from Source (Advanced / Contributors) - -Only required when enhancing VIIPER itself: - -```bash -go run ./cmd/viiper codegen --lang=rust -cd clients/rust -cargo build --release -``` - -## Quick Start (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); +## 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), + }; - // 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, + }, + ).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"); - // 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, - }, - ).expect("Failed to add device"); + // 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), + }; - // Connect to device stream - let mut stream = client - .connect_device(device_info.bus_id, &device_info.dev_id) - .expect("Failed to connect"); + // 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, + }, + ).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"); - println!("Connected to device {} on bus {}", device_info.dev_id, device_info.bus_id); + // Cleanup + let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)).await; + } + ``` - // Send keyboard input - let input = KeyboardInput { - modifiers: MOD_LEFT_SHIFT, - count: 1, - keys: vec![KEY_H], - }; - stream.send(&input).expect("Failed to send input"); +## Device Control/Feedback - // Cleanup - let _ = client.bus_device_remove(device_info.bus_id, Some(&device_info.dev_id)); -} -``` +### Creating a Device + Control/Feedback Stream -## Quick Start (Async) +=== "Sync" -```rust -use tokio::time::{sleep, Duration}; -use viiper_client::{AsyncViiperClient, devices::keyboard::*}; -use std::net::ToSocketAddrs; + ```rust + use viiper_client::{ViiperClient, types::DeviceCreateRequest}; + 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 client = ViiperClient::new(addr); + + // Add device first let device_info = client.bus_device_add( bus_id, - &viiper_client::types::DeviceCreateRequest { - r#type: Some("keyboard".to_string()), + &DeviceCreateRequest { + r#type: Some("xbox360".to_string()), id_vendor: None, id_product: None, }, - ).await.expect("Failed to add device"); + ).expect("Failed to add device"); - // Connect to device stream + // Then connect to its 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 Stream API - -### Creating a Device 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"); -``` - -With custom VID/PID: - -```rust -let device_info = client.bus_device_add( - bus_id, - &DeviceCreateRequest { - r#type: Some("keyboard".to_string()), - id_vendor: Some(0x1234), - id_product: Some(0x5678), - }, -).expect("Failed to add device"); -``` + ``` ### Sending Input @@ -230,62 +209,75 @@ let input = Xbox360Input { stream.send(&input).expect("Failed to send"); ``` -### Receiving Output (Callbacks) +### Receiving Feedback For devices that send feedback (rumble, LEDs), register a callback with `on_output`: -**Sync API:** - -```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"); -``` - -**Async API:** - -```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| { - 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"); -``` +=== "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 @@ -352,114 +344,6 @@ use viiper_client::devices::keyboard::SHIFT_CHARS; let needs_shift = SHIFT_CHARS.contains(&b'A'); // true for uppercase ``` -## Practical Example: Typing Text - -Using the generated maps to type a string: - -```rust -use std::thread; -use std::time::Duration; -use viiper_client::{DeviceStream, devices::keyboard::*}; - -fn type_string(stream: &mut DeviceStream, text: &str) -> Result<(), viiper_client::ViiperError> { - for ch in text.chars() { - let byte = ch as u8; - let key = match CHAR_TO_KEY.get(&byte) { - Some(&k) => k, - None => continue, - }; - - let mods = if SHIFT_CHARS.contains(&byte) { - MOD_LEFT_SHIFT - } else { - 0 - }; - - // Press - let down = KeyboardInput { - modifiers: mods, - count: 1, - keys: vec![key], - }; - stream.send(&down)?; - thread::sleep(Duration::from_millis(50)); - - // Release - let up = KeyboardInput { - modifiers: 0, - count: 0, - keys: vec![], - }; - stream.send(&up)?; - thread::sleep(Duration::from_millis(50)); - } - Ok(()) -} - -// Usage -type_string(&mut stream, "Hello, World!")?; -``` - -## Device-Specific Wire Formats - -### Keyboard Input - -```rust -pub struct KeyboardInput { - pub modifiers: u8, // Modifier flags (Ctrl, Shift, Alt, GUI) - pub count: u8, // Number of keys in keys vec - pub keys: Vec, // Key codes (max 6 for HID compliance) -} -``` - -**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) - -### Keyboard Output (LEDs) - -```rust -// Single byte with LED flags -let leds = buf[0]; -let num_lock = (leds & LED_NUM_LOCK) != 0; -``` - -### Xbox360 Input - -```rust -pub struct Xbox360Input { - pub buttons: u32, // Button flags - pub lt: u8, // Left trigger (0-255) - pub rt: u8, // Right trigger (0-255) - pub lx: i16, // Left stick X (-32768 to 32767) - pub ly: i16, // Left stick Y (-32768 to 32767) - pub rx: i16, // Right stick X (-32768 to 32767) - pub ry: i16, // Right stick Y (-32768 to 32767) -} -``` - -**Wire format:** Fixed 14 bytes, packed structure (little-endian) - -### Xbox360 Output (Rumble) - -```rust -// Two bytes: left motor + right motor (0-255 each) -let left_motor = buf[0]; -let right_motor = buf[1]; -``` - -### Mouse Input - -```rust -pub struct MouseInput { - pub buttons: u8, // Button flags - pub dx: i8, // Relative X movement (-128 to 127) - pub dy: i8, // Relative Y movement (-128 to 127) - pub wheel: i8, // Vertical scroll - pub pan: i8, // Horizontal scroll -} -``` - -**Wire format:** Fixed 5 bytes, packed structure - ## Error Handling The client library uses a custom `ViiperError` type for all errors: @@ -475,7 +359,8 @@ match client.bus_list() { } ``` -The server returns errors as RFC 7807 Problem JSON. The client parses these into `ProblemJson`: +The server returns errors as RFC 7807 Problem inspired JSON. +The client parses these into `ProblemJson`: ```rust use viiper_client::ProblemJson; @@ -522,69 +407,6 @@ Full working examples are available in the repository: - Cycles through buttons - Handles rumble feedback -### Running Examples - -```bash -cd examples/rust - -# Sync examples -cargo run --release -p virtual_keyboard_sync -- localhost:3242 -cargo run --release -p virtual_mouse_sync -- localhost:3242 -cargo run --release -p virtual_x360_pad_sync -- localhost:3242 - -# Async examples -cargo run --release -p virtual_keyboard_async -- localhost:3242 -cargo run --release -p virtual_mouse_async -- localhost:3242 -cargo run --release -p virtual_x360_pad_async -- localhost:3242 -``` - -## Project Structure - -Generated client library layout: - -```text -clients/rust/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # Re-exports -│ ├── client.rs # Sync ViiperClient + DeviceStream -│ ├── async_client.rs # Async ViiperClient (feature = "async") -│ ├── error.rs # ViiperError, ProblemJson -│ ├── types.rs # API request/response types -│ ├── wire.rs # DeviceInput/DeviceOutput traits -│ └── devices/ -│ ├── mod.rs -│ ├── keyboard/ -│ │ ├── mod.rs -│ │ ├── input.rs # KeyboardInput struct -│ │ ├── output.rs # Output parsing -│ │ └── constants.rs # Keys, mods, LEDs, maps -│ ├── mouse/ -│ │ └── ... -│ └── xbox360/ -│ └── ... -``` - -## Troubleshooting - -**Connection refused:** - -Verify VIIPER server is running and listening on the expected API port (default 3242). - -```rust -use std::net::SocketAddr; -let addr: SocketAddr = "127.0.0.1:3242".parse().expect("Invalid address"); -let client = ViiperClient::new(addr); -``` - -**Feature not found errors:** - -Make sure to enable the `async` feature if using `AsyncViiperClient`: - -```toml -viiper-client = { version = "0.1", features = ["async"] } -``` - ## See Also - [Generator Documentation](generator.md): How generated client libraries work @@ -595,7 +417,3 @@ viiper-client = { version = "0.1", features = ["async"] } - [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 - ---- - -For questions or contributions, see the main VIIPER repository. diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md index 0553871a..6f34ae0d 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -2,14 +2,10 @@ The VIIPER TypeScript client library provides a modern, type-safe Node.js client library for interacting with VIIPER servers and controlling virtual devices. -## Overview - 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) -- **Auto-generated**: Generated from server code with device-specific Input/Output classes -- **Modern Node.js**: Targets Node.js 18+ with ES modules - **Zero external dependencies**: Uses only built-in Node.js libraries !!! note "License" @@ -18,7 +14,7 @@ The TypeScript client library features: ## Installation -### 1. Using the Published Package (Recommended) +### 1. Using the Published Package Install the client library from the public npm registry: @@ -26,17 +22,10 @@ Install the client library from the public npm registry: npm install viiperclient ``` -Or with pnpm / yarn: - -```bash -pnpm add viiperclient -# or -yarn add viiperclient -``` - The latest stable version is tagged as `latest`. -> 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. +!!! "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: @@ -69,18 +58,7 @@ npm install npm run build ``` -### 3. Generating from Source (Advanced / Contributors) - -This is only required if you are contributing to VIIPER or adding device types. Normal users should use the npm package. - -```bash -go run ./cmd/viiper codegen --lang=typescript -cd clients/typescript -npm install -npm run build -``` - -## Quick Start +## Example ```typescript import { ViiperClient, Keyboard } from "viiperclient"; @@ -119,9 +97,9 @@ await device.send(input); await client.busdeviceremove(busID, response.devId); ``` -## Device Stream API +## Device Control/Feedback -### Creating a Device Stream +### Creating a Device + Control/Feedback Stream The simplest way to add a device and connect: @@ -130,17 +108,6 @@ const deviceReq = { type: "xbox360" }; const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); ``` -With custom VID/PID: - -```typescript -const deviceReq = { - type: "keyboard", - idVendor: 0x1234, - idProduct: 0x5678 -}; -const { device, response } = await client.addDeviceAndConnect(busID, deviceReq); -``` - Or manually add and connect: ```typescript @@ -175,7 +142,7 @@ const input = new Xbox360Input({ await device.send(input); ``` -### Receiving Output (Events) +### Receiving Feedback For devices that send feedback (rumble, LEDs), subscribe to the `output` event: @@ -212,6 +179,8 @@ device.on("output", (data: Buffer) => { 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: @@ -333,115 +302,6 @@ const { ShiftCharsHas } = Keyboard; const needsShift = ShiftCharsHas('A'.codePointAt(0)!); // true for uppercase ``` -## Practical Example: Typing Text - -Using the generated maps to type a string: - -```typescript -import { ViiperDevice, Keyboard } from "viiperclient"; - -const { KeyboardInput, CharToKeyGet, ShiftCharsHas, Mod } = Keyboard; - -async function typeString(device: ViiperDevice, text: string): Promise { - for (const ch of text) { - const cp = ch.codePointAt(0)!; - const key = CharToKeyGet(cp); - if (key === undefined) continue; - - const mods = ShiftCharsHas(cp) ? Mod.LeftShift : 0; - - // Press - await device.send(new KeyboardInput({ - Modifiers: mods, - Count: 1, - Keys: [key] - })); - await new Promise(r => setTimeout(r, 50)); - - // Release - await device.send(new KeyboardInput({ - Modifiers: 0, - Count: 0, - Keys: [] - })); - await new Promise(r => setTimeout(r, 50)); - } -} - -// Usage -await typeString(device, "Hello, World!"); -``` - -## Device-Specific Wire Formats - -### Keyboard Input - -```typescript -interface KeyboardInput { - Modifiers: number; // Modifier flags (Ctrl, Shift, Alt, GUI) - Count: number; // Number of keys in Keys array - Keys: number[]; // Key codes (max 6 for HID compliance) -} -``` - -**Wire format:** 1 byte modifiers + 1 byte count + N bytes keys (variable-length) - -### Keyboard Output (LEDs) - -```typescript -// Single byte with LED flags -const leds = data.readUInt8(0); -const numLock = (leds & LED.NumLock) !== 0; -``` - -### Xbox360 Input - -```typescript -interface Xbox360Input { - Buttons: number; // Button flags - Lt: number; // Left trigger (0-255) - Rt: number; // Right trigger (0-255) - Lx: number; // Left stick X (-32768 to 32767) - Ly: number; // Left stick Y (-32768 to 32767) - Rx: number; // Right stick X (-32768 to 32767) - Ry: number; // Right stick Y (-32768 to 32767) -} -``` - -**Wire format:** Fixed 14 bytes, packed structure - -### Xbox360 Output (Rumble) - -```typescript -// Two bytes: left motor + right motor (0-255 each) -const leftMotor = data.readUInt8(0); -const rightMotor = data.readUInt8(1); -``` - -### Mouse Input - -```typescript -interface MouseInput { - Buttons: number; // Button flags - Dx: number; // Relative X movement (-32768 to 32767) - Dy: number; // Relative Y movement (-32768 to 32767) - Wheel: number; // Vertical scroll (-32768 to 32767) - Pan: number; // Horizontal scroll (-32768 to 32767) -} -``` - -**Wire format:** Fixed 9 bytes, int16 values little-endian - -## Configuration and Advanced Usage - -### Custom Port - -```typescript -const client = new ViiperClient("localhost", 3242); -``` - -Default port is 3242 if not specified. - ### Error Handling The server returns errors as JSON. The client throws exceptions: @@ -454,7 +314,7 @@ try { } ``` -Stream errors are surfaced through the EventEmitter error event: +Device Control/Feedback stream errors are surfaced through the EventEmitter error event: ```typescript device.on('error', (err) => { @@ -501,15 +361,6 @@ npm run build node dist/virtual_keyboard.js localhost:3242 ``` -## Troubleshooting - -Some quick troubleshooting tips for the TypeScript SDK and device streams: - -- Connection refused / timeout: Verify VIIPER server is running and listening on the expected API port (default 3242). Ensure firewall/ACLs allow TCP connections. -- Unexpected response or parse errors: The VIIPER API uses null-byte (\x00) terminated requests. Use the provided SDK helper methods or ensure raw sockets append a null terminator when calling the server. -- Stream closed unexpectedly: Confirm the device stream was opened (device added and connected) and that the device handler did not time out (default 5s reconnect window). Check server logs for reasons. -- Use examples: See the repository examples in `examples/typescript/` for working end-to-end samples that demonstrate bus creation, device streams, and cleanup. - ## See Also - [Generator Documentation](generator.md): How generated client libraries work @@ -520,7 +371,3 @@ Some quick troubleshooting tips for the TypeScript SDK and device streams: - [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 - ---- - -For questions or contributions, see the main VIIPER repository. diff --git a/docs/devices/dualshock4.md b/docs/devices/dualshock4.md index a2299ba4..2c866a51 100644 --- a/docs/devices/dualshock4.md +++ b/docs/devices/dualshock4.md @@ -1,53 +1,89 @@ # 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), and touchpad finger coordinates. +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), and touchpad finger coordinates. -- USB IDs: VID `0x054C` (Sony), PID `0x05C4` (DualShock 4, v1) -- Device type id (for API add): `dualshock4` +Use `dualshock4` 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/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 them via the device stream. +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. -See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) +You don't need to manually construct packets, just use the provided types +and send/receive them via the device control and feedback stream. -## Adding the device +See: [API Reference](../api/overview.md) -Using the raw API (see [API Reference](../api/overview.md) for details): +## (RAW) Streaming protocol -```bash -# Create a bus -printf "bus/create\0" | nc localhost 3242 +The device stream is a bidirectional, raw TCP connection with fixed-size packets. -# Add DualShock 4 device with JSON payload -printf 'bus/1/add {"type":"dualshock4"}\0' | nc localhost 3242 -``` +### 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 °/s) + - Accelerometer: AccelX, AccelY, AccelZ: int16 each (6 bytes, fixed-point m/s²) -Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. +See `/device/dualshock4/inputstate.go` for details. -## Device stream protocol +### Feedback (Rumble & LED) -The DS4 device stream is a bidirectional TCP stream with **fixed-size** packets. +- 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. -### Device Input +## Reference -- 31-byte packets (little-endian): - - `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` +### Button Constants -See `/device/dualshock4/inputstate.go` +| 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 | -#### Touchpad coordinates +### D-Pad Constants -Touch coordinates are sent as `touch{1,2}X: u16` and `touch{1,2}Y: u16` plus an explicit boolean `touch{1,2}Active`. +| 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: @@ -56,7 +92,9 @@ VIIPER clamps touch coordinates to the DS4 range: These are the bounds used by VIIPER’s DS4 implementation; see `/device/dualshock4/const.go`. -#### Gyro + accelerometer fixed-point units +### IMU (Gyro + Accelerometer) + +#### Fixed-Point Physical Units VIIPER uses **fixed-point physical units** for IMU values on the wire (still stored as `int16`), to avoid float serialization differences across client languages. @@ -65,16 +103,16 @@ Constants (see `/device/dualshock4/const.go`): - `GyroCountsPerDps = 16` - `AccelCountsPerMS2 = 512` -##### Formulas +#### Conversion Formulas -Gyro (degrees/second): +**Gyro (degrees/second):** ```text raw_gyro = round(gyro_dps * GyroCountsPerDps) gyro_dps = raw_gyro / GyroCountsPerDps ``` -Accelerometer (m/s²): +**Accelerometer (m/s²):** ```text raw_accel = round(accel_ms2 * AccelCountsPerMS2) @@ -85,16 +123,16 @@ accel_ms2 = raw_accel / AccelCountsPerMS2 With the default scales: -- Gyro (`GyroCountsPerDps = 16`): - - Resolution: `1/16 = 0.0625 °/s` - - Approx max magnitude: `32767/16 ≈ 2048 °/s` -- Accelerometer (`AccelCountsPerMS2 = 512`): - - Resolution: `1/512 ≈ 0.001953125 m/s²` - - Approx max magnitude: `32767/512 ≈ 64 m/s²` (≈ 6.5 g) +- **Gyro** (`GyroCountsPerDps = 16`): + - Resolution: `1/16 = 0.0625 °/s` + - Approx max magnitude: `32767/16 ≈ 2048 °/s` +- **Accelerometer** (`AccelCountsPerMS2 = 512`): + - Resolution: `1/512 ≈ 0.001953125 m/s²` + - Approx max magnitude: `32767/512 ≈ 64 m/s²` (≈ 6.5 g) Conversions saturate to the `int16` range if inputs exceed representable values. -#### Default (neutral) report gravity +#### Default (Neutral) Report Gravity On device creation, VIIPER initializes the accelerometer to represent a controller lying flat on a table, with gravity "downwards": @@ -108,24 +146,3 @@ In raw fixed-point units, this means: - `AccelZ = round(-9.81 * 512) = -5023` Helpers for converting between physical units and raw values are provided in `/device/dualshock4/helpers.go`. - -## Device Output - -The host sends rumble/LED updates which VIIPER parses and forwards to your client as a compact feedback packet. - -Direction: server → client (feedback) - -- 7-byte packets: - - `RumbleSmall: u8` - - `RumbleLarge: u8` - - `LedRed: u8` - - `LedGreen: u8` - - `LedBlue: u8` - - `FlashOn: u8` (units of 2.5ms) - - `FlashOff: u8` (units of 2.5ms) - -See `/device/dualshock4/inputstate.go` for the `OutputState` wire definition. - -## Examples - -A minimal example that drives a DS4 device is provided in `examples/go/virtual_ds4/`. diff --git a/docs/devices/keyboard.md b/docs/devices/keyboard.md index aaf1b0c2..ad38cd54 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -1,75 +1,60 @@ -# HID Keyboard - -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` - -## 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: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) - -## 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: - -- `/device/keyboard/helpers.go`: TypeString, TypeChar, PressKey, Release, etc. -- `/device/keyboard/const.go`: Modifiers, LED bits, and HID usage IDs, including media keys (Mute, VolumeUp/Down, PlayPause, Stop, Next, Previous) - -## Adding the device - -Using the raw API (see [API Reference](../api/overview.md) for details): - -```bash -# Create a bus -printf "bus/create\0" | nc localhost 3242 - -# Add keyboard device with JSON payload -printf 'bus/1/add {"type":"keyboard"}\0' | nc localhost 3242 -``` - -Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. - -## 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). + +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 currently 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 for common operations are in `/device/keyboard/helpers.go`. diff --git a/docs/devices/mouse.md b/docs/devices/mouse.md index 9990fd00..615b669f 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -1,53 +1,40 @@ -# HID Mouse - -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` - -## 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: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) - -## HID report format (host-facing) - -Input (device → host): 9 bytes - -- Byte 0: Buttons bitfield (bits 0..4 for buttons 1..5) -- Bytes 1-2: X delta (int16 little-endian, -32768 to +32767) -- Bytes 3-4: Y delta (int16 little-endian, -32768 to +32767) -- Bytes 5-6: Vertical wheel (int16 little-endian; positive up) -- Bytes 7-8: Horizontal wheel/pan (int16 little-endian; 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 9-byte packets matching the HID report layout: - [Buttons, dX_lo, dX_hi, dY_lo, dY_hi, Wheel_lo, Wheel_hi, Pan_lo, Pan_hi] - -Buttons persist until changed; motion/wheel deltas are applied once and reset. - -## Adding the device - -Using the raw API (see [API Reference](../api/overview.md) for details): - -```bash -# Create a bus -printf "bus/create\0" | nc localhost 3242 - -# Add mouse device with JSON payload -printf 'bus/1/add {"type":"mouse"}\0' | nc localhost 3242 -``` - -Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. - -## 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. + +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. diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 32e48a71..667b6ec6 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -1,52 +1,59 @@ # 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` +Use `xbox360` 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/xbox360`), 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. +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. -See: [Go Client](../clients/go.md), [Generated Client Libraries](../clients/generator.md) +You don't need to manually construct packets, just use the provided types +and send/receive them via the device control and feedback stream. -## Adding the device +See: [API Reference](../api/overview.md) -Use the API to create a bus and add an Xbox 360 controller. Using the raw API (see [API Reference](../api/overview.md) for details): - -```bash -# Create a bus -printf "bus/create\0" | nc localhost 3242 - -# Add xbox360 device with JSON payload -printf 'bus/1/add {"type":"xbox360"}\0' | nc localhost 3242 -``` - -The API returns a Device object with `busId`, `devId`, and other details. Attach it from a USB/IP client, then open a stream to drive input and receive rumble. - -Or use one of the [client libraries](../clients/generator.md) which handle the protocol automatically. - -## Streaming protocol +## (RAW) Streaming protocol The device stream is a bidirectional, raw TCP connection with fixed-size packets. -Direction: client → server (input state) +### Input State -- 14-byte packets, little-endian layout: - - Buttons: uint32 (4 bytes) - - LT, RT: uint8, uint8 (2 bytes) - - LX, LY, RX, RY: int16 each (8 bytes) +- 14-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 -Direction: server → client (rumble feedback) +### Rumble Feedback -- 2-byte packets: - - LeftMotor: uint8, RightMotor: uint8 +- 2-byte packets: + - LeftMotor: uint8, RightMotor: uint8 + 0-255 intensity values See `/device/xbox360/inputstate.go` for details. -## Example - -A minimal example program that sends input and reads rumble is provided in `examples/`. +### 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 | diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 5105aa0d..6d28bca7 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,10 +1,8 @@ # Quick Start -This guide walks you through setting up VIIPER and creating your first virtual device. +## 📋 Prerequisites -## Prerequisites - -Before starting, ensure you have: +Ensure you have: 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) @@ -20,78 +18,120 @@ viiper server This starts two services: - **USBIP Server** on port `3241` (standard USBIP protocol) -- **API Server** on port `3242` (device management) +- **VIIPER API Server** on port `3242` (management and device interactions) !!! 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. -### Custom Ports +!!! info "Custom Ports" + To use different ports: -To use different ports: + ```bash + viiper server --usb.addr=:9000 --api.addr=:9001 + ``` -```bash -viiper server --usb.addr=:9000 --api.addr=:9001 -``` + See [CLI Reference](../cli/overview.md) for all available options. -## Creating Your First Virtual Device +## 🎮 Creating Your First Virtual Device -VIIPER provides multiple ways to interact with the API. Choose the method that works best for you. +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 available for C, C#, Go, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. +Client libraries are (at time of writing) available for C, 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/c.md) +- [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. -### Option 2: Using Raw TCP (netcat) +### Example -For quick testing without client libraries: +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) -```bash -# Create a bus -printf "bus/create\0" | nc localhost 3242 -# Response: {"busId":1} +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; -# 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"} +const client = new ViiperClient("localhost", 3242); +const bus_create_response = await client.buscreate(); -# List devices on the bus -printf "bus/1/list\0" | nc localhost 3242 +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, +})); ``` -!!! note "Protocol Details" - The API uses TCP with null-byte (`\0`) terminated requests. See [API Reference](../api/overview.md) for complete protocol documentation. +### Option 2: Using Raw TCP + +VIIPER provides a lightweight TCP API for direct interaction. +See: [API Reference](../api/overview.md) for complete documentation. -### Option 3: Using PowerShell Helper Script +For quick testing you can use `netcat` on Linux or the provided PowerShell helper script on Windows. -VIIPER includes a PowerShell helper script for Windows users: +=== "Netcat" -```powershell -# Load the helper script -. .\scripts\viiper-api.ps1 + ```bash + # Create a bus + printf "bus/create\0" | nc localhost 3242 + # Response: {"busId":1} -# Create a bus -Invoke-ViiperAPI "bus/create" + # 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"} -# Add a device -Invoke-ViiperAPI 'bus/1/add {"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) +## 🔌 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 your device manager or `lsusb` to confirm. + 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 @@ -128,13 +168,13 @@ If auto-attach is disabled or you're connecting from a remote machine: # Check Device Manager to verify attachment ``` -## Available Device Types +## 🧰 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 +## ➡️ Next Steps Now that you have a working setup: @@ -143,7 +183,7 @@ Now that you have a working setup: 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 +## 🆘 Troubleshooting ### Server Won't Start @@ -226,7 +266,7 @@ Or if manually attaching devices, use `sudo` with the `usbip attach` command: sudo usbip attach --remote=localhost --tcp-port=3241 --busid=1-1 ``` -## See Also +## 🔗 See Also - [CLI Reference](../cli/overview.md) - Complete command documentation - [API Reference](../api/overview.md) - Management API protocol 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/mkdocs.yml b/mkdocs.yml index ecf92993..32478ad4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,12 +69,12 @@ nav: - API & Clients: - API Overview: api/overview.md - Go Client: clients/go.md - - Generator Documentation: clients/generator.md - C Client Library: clients/c.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 - Devices: - Xbox 360 Controller: devices/xbox360.md - DualShock 4 Controller: devices/dualshock4.md From 346229ddf5da6e68c493c293208aae5432484d7f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 17 Jan 2026 00:22:46 +0100 Subject: [PATCH 118/298] relax codecov... --- .github/workflows/build_base.yml | 2 +- codecov.yml | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index eeaf4a45..0f78a37e 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -54,7 +54,7 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} directory: . - fail_ci_if_error: true + fail_ci_if_error: false build: name: Build binaries (${{ matrix.target.goos }}/${{ matrix.target.goarch }}) diff --git a/codecov.yml b/codecov.yml index 1d882aa6..878b321d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,3 +2,13 @@ ignore: - "examples/go/**/*" - "**/_testing/**/*.go" - "cmd/viiper/**/*" + - "docs" + +coverage: + status: + project: + default: + informational: true + +assume: + utf_8: true From fb4b62da1ff50f2afc9f59a54509dc8bd5ec3231 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 19 Jan 2026 11:25:08 +0100 Subject: [PATCH 119/298] Update README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ecf31fe1..4b215697 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,9 @@ For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have 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. -> ⚠️ Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. -See [Client Libraries Available](docs/api/overview.md). +> [!TIP] +Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. +See [Client Libraries](docs/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). From b3beeb1f87868d0ff3d52a956a2cb3ad32a0147e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 19 Jan 2026 16:39:38 +0100 Subject: [PATCH 120/298] Configurable write batching time Changelog(misc) --- _testing/e2e/bench_test.go | 9 +++++---- internal/server/usb/config.go | 7 ++++--- internal/server/usb/server.go | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 062997ed..fedc36f5 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -107,11 +107,12 @@ func Benchmark_Xbox360_Delay(b *testing.B) { s := cmd.Server{ UsbServerConfig: usb.ServerConfig{ - Addr: ":3241", - BusCleanupTimeout: 1 * time.Second, + Addr: ":3244", + BusCleanupTimeout: 1 * time.Second, + WriteBatchFlushInterval: 0, }, ApiServerConfig: api.ServerConfig{ - Addr: ":3242", + Addr: ":3245", AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, }, @@ -125,7 +126,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { panic(err) } }() - c := apiclient.New("localhost:3242") + c := apiclient.New("localhost:3245") var busResp *apitypes.BusCreateResponse var err error for range 10 { diff --git a/internal/server/usb/config.go b/internal/server/usb/config.go index 8b634da6..5c0cc814 100644 --- a/internal/server/usb/config.go +++ b/internal/server/usb/config.go @@ -4,7 +4,8 @@ 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:"-"` + 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 `help:"Interval to flush write batches to clients; 0 to disable" default:"1ms" env:"VIIPER_USB_WRITE_BATCH_FLUSH_INTERVAL"` } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 357dcab1..98f18342 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -36,7 +36,6 @@ const ( // avoid windows socket overhead while keeping latency very low. writeBatcherBufferSize = 256 * 1024 - writeBatcherFlushEvery = 1 * time.Millisecond writeBatcherFlushAtBytes = 64 * 1024 ) @@ -558,8 +557,15 @@ func (lc *logConn) Write(p []byte) (int, error) { func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { _ = conn.SetDeadline(time.Time{}) - bw := newBatchingWriter(conn, writeBatcherBufferSize, writeBatcherFlushEvery, writeBatcherFlushAtBytes) - defer func() { _ = bw.Close() }() + 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 { @@ -633,7 +639,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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(bw) + _ = ret.Write(writer) continue } if cmd != usbip.CmdSubmitCode { @@ -671,11 +677,11 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { if err := ret.Write(&out); err != nil { return fmt.Errorf("build RET_SUBMIT header: %w", err) } - if _, err := bw.Write(out.Bytes()); err != nil { + if _, err := writer.Write(out.Bytes()); err != nil { return fmt.Errorf("write RET_SUBMIT: %w", err) } if len(respData) > 0 { - if _, err := bw.Write(respData); err != nil { + if _, err := writer.Write(respData); err != nil { return fmt.Errorf("write RET_SUBMIT payload: %w", err) } } From fce27cb9ff5a7e6408ae4442bb3b28163c9b9649 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Wed, 21 Jan 2026 10:58:01 +0100 Subject: [PATCH 121/298] Update Readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b215697..8a2120a6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ These virtual devices are indistinguishable from real hardware to the operating - VIIPER abstracts away all USB / USBIP details. - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. + (USBIP still requires a kernel driver, but this is generic and device _emulation_ code still lives in Userspace) - Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. VIIPER _currently_ comes in a single flavor: @@ -168,7 +169,8 @@ This works with Steam, native Windows games, and any other application supportin ### How is VIIPER different from other controller emulators? Most controller emulators require custom kernel drivers for each device type. -VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without kernel drivers. +VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without needing to develop specialized kernel drivers. +(On Windows, a USBIP-Kernel driver is still required, but that driver is generic and doesn't care about the type of USB devices; Device _emulation_ code still lives in Userspace) This makes VIIPER portable, easier to extend, and simpler to bundle with applications. ### Can I add support for other device types? From 7ffbe498e61325c3f25e67d063a29acc410d7ce3 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 24 Jan 2026 14:55:59 +0100 Subject: [PATCH 122/298] Update copyright --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a2120a6..2191b71b 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ _Note_: Actual device polling rates may be lower depending on the device type an ```license VIIPER - Virtual Input over IP EmulatoR -Copyright (C) 2025 Peter Repukat +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 From 796a8cca283a8b2f0bdd66e4bc5db1284de2d68c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 26 Jan 2026 01:20:33 +0100 Subject: [PATCH 123/298] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2191b71b..428d6e21 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ [![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) [![C Client Library](https://img.shields.io/badge/C_Client_Library-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) [![C++ Client Library](https://img.shields.io/badge/C++_Client_Library-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) +[![Discord](https://img.shields.io/discord/368823110817808384?logo=discord&logoColor=white&label=Discord&color=%23535fe5 +)](https://discord.gg/hs34MtcHJY) # VIIPER 🐍 From d7ffa0b7b9fc62ee5436a68a2d088283e5de5fd7 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 26 Jan 2026 18:21:29 +0100 Subject: [PATCH 124/298] Update docs --- docs/misc/support.md | 23 +++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 24 insertions(+) create mode 100644 docs/misc/support.md 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/mkdocs.yml b/mkdocs.yml index 32478ad4..eebf1660 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,4 +80,5 @@ nav: - DualShock 4 Controller: devices/dualshock4.md - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md + - Community & Support: misc/support.md - Changelog: changelog/ From 424e87317c913d98b8ee89032d96a1ca214fe0d6 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 30 Jan 2026 16:49:18 +0100 Subject: [PATCH 125/298] SECURITY: Fix unauthenticated/unencrypted remote traffic Still allows for plain traffic ON LOCALHOST if the client sends plain requests - except disabled per config --- _testing/test_server.go | 10 +- apiclient/client.go | 5 + apiclient/stream.go | 19 ++ apiclient/stream_test.go | 116 ++++++++ apiclient/transport.go | 64 +++-- apiclient/transport_test.go | 113 ++++++++ apitypes/structs.go | 17 +- go.mod | 8 +- go.sum | 18 +- internal/cmd/server.go | 35 +++ internal/codegen/scanner/payload.go | 6 +- internal/server/api/auth/auth.go | 55 ++++ internal/server/api/auth/auth_test.go | 96 +++++++ internal/server/api/auth/conn.go | 86 ++++++ internal/server/api/auth/conn_test.go | 188 ++++++++++++ internal/server/api/auth/handshake.go | 142 +++++++++ internal/server/api/auth/handshake_test.go | 270 ++++++++++++++++++ internal/server/api/config.go | 3 + .../server/api/device_stream_handler_test.go | 18 +- internal/server/api/error/apierror.go | 31 ++ internal/server/api/errors.go | 29 -- internal/server/api/handler/bus_create.go | 13 +- internal/server/api/handler/bus_device_add.go | 23 +- .../server/api/handler/bus_device_remove.go | 13 +- .../server/api/handler/bus_devices_list.go | 9 +- internal/server/api/handler/bus_remove.go | 9 +- internal/server/api/server.go | 154 +++++++--- internal/server/api/server_test.go | 200 ++++++++++++- 28 files changed, 1590 insertions(+), 160 deletions(-) create mode 100644 internal/server/api/auth/auth.go create mode 100644 internal/server/api/auth/auth_test.go create mode 100644 internal/server/api/auth/conn.go create mode 100644 internal/server/api/auth/conn_test.go create mode 100644 internal/server/api/auth/handshake.go create mode 100644 internal/server/api/auth/handshake_test.go create mode 100644 internal/server/api/error/apierror.go delete mode 100644 internal/server/api/errors.go diff --git a/_testing/test_server.go b/_testing/test_server.go index 30193c5c..8228c835 100644 --- a/_testing/test_server.go +++ b/_testing/test_server.go @@ -17,10 +17,9 @@ type MockServer struct { UsbServer *usb.Server } -func NewTestServer(t *testing.T) *MockServer { +func NewTestServerWithConfig(t *testing.T, cfg *config.CLI) *MockServer { t.Helper() - cfg := TestServerConfig(t) logger := slog.Default() usbServer := usb.New(cfg.Server.UsbServerConfig, logger, nil) @@ -52,6 +51,13 @@ func NewTestServer(t *testing.T) *MockServer { } } +func NewTestServer(t *testing.T) *MockServer { + t.Helper() + + cfg := TestServerConfig(t) + return NewTestServerWithConfig(t, cfg) +} + func TestServerConfig(t *testing.T) *config.CLI { t.Helper() diff --git a/apiclient/client.go b/apiclient/client.go index 27046895..03a6346a 100644 --- a/apiclient/client.go +++ b/apiclient/client.go @@ -19,6 +19,11 @@ type Client struct{ transport *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)} diff --git a/apiclient/stream.go b/apiclient/stream.go index f4c593d7..5abb2feb 100644 --- a/apiclient/stream.go +++ b/apiclient/stream.go @@ -13,6 +13,7 @@ import ( apitypes "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/internal/server/api/auth" ) // DeviceStream represents a bidirectional connection to a device stream. @@ -46,6 +47,24 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D } } + 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() + return nil, err + } + } + streamPath := fmt.Sprintf("bus/%d/%s\x00", busID, devID) if _, err := conn.Write([]byte(streamPath)); err != nil { conn.Close() diff --git a/apiclient/stream_test.go b/apiclient/stream_test.go index faec318c..7105911a 100644 --- a/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -1,10 +1,13 @@ package apiclient_test import ( + "bufio" "context" + "encoding/json" "errors" "log/slog" "net" + "strings" "testing" "time" @@ -15,6 +18,7 @@ import ( 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" @@ -178,3 +182,115 @@ func TestDeviceStream_Operations(t *testing.T) { }) } } + +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() + 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 apitypes.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() + _, _ = 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() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + tc.serverHandler(t, conn) + }() + + cfg := &apiclient.Config{ + DialTimeout: 3 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + Password: tc.password, + } + client := apiclient.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/apiclient/transport.go b/apiclient/transport.go index 7a0f1644..650f3110 100644 --- a/apiclient/transport.go +++ b/apiclient/transport.go @@ -1,6 +1,7 @@ package apiclient import ( + "bufio" "context" "encoding/json" "fmt" @@ -10,6 +11,9 @@ import ( "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. @@ -17,6 +21,7 @@ type Config struct { DialTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration + Password string } func defaultConfig() Config { @@ -42,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() @@ -67,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 @@ -86,8 +97,8 @@ 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) } @@ -99,26 +110,45 @@ func (c *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar } } - if c.cfg.WriteTimeout > 0 { - _ = conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)) + if t.cfg.WriteTimeout > 0 { + _ = conn.SetWriteDeadline(time.Now().Add(t.cfg.WriteTimeout)) } - // Send request with null terminator + + 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() + return "", err + } + } + if _, err := conn.Write(append(lineBytes, '\x00')); err != nil { return "", fmt.Errorf("write: %w", err) } - if c.cfg.ReadTimeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(c.cfg.ReadTimeout)) + if t.cfg.ReadTimeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(t.cfg.ReadTimeout)) } respBytes, err := io.ReadAll(conn) if err != nil && len(respBytes) == 0 { return "", fmt.Errorf("read: %w", err) } resp := string(respBytes) - // Trim exactly one trailing newline if present. - if strings.HasSuffix(resp, "\n") { - resp = strings.TrimSuffix(resp, "\n") - } - return resp, nil + + return strings.TrimSuffix(resp, "\n"), nil } func fillPath(pattern string, params map[string]string) string { diff --git a/apiclient/transport_test.go b/apiclient/transport_test.go index 8a9f412a..4348ae70 100644 --- a/apiclient/transport_test.go +++ b/apiclient/transport_test.go @@ -1,13 +1,18 @@ package apiclient_test import ( + "bufio" "encoding/json" + "errors" + "log/slog" "net" "strings" "testing" "time" "github.com/Alia5/VIIPER/apiclient" + apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/internal/server/api/auth" "github.com/stretchr/testify/assert" ) @@ -153,3 +158,111 @@ func TestTransportMultiLineResponse(t *testing.T) { 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() + 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 apitypes.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() + _, _ = 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() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + tc.serverHandler(t, conn) + }() + + client := apiclient.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/apitypes/structs.go b/apitypes/structs.go index 639b6981..738ffa30 100644 --- a/apitypes/structs.go +++ b/apitypes/structs.go @@ -17,15 +17,7 @@ type ApiError struct { Detail string `json:"detail"` } -type PingResponse struct { - Server string `json:"server"` - Version string `json:"version"` -} - -func (e *ApiError) Error() string { - if e == nil { - return "" - } +func (e ApiError) Error() string { if e.Status == 0 && e.Title == "" { return "unknown error" } @@ -35,6 +27,13 @@ func (e *ApiError) Error() string { 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"` } diff --git a/go.mod b/go.mod index e686a09e..84f12cb6 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,19 @@ module github.com/Alia5/VIIPER go 1.25 require ( - github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7 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 + golang.org/x/crypto v0.47.0 + golang.org/x/sys v0.40.0 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 80b60bff..c88c099e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7 h1:ZRKdFIh1if8ZitQ9KKKnbglmziT9Sc3JhxY5vauMnTc= -github.com/Zyko0/go-sdl3 v0.0.0-20250919234044-0fbb60f62dd7/go.mod h1:a+48Psmm0D/PuXXB9CW0u9faFMxhNoWvZdfSXuKoD58= -github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c h1:3z1BdpfvUbaP7oXjPabl7STN7zz88S432hZJ8M095kI= -github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c/go.mod h1:xpUxPkAb7v0Ffn/NGp1XpD+tZly4dpSPI7DTAFT37es= 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= @@ -15,8 +11,6 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 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/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b h1:/KAOJuXR4cWaQIiA9xBMDSQJ1JXq5gZHdSK8prrtUqQ= -github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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= @@ -29,12 +23,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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= diff --git a/internal/cmd/server.go b/internal/cmd/server.go index e728c62a..8ccfdd5a 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -6,16 +6,22 @@ import ( "log/slog" "os" "os/signal" + "path" + "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/util" ) +const keyFileName = "viiper.key.txt" + type Server struct { UsbServerConfig usb.ServerConfig `embed:"" prefix:"usb."` ApiServerConfig api.ServerConfig `embed:"" prefix:"api."` @@ -35,6 +41,35 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger s.UsbServerConfig.BusCleanupTimeout = s.ApiServerConfig.DeviceHandlerConnectTimeout logger.Info("Starting VIIPER USB-IP server", "addr", s.UsbServerConfig.Addr) + + keyFileDir, err := configpaths.DefaultConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve key file path: %w", err) + } + keyFilePath := path.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) diff --git a/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go index 4b95e552..d13f3096 100644 --- a/internal/codegen/scanner/payload.go +++ b/internal/codegen/scanner/payload.go @@ -261,8 +261,10 @@ func blockReturnsError(block *ast.BlockStmt) bool { 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 && ident.Name == "api" && strings.HasPrefix(sel.Sel.Name, "Err") { - return true + if ident, ok := sel.X.(*ast.Ident); ok { + if (ident.Name == "api" || ident.Name == "apierror") && strings.HasPrefix(sel.Sel.Name, "Err") { + return true + } } } } diff --git a/internal/server/api/auth/auth.go b/internal/server/api/auth/auth.go new file mode 100644 index 00000000..d083b815 --- /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 master 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..2763b385 --- /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..9b18041f --- /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() + defer clientConn.Close() + defer serverConn.Close() + + 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..c0f91cf2 --- /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" + + apitypes "github.com/Alia5/VIIPER/apitypes" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" +) + +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 apitypes.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..88f3e019 --- /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() + 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() + 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/config.go b/internal/server/api/config.go index 025e4674..cd44d1ef 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -7,6 +7,9 @@ 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/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go index e0a47b60..fa3953ff 100644 --- a/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/Alia5/VIIPER/device" - "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/device/keyboard" htesting "github.com/Alia5/VIIPER/internal/_testing" th "github.com/Alia5/VIIPER/internal/_testing" "github.com/Alia5/VIIPER/internal/log" @@ -29,7 +29,7 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { bus, err := virtualbus.NewWithBusId(90001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New(nil) + dev := keyboard.New(nil) devCtx, err := bus.Add(dev) require.NoError(t, err) @@ -54,15 +54,15 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { require.NotNil(t, dv) handlerCalled := make(chan bool, 1) - testReg := th.CreateMockRegistration(t, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + testReg := th.CreateMockRegistration(t, "keyboard", + func(o *device.CreateOptions) pusb.Device { return keyboard.New(o) }, func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { handlerCalled <- true return nil }, ) - api.RegisterDevice("xbox360", testReg) + api.RegisterDevice("keyboard", testReg) clientConn, serverConn := net.Pipe() defer clientConn.Close() @@ -91,7 +91,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { bus, err := virtualbus.NewWithBusId(70001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := xbox360.New(nil) + dev := keyboard.New(nil) devCtx, err := bus.Add(dev) require.NoError(t, err) meta := device.GetDeviceMeta(devCtx) @@ -109,14 +109,14 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { require.NotEmpty(t, deviceID) handlerCalled := make(chan struct{}, 1) - testReg := th.CreateMockRegistration(t, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + testReg := th.CreateMockRegistration(t, "keyboard", + func(o *device.CreateOptions) pusb.Device { return keyboard.New(o) }, func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { handlerCalled <- struct{}{} return nil }, ) - api.RegisterDevice("xbox360", testReg) + api.RegisterDevice("keyboard", testReg) c, err := net.Dial("tcp", addr) require.NoError(t, err) diff --git a/internal/server/api/error/apierror.go b/internal/server/api/error/apierror.go new file mode 100644 index 00000000..f307603c --- /dev/null +++ b/internal/server/api/error/apierror.go @@ -0,0 +1,31 @@ +package apierror + +import "github.com/Alia5/VIIPER/apitypes" + +func ErrBadRequest(detail string) apitypes.ApiError { + return apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} +} +func ErrNotFound(detail string) apitypes.ApiError { + return apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} +} +func ErrConflict(detail string) apitypes.ApiError { + return apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} +} +func ErrInternal(detail string) apitypes.ApiError { + return apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} +} +func ErrUnauthorized(detail string) apitypes.ApiError { + return apitypes.ApiError{Status: 401, Title: "Unauthorized", Detail: detail} +} + +// WrapError normalizes any error into apitypes.ApiError. +func WrapError(err error) apitypes.ApiError { + if ae, ok := err.(*apitypes.ApiError); ok { + return *ae + } + if ae, ok := err.(apitypes.ApiError); ok { + return ae + } + // Default wrap as internal error + return ErrInternal(err.Error()) +} diff --git a/internal/server/api/errors.go b/internal/server/api/errors.go deleted file mode 100644 index 9b7b50a1..00000000 --- a/internal/server/api/errors.go +++ /dev/null @@ -1,29 +0,0 @@ -package api - -import "github.com/Alia5/VIIPER/apitypes" - -// Factory helpers returning *apitypes.ApiError (single canonical error type). -func ErrBadRequest(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} -} -func ErrNotFound(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} -} -func ErrConflict(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} -} -func ErrInternal(detail string) *apitypes.ApiError { - return &apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} -} - -// WrapError normalizes any error into *apitypes.ApiError. -func WrapError(err error) *apitypes.ApiError { - if err == nil { - return nil - } - if ae, ok := err.(*apitypes.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 index 96b67f33..cbb0b5df 100644 --- a/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -8,6 +8,7 @@ import ( "github.com/Alia5/VIIPER/apitypes" "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/virtualbus" ) @@ -19,7 +20,7 @@ func BusCreate(s *usb.Server) api.HandlerFunc { if req.Payload != "" { busId, err := strconv.ParseUint(req.Payload, 10, 32) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if busId == 0 { @@ -28,14 +29,14 @@ func BusCreate(s *usb.Server) api.HandlerFunc { b, err := virtualbus.NewWithBusId(uint32(busId)) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if err := s.AddBus(b); err != nil { - return api.ErrConflict(fmt.Sprintf("bus %d already exists", busId)) + return apierror.ErrConflict(fmt.Sprintf("bus %d already exists", busId)) } out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(out) return nil @@ -44,11 +45,11 @@ func BusCreate(s *usb.Server) api.HandlerFunc { busId := s.NextFreeBusID() b := virtualbus.New(busId) if err := s.AddBus(b); err != nil { - return api.ErrInternal(fmt.Sprintf("failed to add bus: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to add bus: %v", err)) } out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(out) return nil diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 2b7200ed..8d495b7b 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -10,6 +10,7 @@ import ( "github.com/Alia5/VIIPER/apitypes" "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" ) @@ -18,33 +19,33 @@ 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 api.ErrBadRequest("missing id parameter") + return apierror.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } b := s.GetBus(uint32(busID)) if b == nil { - return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } if req.Payload == "" { - return api.ErrBadRequest("missing payload") + return apierror.ErrBadRequest("missing payload") } var deviceCreateReq apitypes.DeviceCreateRequest err = json.Unmarshal([]byte(req.Payload), &deviceCreateReq) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) } if deviceCreateReq.Type == nil { - return api.ErrBadRequest("missing device type") + return apierror.ErrBadRequest("missing device type") } name := strings.ToLower(*deviceCreateReq.Type) reg := api.GetRegistration(name) if reg == nil { - return api.ErrBadRequest(fmt.Sprintf("unknown device type: %s", name)) + return apierror.ErrBadRequest(fmt.Sprintf("unknown device type: %s", name)) } opts := device.CreateOptions{ @@ -55,12 +56,12 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { dev := reg.CreateDevice(&opts) devCtx, err := b.Add(dev) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to add device to bus: %v", err)) } exportMeta := device.GetDeviceMeta(devCtx) if exportMeta == nil { - return api.ErrInternal("failed to get device metadata from context") + return apierror.ErrInternal("failed to get device metadata from context") } connTimer := device.GetConnTimer(devCtx) @@ -92,7 +93,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { ) if err != nil { logger.Error("failed to auto-attach localhost client", "error", err) - return api.ErrConflict(fmt.Sprintf( + return apierror.ErrConflict(fmt.Sprintf( "Failed to auto-attach device: %v", err, )) } @@ -106,7 +107,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { Type: name, }) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(payload) diff --git a/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go index ab7fbbdd..bbb110f2 100644 --- a/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -8,6 +8,7 @@ import ( "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" ) @@ -16,28 +17,28 @@ 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 api.ErrBadRequest("missing id parameter") + return apierror.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if req.Payload == "" { - return api.ErrBadRequest("missing device number") + return apierror.ErrBadRequest("missing device number") } deviceID := req.Payload b := s.GetBus(uint32(busID)) if b == nil { - return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } if err := s.RemoveDeviceByID(uint32(busID), deviceID); err != nil { - return api.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) + return apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } j, err := json.Marshal(apitypes.DeviceRemoveResponse{BusID: uint32(busID), DevId: deviceID}) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(j) return nil diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go index d6f4397a..026b67ba 100644 --- a/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -11,6 +11,7 @@ import ( "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" ) @@ -19,15 +20,15 @@ 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 api.ErrBadRequest("missing id parameter") + return apierror.ErrBadRequest("missing id parameter") } busID, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } b := s.GetBus(uint32(busID)) if b == nil { - return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } metas := b.GetAllDeviceMetas() out := make([]apitypes.Device, 0, len(metas)) @@ -43,7 +44,7 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { } payload, err := json.Marshal(apitypes.DevicesListResponse{Devices: out}) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + 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_remove.go b/internal/server/api/handler/bus_remove.go index 3f6f7203..fc50c184 100644 --- a/internal/server/api/handler/bus_remove.go +++ b/internal/server/api/handler/bus_remove.go @@ -8,6 +8,7 @@ import ( "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/internal/server/api" + apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" ) @@ -15,18 +16,18 @@ import ( func BusRemove(s *usb.Server) api.HandlerFunc { return func(req *api.Request, res *api.Response, logger *slog.Logger) error { if req.Payload == "" { - return api.ErrBadRequest("missing busId") + return apierror.ErrBadRequest("missing busId") } busID, err := strconv.ParseUint(req.Payload, 10, 32) if err != nil { - return api.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) + return apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err)) } if err := s.RemoveBus(uint32(busID)); err != nil { - return api.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) + return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } out, err := json.Marshal(apitypes.BusRemoveResponse{BusID: uint32(busID)}) if err != nil { - return api.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } res.JSON = string(out) return nil diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 03cf86d6..c73bd1be 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -13,7 +13,10 @@ import ( "strconv" "strings" + "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/device" + "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" ) @@ -42,72 +45,72 @@ func New(s *usb.Server, addr string, config ServerConfig, logger *slog.Logger) * } // Router returns the router used by the API server so callers can register handlers. -func (a *Server) Router() *Router { return a.router } +func (s *Server) Router() *Router { return s.router } // USB returns the underlying USB server. -func (a *Server) USB() *usb.Server { return a.usbs } +func (s *Server) USB() *usb.Server { return s.usbs } // Config returns the server configuration. -func (a *Server) Config() *ServerConfig { return a.config } +func (s *Server) Config() *ServerConfig { return s.config } // Addr returns the actual address the server is listening on. // If Start hasn't been called yet, it returns the configured address. -func (a *Server) Addr() string { - if a.ln != nil { - return a.ln.Addr().String() +func (s *Server) Addr() string { + if s.ln != nil { + return s.ln.Addr().String() } - return a.addr + return s.addr } // Start listens on the configured address and serves incoming API commands. -func (a *Server) Start() error { - ln, err := net.Listen("tcp", a.addr) +func (s *Server) Start() error { + ln, err := net.Listen("tcp", s.addr) if err != nil { return err } - a.ln = ln + s.ln = ln - a.addr = ln.Addr().String() - a.config.Addr = a.addr - a.logger.Info("API listening", "addr", a.addr) - go a.serve() + 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 (a *Server) Close() { - if a.ln != nil { - _ = a.ln.Close() +func (s *Server) Close() { + if s.ln != nil { + _ = s.ln.Close() } } -func (a *Server) serve() { +func (s *Server) serve() { for { - c, err := a.ln.Accept() + 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") { - a.logger.Info("API server stopped") + s.logger.Info("API server stopped") return } - a.logger.Info("API accept error", "error", err) + s.logger.Info("API accept error", "error", err) return } if tcpConn, ok := c.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { - a.logger.Warn("failed to set TCP_NODELAY", "error", err) + s.logger.Warn("failed to set TCP_NODELAY", "error", err) } } - go a.handleConn(c) + go s.handleConn(c) } } -func (a *Server) writeError(w io.Writer, err error) { - apiErr := WrapError(err) +func (s *Server) writeError(w io.Writer, err error) { + apiErr := apierror.WrapError(err) problemJSON, _ := json.Marshal(apiErr) fmt.Fprintf(w, "%s\n", string(problemJSON)) } -func (a *Server) writeOK(w io.Writer, rest string) { +func (s *Server) writeOK(w io.Writer, rest string) { if rest == "" { fmt.Fprintln(w) } else { @@ -115,16 +118,63 @@ func (a *Server) writeOK(w io.Writer, rest string) { } } -func (a *Server) handleConn(conn net.Conn) { +func (s *Server) handleConn(conn net.Conn) { defer conn.Close() connCtx, connCancel := context.WithCancel(context.Background()) defer connCancel() - connLogger := a.logger.With("remote", conn.RemoteAddr().String()) + 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 { + var apiErr apitypes.ApiError + if errors.As(err, &apiErr) { + connLogger.Error("auth handshake failed", "error", err) + s.writeError(w, err) + 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 { @@ -140,7 +190,7 @@ func (a *Server) handleConn(conn net.Conn) { if reqData == "" { connLogger.Error("api empty command") - a.writeError(w, ErrBadRequest("empty request")) + s.writeError(w, apierror.ErrBadRequest("empty request")) return } @@ -159,45 +209,45 @@ func (a *Server) handleConn(conn net.Conn) { if path == "" { connLogger.Error("api empty path") - a.writeError(w, ErrBadRequest("empty path")) + s.writeError(w, apierror.ErrBadRequest("empty path")) return } path = strings.ToLower(path) connLogger.Info("api cmd", "path", path) - if h, params := a.router.Match(path); h != nil { + 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) - a.writeError(w, err) + s.writeError(w, err) return } connLogger.Debug("api handler success", "path", path) - a.writeOK(w, res.JSON) + s.writeOK(w, res.JSON) return - } else if sh, params := a.router.MatchStream(path); sh != nil { + } else if sh, params := s.router.MatchStream(path); sh != nil { connLogger.Info("api stream begin", "path", path) busIDStr, ok := params["busId"] if !ok { - a.writeError(w, ErrBadRequest("missing busId parameter")) + s.writeError(w, apierror.ErrBadRequest("missing busId parameter")) return } devIDStr, ok := params["deviceid"] if !ok { - a.writeError(w, ErrBadRequest("missing deviceid parameter")) + s.writeError(w, apierror.ErrBadRequest("missing deviceid parameter")) return } busID, err := strconv.ParseUint(busIDStr, 10, 32) if err != nil { - a.writeError(w, ErrBadRequest(fmt.Sprintf("invalid busId: %v", err))) + s.writeError(w, apierror.ErrBadRequest(fmt.Sprintf("invalid busId: %v", err))) return } - bus := a.usbs.GetBus(uint32(busID)) + bus := s.usbs.GetBus(uint32(busID)) if bus == nil { - a.writeError(w, ErrNotFound(fmt.Sprintf("bus %d not found", busID))) + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID))) return } var dev pusb.Device @@ -211,7 +261,7 @@ func (a *Server) handleConn(conn net.Conn) { } } if dev == nil || devCtx == nil { - a.writeError(w, ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", devIDStr, busID))) return } @@ -228,7 +278,7 @@ func (a *Server) handleConn(conn net.Conn) { connTimer = device.GetConnTimer(devCtx) if connTimer != nil { - connTimer.Reset(a.config.DeviceHandlerConnectTimeout) + connTimer.Reset(s.config.DeviceHandlerConnectTimeout) go func() { select { case <-devCtx.Done(): @@ -253,5 +303,25 @@ func (a *Server) handleConn(conn net.Conn) { return } connLogger.Error("api unknown path", "path", path) - a.writeError(w, ErrNotFound(fmt.Sprintf("unknown path: %s", path))) + s.writeError(w, apierror.ErrNotFound(fmt.Sprintf("unknown path: %s", path))) +} + +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 index 907c8a04..3ed041c0 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -1,21 +1,30 @@ 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/apiclient" "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/virtualbus" ) @@ -50,12 +59,12 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { require.NotEmpty(t, devID) sentinel := fmt.Errorf("boom") - mr := th.CreateMockRegistration(t, "xbox360", + mr := th.CreateMockRegistration(t, "xbox360_error_stream", func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, ) - api.RegisterDevice("xbox360", mr) + 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) @@ -67,3 +76,190 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { 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() + defer s.UsbServer.Close() + + 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() + _ = s.UsbServer.AddBus(b) + time.Sleep(50 * time.Millisecond) + + client := apiclient.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() + 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() + } + + 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, 750*time.Millisecond) + 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(750 * time.Millisecond)) + _, 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) + + }) + } + +} From f92a709f6ab9f33c3f1079c256935a9e3c88fe2c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 30 Jan 2026 21:55:50 +0100 Subject: [PATCH 126/298] Update docs --- docs/api/overview.md | 19 ++++++++++++++++++- docs/cli/configuration.md | 27 +++++++++++++++++++++++++++ docs/cli/server.md | 30 ++++++++++++++++++++++++++++++ docs/getting-started/quickstart.md | 13 +++++++++++++ docs/index.md | 23 ++++++++++++++++------- 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index b55529b4..696da019 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -77,8 +77,10 @@ The TCP API is inspired by the ubiquitous HTTP REST style, but is more lightweig 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 +- **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`) @@ -93,6 +95,21 @@ The exception to this are the device-control and feedback streams, which are raw !!! 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. +!!! warning "Authentication Required for Remote Connections" + **VIIPER requires authentication for all non-localhost connections.** + + - **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: `~/.config/viiper/viiper.key.txt` + + Remote clients must provide this password to establish a connection. + + See the [Configuration](../cli/configuration.md) documentation for details on password management and the `--api.require-localhost-auth` option. + ## Endpoints !!! info "null byte excluded" diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 09705a1c..555ca2a8 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -24,6 +24,7 @@ All command-line flags have corresponding environment variables for easier deplo | `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 @@ -63,6 +64,32 @@ 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) +## Authentication and Security + +VIIPER requires authentication for remote (non-localhost) connections +to prevent unauthorized device creation. + +The password file is _intentionally_ separated from the main configuration + +**Password File:** `viiper.key.txt` + +- **Location:** + - **Windows:** `%APPDATA%\viiper\` + - **Linux/macOS:** `~/.config/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 + +### Localhost Exemption + +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`. + +### Remote Connections + +All remote clients MUST authenticate using the password from `viiper.key.txt`. + ## Configuration Examples === "Config files" diff --git a/docs/cli/server.md b/docs/cli/server.md index 344287a8..38387767 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -18,6 +18,20 @@ The server exposes two interfaces: 1. **USBIP Server** - Standard USBIP protocol for device attachment 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: `~/.config/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. @@ -61,6 +75,22 @@ Disable example: viiper server --api.auto-attach-local-client=false ``` +### `--api.require-localhost-auth` + +Require authentication even for clients connecting from localhost (`127.0.0.1`, `::1`, `localhost`). + +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. + +**Default:** `false` +**Environment Variable:** `VIIPER_API_REQUIRE_LOCALHOST_AUTH` + +Enable example: + +```bash +viiper server --api.require-localhost-auth=true +``` + ### `--connection-timeout` Connection operation timeout for both USBIP and API servers. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 6d28bca7..c1383a8c 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -20,6 +20,19 @@ 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: `~/.config/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. diff --git a/docs/index.md b/docs/index.md index d515f984..b1b993bf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,11 +49,13 @@ It's designed to be trivial to drive from any language that can open a TCP socke 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). +- 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) + - 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). @@ -61,6 +63,13 @@ It's designed to be trivial to drive from any language that can open a TCP socke 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 --- @@ -75,9 +84,9 @@ VIIPER uses it because it's already built into Linux and available for Windows, ### Why is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself - 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 - - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. + - 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 + - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. - **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) From 59564a3a027b01446427f8872dc109104c788fa7 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 30 Jan 2026 22:23:03 +0100 Subject: [PATCH 127/298] Update e2e benches --- _testing/e2e/bench_test.go | 78 ++++++++++++++++++++++++++++--- _testing/e2e/scripts/lat_bench.go | 48 ++++++++++++++++--- go.mod | 3 ++ go.sum | 6 +++ 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index fedc36f5..321a37d0 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -34,12 +34,13 @@ const ( func Benchmark_Xbox360_Delay(b *testing.B) { type bench struct { - name string - timeOn func(tw TimeWhat, b *testing.B) + name string + timeOn func(tw TimeWhat, b *testing.B) + useEncryption bool } benches := []bench{ { - name: "1 Go-Client-Write", + name: "1 Go-Client-Write (PLAIN)", timeOn: func(tw TimeWhat, b *testing.B) { switch tw { case TimeWhat_ClientWritePress: @@ -51,7 +52,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }, }, { - name: "2 InputDelay-Without-Client", + name: "2 InputDelay-Without-Client (PLAIN)", timeOn: func(tw TimeWhat, b *testing.B) { switch tw { case TimeWhat_ClientWritePress: @@ -63,7 +64,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }, }, { - name: "3 E2E-InputDelay", + name: "3 E2E-InputDelay (PLAIN)", timeOn: func(tw TimeWhat, b *testing.B) { switch tw { case TimeWhat_ClientWritePress: @@ -76,7 +77,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }, }, { - name: "4 E2E-PressAndRelease", + name: "4 E2E-PressAndRelease (PLAIN)", timeOn: func(tw TimeWhat, b *testing.B) { switch tw { case TimeWhat_ClientWritePress: @@ -90,6 +91,62 @@ func Benchmark_Xbox360_Delay(b *testing.B) { } }, }, + { + 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) @@ -115,6 +172,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { Addr: ":3245", AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, + Password: "testpassword1234", }, ConnectionTimeout: 5 * time.Second, } @@ -126,7 +184,10 @@ func Benchmark_Xbox360_Delay(b *testing.B) { panic(err) } }() - c := apiclient.New("localhost:3245") + + var c *apiclient.Client + + c = apiclient.New("localhost:3245") var busResp *apitypes.BusCreateResponse var err error for range 10 { @@ -195,6 +256,9 @@ func Benchmark_Xbox360_Delay(b *testing.B) { }() for _, bench := range benches { + if bench.useEncryption { + c = apiclient.NewWithPassword("localhost:3245", "testpassword1234") + } b.Run(bench.name, func(b *testing.B) { for b.Loop() { b.StopTimer() diff --git a/_testing/e2e/scripts/lat_bench.go b/_testing/e2e/scripts/lat_bench.go index 830b3230..bf9ca11d 100644 --- a/_testing/e2e/scripts/lat_bench.go +++ b/_testing/e2e/scripts/lat_bench.go @@ -25,6 +25,11 @@ package main // # 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) @@ -50,13 +55,14 @@ import ( ) 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).") + 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() { @@ -82,6 +88,7 @@ func main() { 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 { @@ -267,6 +274,33 @@ func deriveRun(lines []benchLine) (out []derivedMetrics, notes []string) { 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 diff --git a/go.mod b/go.mod index 84f12cb6..47b12920 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,10 @@ require ( ) require ( + github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 // indirect + github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index c88c099e..ca5b53ed 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 h1:a7BoGQPgciWaIwBlh/IMkuqDDU0otZkzbnl4sPZZKm0= +github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1/go.mod h1:mJcKev/gsLraiuXsB/EPyh4hdYnsUHyNU5CXch3cv0A= +github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c h1:3z1BdpfvUbaP7oXjPabl7STN7zz88S432hZJ8M095kI= +github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c/go.mod h1:xpUxPkAb7v0Ffn/NGp1XpD+tZly4dpSPI7DTAFT37es= 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= @@ -11,6 +15,8 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 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/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b h1:/KAOJuXR4cWaQIiA9xBMDSQJ1JXq5gZHdSK8prrtUqQ= +github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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= From f2b64aae36136cce177ad79570c579f5ab547aba Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 30 Jan 2026 23:37:09 +0100 Subject: [PATCH 128/298] Update typescript SDK --- internal/codegen/generator/typescript/auth.go | 243 ++++++++++++++++++ .../codegen/generator/typescript/client.go | 105 +++++--- internal/codegen/generator/typescript/gen.go | 3 + internal/server/api/auth/auth.go | 2 +- 4 files changed, 315 insertions(+), 38 deletions(-) create mode 100644 internal/codegen/generator/typescript/auth.go diff --git a/internal/codegen/generator/typescript/auth.go b/internal/codegen/generator/typescript/auth.go new file mode 100644 index 00000000..81b56f4f --- /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/client.go b/internal/codegen/generator/typescript/client.go index 35f5313f..24db4043 100644 --- a/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -18,6 +18,7 @@ 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(); @@ -25,14 +26,20 @@ 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) { + constructor(host: string, port: number = 3242, password: string = "") { this.host = host; this.port = port; + this.password = password; } {{range .Routes}}{{if eq .Method "Register"}} /** @@ -45,38 +52,51 @@ export class ViiperClient { {{if .ResponseDTO}}return await this.sendRequest(path, payload);{{else}}await this.sendRequest(path, payload); return true;{{end}} } {{end}}{{end}} - private sendRequest(path: string, payload?: string | null): Promise { - return new Promise((resolve, reject) => { + 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, () => { - socket.setNoDelay(true); - let line = path; // preserve case - if (payload && payload.length > 0) line += ' ' + payload; - line += '\0'; - socket.write(encoder.encode(line)); - }); - - let buffer = ''; - socket.on('data', (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) { - socket.end(); - reject(e); - return; + 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); } - if (parsed && typeof parsed === 'object' && 'status' in parsed && parsed.status >= 400) { - socket.end(); - reject(new Error(String(parsed.status) + ' ' + parsed.title + ': ' + parsed.detail)); - return; - } - socket.end(); - resolve(parsed as T); + }; + + wrappedSocket.on('data', handleData); + } catch (e) { + socket.end(); + reject(e); } }); @@ -86,13 +106,24 @@ export class ViiperClient { } async connectDevice(busId: number, devId: string): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const socket = new Socket(); - socket.connect(this.port, this.host, () => { - socket.setNoDelay(true); - const line = ` + "`" + `bus/${busId}/${devId}\0` + "`" + `; - socket.write(encoder.encode(line)); - resolve(new ViiperDevice(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); }); diff --git a/internal/codegen/generator/typescript/gen.go b/internal/codegen/generator/typescript/gen.go index 1ce29d0c..c1f63e82 100644 --- a/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -37,6 +37,9 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { 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 } diff --git a/internal/server/api/auth/auth.go b/internal/server/api/auth/auth.go index d083b815..5a7ced6b 100644 --- a/internal/server/api/auth/auth.go +++ b/internal/server/api/auth/auth.go @@ -43,7 +43,7 @@ func DeriveKey(password string) ([]byte, error) { ) } -// DeriveSessionKey creates unique session key from master key and nonces +// 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() From a71b9f1a73ae1fc93f32e6f81961895c3e09f155 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 00:06:32 +0100 Subject: [PATCH 129/298] Update C# Client --- internal/codegen/generator/csharp/auth.go | 351 ++++++++++++++++++++ internal/codegen/generator/csharp/client.go | 21 +- internal/codegen/generator/csharp/device.go | 4 +- internal/codegen/generator/csharp/gen.go | 4 +- 4 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 internal/codegen/generator/csharp/auth.go diff --git a/internal/codegen/generator/csharp/auth.go b/internal/codegen/generator/csharp/auth.go new file mode 100644 index 00000000..99f2f7f7 --- /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 index d54a61d4..421f4d5e 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -26,6 +26,7 @@ public class ViiperClient : IDisposable { private readonly string _host; private readonly int _port; + private readonly string _password; private bool _disposed; /// @@ -33,10 +34,12 @@ public class ViiperClient : IDisposable /// /// VIIPER server hostname or IP address /// VIIPER API server port (default: 3242) - public ViiperClient(string host, int port = 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"}} /// @@ -58,9 +61,13 @@ public class ViiperClient : IDisposable await client.ConnectAsync(_host, _port, cancellationToken); client.NoDelay = true; - using var stream = client.GetStream(); + Stream stream = client.GetStream(); + + if (!string.IsNullOrEmpty(_password)) + { + stream = await ViiperAuth.PerformHandshakeAsync(stream, _password, cancellationToken); + } - // Build command line: "path[ optional-payload]\0" string commandLine = path.ToLowerInvariant(); if (!string.IsNullOrEmpty(payload)) { @@ -104,7 +111,13 @@ public class ViiperClient : IDisposable var client = new TcpClient(); await client.ConnectAsync(_host, _port, cancellationToken); client.NoDelay = true; - var stream = client.GetStream(); + 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); diff --git a/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go index 370eb20f..f4135a9d 100644 --- a/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -30,7 +30,7 @@ public interface IBinarySerializable public sealed class ViiperDevice : IAsyncDisposable, IDisposable { private readonly TcpClient _client; - private readonly NetworkStream _stream; + private readonly Stream _stream; private readonly CancellationTokenSource _cts = new(); private Task? _readLoop; private bool _disposed; @@ -60,7 +60,7 @@ public sealed class ViiperDevice : IAsyncDisposable, IDisposable set => _onDisconnect = value; } - internal ViiperDevice(TcpClient client, NetworkStream stream) + internal ViiperDevice(TcpClient client, Stream stream) { _client = client; _stream = stream; diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go index 5a9e0bf8..b09959a5 100644 --- a/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -38,7 +38,9 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { 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 } From fdb42a0435c972446a00dabdabf1119c34c7d9ab Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 00:30:40 +0100 Subject: [PATCH 130/298] gofmt --- internal/codegen/generator/csharp/auth.go | 702 +++++++++--------- internal/codegen/generator/typescript/auth.go | 486 ++++++------ 2 files changed, 594 insertions(+), 594 deletions(-) diff --git a/internal/codegen/generator/csharp/auth.go b/internal/codegen/generator/csharp/auth.go index 99f2f7f7..2c37f7a1 100644 --- a/internal/codegen/generator/csharp/auth.go +++ b/internal/codegen/generator/csharp/auth.go @@ -1,351 +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 -} +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/typescript/auth.go b/internal/codegen/generator/typescript/auth.go index 81b56f4f..ce9f1c22 100644 --- a/internal/codegen/generator/typescript/auth.go +++ b/internal/codegen/generator/typescript/auth.go @@ -1,243 +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 -} +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 +} From 6ff058ed055d7ed32c85cd64fafe76b0caebdb36 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 01:21:34 +0100 Subject: [PATCH 131/298] Update C++ client --- .github/workflows/clients_ci.yml | 5 + docs/clients/cpp.md | 16 +- examples/cpp/CMakeLists.txt | 3 + internal/codegen/generator/cpp/auth.go | 371 +++++++++++++++++++++++ internal/codegen/generator/cpp/client.go | 49 ++- internal/codegen/generator/cpp/device.go | 48 ++- internal/codegen/generator/cpp/gen.go | 4 + 7 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 internal/codegen/generator/cpp/auth.go diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 89de863f..672e49f8 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -208,6 +208,11 @@ jobs: 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 diff --git a/docs/clients/cpp.md b/docs/clients/cpp.md index d3c8a5ed..c1382db6 100644 --- a/docs/clients/cpp.md +++ b/docs/clients/cpp.md @@ -16,6 +16,10 @@ The C++ client library features: **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. @@ -376,16 +380,16 @@ auto buses = client.buslist().value(); 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 + - 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 + - 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 + - Cycles through buttons A, B, X, Y + - Handles rumble feedback ### Building Examples diff --git a/examples/cpp/CMakeLists.txt b/examples/cpp/CMakeLists.txt index 5aaa9088..ff35b72b 100644 --- a/examples/cpp/CMakeLists.txt +++ b/examples/cpp/CMakeLists.txt @@ -25,9 +25,12 @@ FetchContent_Declare(json ) 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 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 index 00a9c2ca..743a3e54 100644 --- a/internal/codegen/generator/cpp/client.go +++ b/internal/codegen/generator/cpp/client.go @@ -20,6 +20,7 @@ const clientTemplate = `{{.Header}} #include "device.hpp" #include "detail/socket.hpp" #include "detail/json.hpp" +#include "detail/auth.hpp" #include #include #include @@ -33,8 +34,8 @@ namespace viiper { class ViiperClient { public: - ViiperClient(std::string host, std::uint16_t port = 3242) - : host_(std::move(host)), port_(port) {} + ViiperClient(std::string host, std::uint16_t port = 3242, std::string password = "") + : host_(std::move(host)), port_(port), password_(std::move(password)) {} ~ViiperClient() = default; @@ -45,6 +46,7 @@ public: [[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) @@ -74,10 +76,22 @@ public: if (conn_result.is_error()) return conn_result.error(); std::string handshake = "bus/" + std::to_string(bus_id) + "/" + dev_id + '\0'; - auto send_result = socket.send(handshake); - if (send_result.is_error()) return send_result.error(); - return std::unique_ptr(new ViiperDevice(std::move(socket))); + 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 @@ -109,13 +123,27 @@ private: } request += '\0'; - auto send_result = socket.send(request); - if (send_result.is_error()) return send_result.error(); + 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(); - auto recv_result = 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(); - return detail::parse_json_response(recv_result.value()); + 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, @@ -133,6 +161,7 @@ private: std::string host_; std::uint16_t port_; + std::string password_; mutable std::mutex request_mutex_; }; diff --git a/internal/codegen/generator/cpp/device.go b/internal/codegen/generator/cpp/device.go index ae658586..5479b350 100644 --- a/internal/codegen/generator/cpp/device.go +++ b/internal/codegen/generator/cpp/device.go @@ -17,6 +17,7 @@ const deviceTemplate = `// Auto-generated VIIPER C++ Client Library #include "config.hpp" #include "error.hpp" #include "detail/socket.hpp" +#include "detail/auth_impl.hpp" #include #include #include @@ -24,6 +25,7 @@ const deviceTemplate = `// Auto-generated VIIPER C++ Client Library #include #include #include +#include namespace viiper { @@ -59,12 +61,24 @@ public: Result send(const T& input) { std::lock_guard lock(send_mutex_); auto bytes = input.to_bytes(); - return socket_.send(bytes.data(), bytes.size()); + 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 socket_.send(data, size); + 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_); } // ======================================================================== @@ -86,7 +100,14 @@ public: auto buffer = std::make_unique(output_buffer_size_); while (running_) { - auto recv_result = socket_.recv(buffer.get(), output_buffer_size_); + 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()); @@ -128,21 +149,36 @@ public: void stop() { running_ = false; - socket_.force_close(); + 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() && socket_.is_valid(); + 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: - detail::Socket socket_; + std::variant> socket_; std::atomic running_; std::size_t output_buffer_size_; OutputCallback output_callback_; diff --git a/internal/codegen/generator/cpp/gen.go b/internal/codegen/generator/cpp/gen.go index 2953814c..bfb2bb3d 100644 --- a/internal/codegen/generator/cpp/gen.go +++ b/internal/codegen/generator/cpp/gen.go @@ -48,6 +48,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } + if err := generateAuthHeader(logger, detailDir); err != nil { + return err + } + if err := generateClient(logger, includeDir, md); err != nil { return err } From af39b5208b59a4c06617918f00f97c41b2339e45 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 02:24:03 +0100 Subject: [PATCH 132/298] Remove C API client Changelog(misc) --- .github/workflows/clients_ci.yml | 41 -- .github/workflows/snapshots.yml | 5 +- README.md | 45 +- docs/api/overview.md | 1 - docs/cli/codegen.md | 8 +- docs/clients/c.md | 298 -------- docs/clients/cpp.md | 1 - docs/clients/csharp.md | 13 +- docs/clients/generator.md | 31 +- docs/clients/go.md | 1 - docs/clients/rust.md | 15 +- docs/clients/typescript.md | 13 +- docs/getting-started/quickstart.md | 5 +- examples/c/CMakeLists.txt | 5 - examples/c/virtual_keyboard/CMakeLists.txt | 26 - examples/c/virtual_keyboard/main.c | 163 ----- examples/c/virtual_x360_pad/CMakeLists.txt | 27 - examples/c/virtual_x360_pad/main.c | 184 ----- internal/codegen/generator/c/cmake.go | 87 --- internal/codegen/generator/c/gen.go | 65 -- internal/codegen/generator/c/header_common.go | 215 ------ internal/codegen/generator/c/header_device.go | 100 --- internal/codegen/generator/c/helpers.go | 592 --------------- internal/codegen/generator/c/source_common.go | 671 ------------------ internal/codegen/generator/c/source_device.go | 54 -- internal/codegen/generator/generator.go | 2 - mkdocs.yml | 1 - 27 files changed, 48 insertions(+), 2621 deletions(-) delete mode 100644 docs/clients/c.md delete mode 100644 examples/c/CMakeLists.txt delete mode 100644 examples/c/virtual_keyboard/CMakeLists.txt delete mode 100644 examples/c/virtual_keyboard/main.c delete mode 100644 examples/c/virtual_x360_pad/CMakeLists.txt delete mode 100644 examples/c/virtual_x360_pad/main.c delete mode 100644 internal/codegen/generator/c/cmake.go delete mode 100644 internal/codegen/generator/c/gen.go delete mode 100644 internal/codegen/generator/c/header_common.go delete mode 100644 internal/codegen/generator/c/header_device.go delete mode 100644 internal/codegen/generator/c/helpers.go delete mode 100644 internal/codegen/generator/c/source_common.go delete mode 100644 internal/codegen/generator/c/source_device.go diff --git a/.github/workflows/clients_ci.yml b/.github/workflows/clients_ci.yml index 672e49f8..7558ca0b 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -148,47 +148,6 @@ jobs: path: artifacts/nuget/*.nupkg if-no-files-found: error - c-sdk: - name: C Client Library - needs: codegen - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download generated clients - uses: actions/download-artifact@v4 - with: - name: generated-clients - path: clients/ - - - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2 - with: - cmake-version: "3.26.x" - - - name: Build C Client Library - run: | - cmake -S clients/c -B clients/c/build - cmake --build clients/c/build --config Release --parallel - - - name: Build C examples (smoke) - run: | - cmake -S examples/c -B examples/c/build - cmake --build examples/c/build --config Release --parallel - - - name: Archive C Client Library source - if: ${{ inputs.upload_artifacts }} - run: tar -czf c-client-library-source${{ inputs.artifact_suffix }}.tar.gz -C clients/c --exclude='build' . - - - name: Upload C Client Library source - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: c-client-library-source${{ inputs.artifact_suffix }} - path: c-client-library-source${{ inputs.artifact_suffix }}.tar.gz - if-no-files-found: error - cpp-sdk: name: C++ Client Library needs: codegen diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index b909d77a..aac8c7cf 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -112,10 +112,7 @@ jobs: name: "Dev Build ${{ steps.build_info.outputs.version }}" prerelease: true body: | - Automated development build. - - Commit: [${{ steps.build_info.outputs.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}) - + Automated development build. ⚠️ These are the latest development builds and may contain bugs or unfinished features. ${{ needs.generate-changelog.outputs.changelog }} diff --git a/README.md b/README.md index 428d6e21..32b4f9a5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ [![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) [![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) [![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) -[![C Client Library](https://img.shields.io/badge/C_Client_Library-artifact-blueviolet)](https://github.com/Alia5/VIIPER/releases) [![C++ Client Library](https://img.shields.io/badge/C++_Client_Library-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) [![Discord](https://img.shields.io/discord/368823110817808384?logo=discord&logoColor=white&label=Discord&color=%23535fe5 )](https://discord.gg/hs34MtcHJY) @@ -29,7 +28,7 @@ These virtual devices are indistinguishable from real hardware to the operating - VIIPER abstracts away all USB / USBIP details. - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. - (USBIP still requires a kernel driver, but this is generic and device _emulation_ code still lives in Userspace) + (USBIP still requires a kernel driver, but this is generic and device _emulation_ code still lives in Userspace) - Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. VIIPER _currently_ comes in a single flavor: @@ -44,12 +43,12 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio ### ✨🛣️ Features / Roadmap - ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation; 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) - - ✅ PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) - - 🔜 Xbox One / Series(?) controller emulation - - 🔜 ??? + - ✅ Xbox 360 controller emulation; 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) + - ✅ PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) + - 🔜 Xbox One / Series(?) controller emulation + - 🔜 ??? 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) - ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) - ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) @@ -64,12 +63,12 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio **Linux:** - **Arch Linux:** - - Install: `sudo pacman -S usbip` - - Docs: [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) + - Install: `sudo pacman -S usbip` + - Docs: [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) - **Ubuntu:** - - Install: `sudo apt install linux-tools-generic` - - Docs: [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) + - Install: `sudo apt install linux-tools-generic` + - Docs: [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) **Windows:** @@ -95,10 +94,10 @@ Most of the time, you don't need to implement that raw protocol yourself, as cli See [Client Libraries](docs/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). + - 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) + - 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). @@ -117,8 +116,8 @@ See the [API documentation](./docs/api) for details - [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` + - Linux/macOS: Usually pre-installed + - Windows: `winget install ezwinports.make` ### 🔄 Building from Source @@ -157,15 +156,15 @@ VIIPER uses it because it's already built into Linux and available for Windows, ### Why is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself - 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 - - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. + - 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 + - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. - **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) ### Can I use VIIPER for gaming? -Yes! VIIPER can create virtual controllers (currently only Xbox360) that appear as real hardware to games and applications. +Yes! VIIPER can create virtual controllers that appear as real hardware to games and applications. This works with Steam, native Windows games, and any other application supporting controllers. ### How is VIIPER different from other controller emulators? @@ -227,7 +226,7 @@ along with this program. If not, see . that sent me down this rabbit hole in the first place I kinda hate you guys... in good way(?) ;) - **USBIP** without VIIPER would not be possible. - - [USBIP](https://usbip.sourceforge.net/) - - [USBIP-Win2](https://github.com/vadimgrn/usbip-win2) + - [USBIP](https://usbip.sourceforge.net/) + - [USBIP-Win2](https://github.com/vadimgrn/usbip-win2) - [SDL](https://www.libsdl.org/) For their excellent work on input device handling, reducing reversing efforts to a minimum. diff --git a/docs/api/overview.md b/docs/api/overview.md index 696da019..0fdf806d 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -62,7 +62,6 @@ It's designed to be trivial to drive from any language that can open a TCP socke - [Go Client](../clients/go.md): Reference implementation included in the repository - [Generator Documentation](../clients/generator.md): Information about code generation - - [C Client Library](../clients/c.md): Generated C library with type-safe device streams - [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 diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index 9794ef0a..3f0444e1 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -40,7 +40,7 @@ Output directory for generated client libraries (relative to repository root). Target language to generate. -**Values:** `c`, `csharp`, `typescript`, `all` +**Values:** `csharp`, `typescript`, `all` **Default:** `all` **Environment Variable:** `VIIPER_CODEGEN_LANG` @@ -52,12 +52,10 @@ Target language to generate. go run ./cmd/viiper codegen ``` -### Generate C Client Library and Rebuild Examples +### Generate a Single Client Library ```bash -go run ./cmd/viiper codegen --lang=c -cd examples/c -cmake --build build --config Release +go run ./cmd/viiper codegen --lang=csharp ``` ## When to Regenerate diff --git a/docs/clients/c.md b/docs/clients/c.md deleted file mode 100644 index 450b3c7f..00000000 --- a/docs/clients/c.md +++ /dev/null @@ -1,298 +0,0 @@ -# C Client Library Documentation - -The VIIPER C client library provides a lightweight, dependency-free client library for interacting with VIIPER servers and controlling virtual devices. - -The C client library features: - -- **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 -- **Device-agnostic streaming API**: Uniform interface for all device types - -!!! 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 - -### Building from Source - -The C client library is generated from the VIIPER server codebase: - -```bash -go run ./cmd/viiper codegen --lang=c -``` - -Build the client library: - -```bash -cd ../clients/c -cmake -B build -G "Visual Studio 17 2022" # Windows -cmake -B build # LINUX -cmake --build build --config Release -``` - -### Linking to Your Project - -**CMake:** - -```cmake -# Add viiper client library -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) - -## Example - -```c -#include -#include -#include - -int main(void) { - // Create new Viiper client - 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_response_t buses = {0}; - err = viiper_bus_list(client, &buses); - uint32_t bus_id = (buses.BusesCount > 0) ? buses.Buses[0] : 0; - - if (bus_id == 0) { - viiper_bus_create_response_t resp = {0}; - uint32_t desired_id = 1; - err = viiper_bus_create(client, &desired_id, &resp); // NULL for auto-assign - bus_id = resp.BusID; - } - - // Add device and connect (convenience function) - const char* device_type = "keyboard"; - viiper_device_create_request_t req = { - .Type = &device_type, - .IdVendor = NULL, - .IdProduct = NULL - }; - viiper_device_info_t dev_info = {0}; - viiper_device_t* device = NULL; - err = viiper_add_device_and_connect(client, bus_id, &req, &dev_info, &device); - - // Send keyboard input - viiper_keyboard_input_t input = { - .modifiers = 0, - .count = 1 - }; - uint8_t keys[] = {VIIPER_KEYBOARD_KEY_A}; - 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); - - char bus_id_str[32]; - snprintf(bus_id_str, sizeof(bus_id_str), "%u", bus_id); - viiper_device_remove_response_t remove_resp = {0}; - viiper_bus_device_remove(client, bus_id_str, dev_info.DevId, &remove_resp); - - viiper_client_free(client); - return 0; -} -``` - -## Device Control/Feedback API - -### Creating a Device Control/Feedback Stream - -Manual (add device, then connect): - -```c -// Add device first -const char* device_type = "keyboard"; -viiper_device_create_request_t req = { - .Type = &device_type, - .IdVendor = NULL, // Optional: set to specify custom VID - .IdProduct = NULL // Optional: set to specify custom PID -}; - -char bus_id_str[32]; -snprintf(bus_id_str, sizeof(bus_id_str), "%u", bus_id); - -viiper_device_info_t dev_info = {0}; -viiper_error_t err = viiper_bus_device_add(client, bus_id_str, &req, &dev_info); -if (err != VIIPER_OK) { - fprintf(stderr, "Failed to add device: %s\n", viiper_get_error(client)); -} - -// Then connect to its stream -viiper_device_t* device = NULL; -err = viiper_open_stream(client, bus_id, dev_info.DevId, &device); -if (err != VIIPER_OK) { - fprintf(stderr, "Failed to open device stream: %s\n", viiper_get_error(client)); -} -``` - -Convenience approach (add and connect in one call): - -```c -const char* device_type = "xbox360"; -viiper_device_create_request_t req = { - .Type = &device_type, - .IdVendor = NULL, - .IdProduct = NULL -}; - -viiper_device_info_t dev_info = {0}; -viiper_device_t* device = NULL; -viiper_error_t err = viiper_add_device_and_connect(client, bus_id, &req, &dev_info, &device); -if (err != VIIPER_OK) { - fprintf(stderr, "Failed to add and connect device: %s\n", viiper_get_error(client)); -} -``` - -### 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 Feedback (Rumble, LEDs, etc.) - -```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_LED_NUM_LOCK), - !!(leds & VIIPER_KEYBOARD_LED_CAPS_LOCK), - !!(leds & VIIPER_KEYBOARD_LED_SCROLL_LOCK)); -} - -viiper_device_on_output(device, on_led_update, NULL); -``` - -### Closing a Stream - -```c -viiper_device_close(device); -``` - -The VIIPER server automatically removes the device when the stream is closed after a short timeout. - -## 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 client library 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) -``` - -!!! warning "Compiler compatibility" - **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_KEY_L, 0); -Sleep(100); -press_and_release(dev, VIIPER_KEYBOARD_KEY_L, 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 client library is generated -- [Go Client Documentation](go.md): Reference implementation -- [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/cpp.md b/docs/clients/cpp.md index c1382db6..15ab4a0f 100644 --- a/docs/clients/cpp.md +++ b/docs/clients/cpp.md @@ -441,7 +441,6 @@ viiper server --api-addr localhost:3242 ## See Also - [Generator Documentation](generator.md): How generated client libraries work -- [C Client Library Documentation](c.md): Pure C alternative - [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 diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index 90f92542..eb033a6e 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -313,16 +313,16 @@ finally 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 + - 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 + - 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 + - Presses buttons and moves sticks + - Handles rumble feedback ### Running Examples @@ -357,7 +357,6 @@ The generated code uses nullable annotations. You may see warnings like CS8601/C - [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](c.md): Alternative client library for native integration - [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 f3e1dc0f..dfa9100c 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -19,7 +19,6 @@ The VIIPER client generator scans Go source code to extract API routes, device w ```bash go run ./cmd/viiper codegen --lang=all # Generate all client libraries -go run ./cmd/viiper codegen --lang=c # Generate C client library only go run ./cmd/viiper codegen --lang=csharp # Generate C# client library only go run ./cmd/viiper codegen --lang=typescript # Generate TypeScript client library only ``` @@ -60,7 +59,7 @@ type InputState struct { ... } 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 (C#/TS) or `#define` macros (C) based on common prefixes +- **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 @@ -105,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 @@ -121,19 +119,6 @@ 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 and Map Export **Go source (`/device/keyboard/const.go`):** @@ -155,18 +140,6 @@ var CharToKey = map[byte]byte{ } ``` -**Emitted C header (`viiper_keyboard.h`):** - -```c -#define VIIPER_KEYBOARD_MOD_LEFT_CTRL 0x1 -#define VIIPER_KEYBOARD_MOD_LEFT_SHIFT 0x2 -#define VIIPER_KEYBOARD_KEY_A 0x4 -#define VIIPER_KEYBOARD_KEY_B 0x5 - -// Map lookup function -int viiper_keyboard_char_to_key_lookup(uint8_t key, uint8_t* out_value); -``` - **Emitted C# (`KeyboardConstants.cs`):** ```csharp @@ -214,13 +187,11 @@ Run codegen when any of these change: ## Language-Specific Notes -- **C**: `#define` macros for constants; switch-based lookup functions for maps; manual memory management for variable-length fields; builds with CMake. - **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 - [Go Client Documentation](go.md): Go reference client usage -- [C Client Library Documentation](c.md): C-specific usage, build, and examples - [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 ce422174..80d9e87c 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -208,7 +208,6 @@ Full working examples are available in the repository: ## See Also - [Generator Documentation](generator.md): How generated client libraries work -- [C Client Library Documentation](c.md): Generated C client library usage - [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 diff --git a/docs/clients/rust.md b/docs/clients/rust.md index bb9b0ff4..72801e4c 100644 --- a/docs/clients/rust.md +++ b/docs/clients/rust.md @@ -393,19 +393,19 @@ viiper-client = { version = "0.1", features = ["async"] } 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 + - 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 + - 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 + - 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 + - Cycles through buttons + - Handles rumble feedback ## See Also @@ -413,7 +413,6 @@ Full working examples are available in the repository: - [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](c.md): Native C 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 index 6f34ae0d..dec3d7c4 100644 --- a/docs/clients/typescript.md +++ b/docs/clients/typescript.md @@ -340,16 +340,16 @@ try { 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 + - 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 + - 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 + - Runs at 60fps with cycling buttons and animated triggers + - Handles rumble feedback ### Running Examples @@ -367,7 +367,6 @@ node dist/virtual_keyboard.js localhost:3242 - [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](c.md): Alternative client library for native integration - [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/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c1383a8c..45c58669 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -53,11 +53,10 @@ 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++, C#, Go, Rust, and TypeScript. They handle the protocol details automatically, providing type-safe interfaces and device-specific helpers. +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/c.md) - [C++ Client Library Documentation](../clients/cpp.md) - [C# Client Library Documentation](../clients/csharp.md) - [TypeScript Client Library Documentation](../clients/typescript.md) @@ -191,7 +190,7 @@ For a complete list of supported devices, their specifications, and wire protoco Now that you have a working setup: -1. **Explore Examples**: Check the `examples/` directory for complete working programs in C, C#, Go, and TypeScript +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) 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 821786bf..00000000 --- a/examples/c/virtual_keyboard/main.c +++ /dev/null @@ -1,163 +0,0 @@ -#include "viiper.h" -#include "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_disconnect(void* user) -{ - (void)user; /* unused */ - printf("!!! Server disconnected\n"); -} - -static void on_leds(void* buffer, size_t bytes_read, void* user) -{ - const uint8_t* led_data = (const uint8_t*)buffer; - (void)user; /* unused */ - if (bytes_read == 0) return; - /* Keyboard LED state is 1 byte messages; handle possible coalesced bytes */ - for (size_t i = 0; i < bytes_read; ++i) { - uint8_t b = led_data[i]; - printf("→ LEDs: Num=%u Caps=%u Scroll=%u Compose=%u Kana=%u\n", - (b & VIIPER_KEYBOARD_LED_NUM_LOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LED_CAPS_LOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LED_SCROLL_LOCK) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LED_COMPOSE) ? 1u : 0u, - (b & VIIPER_KEYBOARD_LED_KANA) ? 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_MOD_LEFT_SHIFT, VIIPER_KEYBOARD_KEY_H); /* 'H' */ - press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_E); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_L); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_L); - press_and_release(dev, 0, VIIPER_KEYBOARD_KEY_O); - /* '!' = Shift + '1' */ - press_and_release(dev, VIIPER_KEYBOARD_MOD_LEFT_SHIFT, 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 and connect device in one step */ - viiper_device_info_t addResp; memset(&addResp, 0, sizeof addResp); - viiper_device_t* dev = NULL; - const char* deviceType = "keyboard"; - viiper_device_create_request_t createReq = { - .Type = &deviceType, - .IdVendor = NULL, - .IdProduct = NULL - }; - viiper_error_t rc = viiper_add_device_and_connect(client, busId, &createReq, &addResp, &dev); - if (rc != VIIPER_OK) { - fprintf(stderr, "add_device_and_connect failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); return 1; - } - const char* devId = addResp.DevId ? addResp.DevId : "(none)"; - printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); - - /* Register LED callback with user-allocated buffer */ - static uint8_t led_buffer[VIIPER_KEYBOARD_OUTPUT_SIZE]; - viiper_device_on_output(dev, led_buffer, sizeof(led_buffer), on_leds, NULL); - - /* Register disconnect callback */ - viiper_device_on_disconnect(dev, on_disconnect, 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_KEY_ENTER); - 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 11566f62..00000000 --- a/examples/c/virtual_x360_pad/CMakeLists.txt +++ /dev/null @@ -1,27 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(virtual_x360_pad C) - -set(CMAKE_C_STANDARD 99) - -set(VIIPER_SDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../clients/c") -set(VIIPER_INCLUDE_DIR "${VIIPER_SDK_ROOT}/include") - -add_executable(virtual_x360_pad main.c) - -target_include_directories(virtual_x360_pad PRIVATE "${VIIPER_INCLUDE_DIR}") - -if (WIN32) - set(VIIPER_LIB "${VIIPER_SDK_ROOT}/build/Release/viiper.lib") -else() - set(VIIPER_LIB "${VIIPER_SDK_ROOT}/build/libviiper.a") -endif() - -target_link_libraries(virtual_x360_pad PRIVATE "${VIIPER_LIB}") - -if (WIN32) - add_custom_command(TARGET virtual_x360_pad POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VIIPER_SDK_ROOT}/build/Release/viiper.dll" - "$" - ) -endif() diff --git a/examples/c/virtual_x360_pad/main.c b/examples/c/virtual_x360_pad/main.c deleted file mode 100644 index 560cb08b..00000000 --- a/examples/c/virtual_x360_pad/main.c +++ /dev/null @@ -1,184 +0,0 @@ -#include "viiper.h" -#include "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_disconnect(void* user) -{ - (void)user; /* unused */ - printf("!!! Server disconnected\n"); -} - -static void on_rumble(void* buffer, size_t bytes_read, void* user) -{ - (void)user; /* unused */ - const uint8_t* rumble_data = (const uint8_t*)buffer; - if (bytes_read >= 2) { - const viiper_xbox360_output_t* out = (const viiper_xbox360_output_t*)rumble_data; - 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; - } - - viiper_device_info_t addResp; memset(&addResp, 0, sizeof addResp); - viiper_device_t* dev = NULL; - const char* deviceType = "xbox360"; - viiper_device_create_request_t createReq = { .Type = &deviceType, .IdVendor = NULL, .IdProduct = NULL }; - viiper_error_t rc = viiper_add_device_and_connect(client, busId, &createReq, &addResp, &dev); - if (rc != VIIPER_OK){ - fprintf(stderr, "add_device_and_connect failed: %s\n", viiper_get_error(client)); - viiper_client_free(client); - return 1; - } - const char* devId = addResp.DevId ? addResp.DevId : "(none)"; - printf("Created and connected device %s on bus %u (type: %s)\n", devId, (unsigned)busId, addResp.Type ? addResp.Type : "unknown"); - - /* Register async backchannel callback with user-allocated buffer */ - static uint8_t rumble_buffer[VIIPER_XBOX360_OUTPUT_SIZE]; - viiper_device_on_output(dev, rumble_buffer, sizeof(rumble_buffer), on_rumble, NULL); - - /* Register disconnect callback */ - viiper_device_on_disconnect(dev, on_disconnect, 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_BUTTON_A; - break; - case 1: - in.buttons = VIIPER_XBOX360_BUTTON_B; - break; - case 2: - in.buttons = VIIPER_XBOX360_BUTTON_X; - break; - default: - in.buttons = VIIPER_XBOX360_BUTTON_Y; - 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) - viiper_bus_remove_response_t br; - memset(&br, 0, sizeof br); - if (viiper_bus_remove(client, busId, &br) != VIIPER_OK){ - fprintf(stderr, "BusRemove failed (continuing): %s\n", viiper_get_error(client)); - } - viiper_client_free(client); - return 0; -} diff --git a/internal/codegen/generator/c/cmake.go b/internal/codegen/generator/c/cmake.go deleted file mode 100644 index e1553ff1..00000000 --- a/internal/codegen/generator/c/cmake.go +++ /dev/null @@ -1,87 +0,0 @@ -package cgen - -import ( - "bytes" - "fmt" - "log/slog" - "os" - "path/filepath" - "sort" - "text/template" - - "github.com/Alia5/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 -set(VIIPER_SOURCES - src/viiper.c -{{range .Devices}} src/viiper_{{.}}.c -{{end}}) - -add_library(viiper SHARED ${VIIPER_SOURCES}) -add_library(viiper_static STATIC ${VIIPER_SOURCES}) - -if(NOT WIN32) - set_target_properties(viiper_static PROPERTIES OUTPUT_NAME viiper) -endif() - -# Include directories -target_include_directories(viiper PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include -) -target_include_directories(viiper_static PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include -) - -# Platform-specific settings -if(WIN32) - target_compile_definitions(viiper PRIVATE VIIPER_BUILD) - target_compile_definitions(viiper_static PRIVATE VIIPER_BUILD) - target_link_libraries(viiper ws2_32) - target_link_libraries(viiper_static ws2_32) -else() - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden") -endif() - -# Installation -install(TARGETS viiper viiper_static - 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/internal/codegen/generator/c/gen.go b/internal/codegen/generator/c/gen.go deleted file mode 100644 index 605b671c..00000000 --- a/internal/codegen/generator/c/gen.go +++ /dev/null @@ -1,65 +0,0 @@ -package cgen - -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") - 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, major, minor, patch); 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 - } - - 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/c/header_common.go b/internal/codegen/generator/c/header_common.go deleted file mode 100644 index 5722d2ce..00000000 --- a/internal/codegen/generator/c/header_common.go +++ /dev/null @@ -1,215 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const commonHeaderTmpl = `#ifndef VIIPER_H -#define VIIPER_H - -/* Auto-generated VIIPER - C Client Library: common header */ - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#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 {{.Major}} -#define VIIPER_VERSION_MINOR {{.Minor}} -#define VIIPER_VERSION_PATCH {{.Patch}} - -/* 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 client library) */ -{{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 */ -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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, - {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, - {{responseCType .ResponseDTO}}* out{{end}} -); -{{end}} - -/* ======================================================================== - * Device Streaming API - * ======================================================================== */ - -/* Callback type for receiving device output */ -typedef void (*viiper_output_cb)(void* buffer, size_t bytes_read, void* user_data); - -/* Callback type for disconnect notification */ -typedef void (*viiper_disconnect_cb)(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) - * User provides buffer - client library reads directly into it and calls callback with byte count */ -VIIPER_API void viiper_device_on_output( - viiper_device_t* device, - void* buffer, - size_t buffer_size, - viiper_output_cb callback, - void* user_data -); - -/* Register callback for disconnect notification (called when connection is lost) */ -VIIPER_API void viiper_device_on_disconnect( - viiper_device_t* device, - viiper_disconnect_cb callback, - void* user_data -); - -/* Close device stream and free resources */ -VIIPER_API void viiper_device_close(viiper_device_t* device); - -/* OpenStream: connect to an existing device's stream channel (device must already exist) */ -VIIPER_API viiper_error_t viiper_open_stream( - viiper_client_t* client, - uint32_t bus_id, - const char* dev_id, - viiper_device_t** out_device -); - -/* Convenience: AddDeviceAndConnect (create device then connect its stream) */ -VIIPER_API viiper_error_t viiper_add_device_and_connect( - viiper_client_t* client, - uint32_t bus_id, - const viiper_device_create_request_t* request, - viiper_device_info_t* out_info, - viiper_device_t** out_device -); - -#ifdef __cplusplus -} -#endif - -#endif /* VIIPER_H */ -` - -func generateCommonHeader(logger *slog.Logger, includeDir string, md *meta.Metadata, major, minor, patch int) 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() - - // Create data with version and metadata - data := struct { - *meta.Metadata - Major int - Minor int - Patch int - }{ - Metadata: md, - Major: major, - Minor: minor, - Patch: patch, - } - - if err := t.Execute(f, data); err != nil { - return fmt.Errorf("exec header tmpl: %w", err) - } - logger.Info("Generated C header", "file", out) - return nil -} diff --git a/internal/codegen/generator/c/header_device.go b/internal/codegen/generator/c/header_device.go deleted file mode 100644 index 743922ba..00000000 --- a/internal/codegen/generator/c/header_device.go +++ /dev/null @@ -1,100 +0,0 @@ -package cgen - -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 deviceHeaderTmpl = `#ifndef VIIPER_{{upper .Device}}_H -#define VIIPER_{{upper .Device}}_H - -/* Auto-generated VIIPER - C Client Library: {{.Device}} device */ - -/* Minimal path fix: include common header without duplicated module path */ -#include "viiper.h" - -/* ======================================================================== - * {{.Device}} Constants - * ======================================================================== */ -/* Device output size (bytes to read from socket in callback) */ -#define VIIPER_{{upper .Device}}_OUTPUT_SIZE {{.OutputSize}} - -{{- if gt (len .Pkg.Constants) 0 }} -/* {{.Device}} constants */ -{{range .Pkg.Constants -}} -#define VIIPER_{{upper $.Device}}_{{upper (snakecase .Name)}} {{cConstValue .}} -{{end}} -{{- end}} - -/* ======================================================================== - * {{.Device}} Lookup Maps - * ======================================================================== */ -{{- range .Pkg.Maps }} -/* {{.Name}} lookup function */ -{{mapFuncDecl $.Device .}} -{{- 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 - OutputSize int -} - -func generateDeviceHeader(logger *slog.Logger, includeDir, device string, md *meta.Metadata) error { - pkg := md.DevicePackages[device] - - // Calculate OUTPUT_SIZE from s2c wire tag - outputSize := 0 - if md.WireTags != nil { - if s2cTag := md.WireTags.GetTag(device, "s2c"); s2cTag != nil { - outputSize = common.CalculateOutputSize(s2cTag) - } - } - - data := deviceHeaderData{ - Device: device, - Pkg: pkg, - OutputSize: outputSize, - } - 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/internal/codegen/generator/c/helpers.go b/internal/codegen/generator/c/helpers.go deleted file mode 100644 index 03da59f4..00000000 --- a/internal/codegen/generator/c/helpers.go +++ /dev/null @@ -1,592 +0,0 @@ -package cgen - -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{ - "ctype": cType, - "snakecase": common.ToSnakeCase, - "upper": strings.ToUpper, - "hasWireTag": func(device, direction string) bool { return common.HasWireTag(md, device, direction) }, - "wireFields": func(device, direction string) string { return wireFieldsString(md, device, direction) }, - "indent": indent, - "fieldDecl": func(f scanner.FieldInfo) string { return fieldDecl(md, f) }, - "cConstValue": cConstValue, - "pathParams": common.ExtractPathParams, - "join": strings.Join, - "mapFuncDecl": mapFuncDecl, - "mapFuncImpl": mapFuncImpl, - "payloadCType": func(pi scanner.PayloadInfo) string { return payloadCType(md, pi) }, - "responseCType": func(name string) string { return responseCType(md, name) }, - "marshalPayload": func(pi scanner.PayloadInfo) string { return marshalPayload(md, pi) }, - "genFreeFunc": func(dto scanner.DTOSchema) string { return generateFreeFunction(md, dto) }, - "genParser": func(dto scanner.DTOSchema) string { return generateParser(md, dto) }, - } -} - -func cConstValue(c scanner.ConstantInfo) string { - base, _, _ := common.NormalizeGoType(c.Type) - - if s, ok := c.Value.(string); ok { - if base == "string" { - return fmt.Sprintf("\"%s\"", s) - } - if _, err := strconv.ParseInt(s, 0, 64); err == nil { - return s - } - if _, err := strconv.ParseUint(s, 0, 64); err == nil { - return s - } - if _, err := strconv.ParseFloat(s, 64); err == nil { - return s - } - return s - } - - switch v := c.Value.(type) { - case uint64: - if strings.HasPrefix(base, "uint") || base == "byte" { - return fmt.Sprintf("0x%X", v) - } - return fmt.Sprintf("%d", v) - case int64: - if strings.HasPrefix(base, "uint") || base == "byte" { - if v < 0 { - return fmt.Sprintf("%d", v) - } - return fmt.Sprintf("0x%X", uint64(v)) - } - return fmt.Sprintf("%d", v) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - default: - return fmt.Sprintf("%v", c.Value) - } -} - -func payloadCType(md *meta.Metadata, pi scanner.PayloadInfo) string { - switch pi.Kind { - case scanner.PayloadJSON: - if pi.RawType != "" { - return fmt.Sprintf("const viiper_%s_t*", dtoToCTypeName(md, pi.RawType)) - } - return "const char*" // fallback to raw JSON string - case scanner.PayloadNumeric: - if pi.Required { - return "uint32_t" - } - return "uint32_t*" - case scanner.PayloadString: - return "const char*" - default: - return "" - } -} - -func responseCType(md *meta.Metadata, dtoName string) string { - if dtoName == "" { - return "" - } - return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, dtoName)) -} - -func dtoToCTypeName(md *meta.Metadata, dtoName string) string { - if md.CTypeNames != nil { - if mapped, ok := md.CTypeNames[dtoName]; ok { - return mapped - } - } - return common.ToSnakeCase(dtoName) -} - -func marshalPayload(md *meta.Metadata, pi scanner.PayloadInfo) string { - if pi.Kind != scanner.PayloadJSON || pi.RawType == "" { - return "" - } - - var dto *scanner.DTOSchema - for i := range md.DTOs { - if md.DTOs[i].Name == pi.RawType { - dto = &md.DTOs[i] - break - } - } - if dto == nil { - return "" - } - - varName := "request" - lines := []string{ - fmt.Sprintf("if (%s) {", varName), - } - - firstField := true - for _, f := range dto.Fields { - isPointer := strings.HasPrefix(f.Type, "*") - baseType := strings.TrimPrefix(f.Type, "*") - - var condition string - if !f.Optional { - condition = "" - } else if isPointer { - condition = fmt.Sprintf("if (%s->%s) ", varName, f.Name) - } else { - condition = "" - } - - var fieldCode string - switch baseType { - case "string": - if firstField { - fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":\\\"%%s\\\"\", *%s->%s);", - condition, f.JSONName, varName, f.Name) - } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":\\\"%%s\\\"\", *%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", - condition, f.JSONName, varName, f.Name) - } - case "uint16", "uint32": - if firstField { - fieldCode = fmt.Sprintf("%ssnprintf(payload, sizeof payload, \"{\\\"%s\\\":%%u\", (unsigned)*%s->%s);", - condition, f.JSONName, varName, f.Name) - } else { - fieldCode = fmt.Sprintf("%s{ char tmp[64]; snprintf(tmp, sizeof tmp, \",\\\"%s\\\":%%u\", (unsigned)*%s->%s); strncat(payload, tmp, sizeof(payload) - strlen(payload) - 1); }", - condition, f.JSONName, varName, f.Name) - } - default: - continue - } - - if firstField { - firstField = false - } - - lines = append(lines, " "+fieldCode) - } - - lines = append(lines, " strncat(payload, \"}\", sizeof(payload) - strlen(payload) - 1);") - lines = append(lines, " }") - - return strings.Join(lines, "\n") -} - -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 kind == "struct" { - return fmt.Sprintf("viiper_%s_t*", common.ToSnakeCase(goType)) - } - return goType - } -} - -func wireFieldsString(md *meta.Metadata, device, direction string) string { - tag := common.GetWireTag(md, 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, common.ToSnakeCase(field.Name))) - continue - } - lines = append(lines, fmt.Sprintf("%s %s;", wireBaseToC(field.Type), field.Name)) - } - return strings.Join(lines, "\n ") -} - -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 fieldDecl(md *meta.Metadata, f scanner.FieldInfo) string { - if f.TypeKind == "slice" || strings.HasPrefix(f.Type, "[]") { - elem := strings.TrimPrefix(f.Type, "[]") - cElem := fieldTypeToCType(md, elem) - return fmt.Sprintf("%s* %s; size_t %s_count;%s", cElem, f.Name, common.ToSnakeCase(f.Name), optComment(f)) - } - - if strings.HasPrefix(f.Type, "*") { - elem := strings.TrimPrefix(f.Type, "*") - cElem := fieldTypeToCType(md, elem) - return fmt.Sprintf("%s* %s;%s", cElem, f.Name, optComment(f)) - } - - return fmt.Sprintf("%s %s;%s", fieldTypeToCType(md, f.Type), f.Name, optComment(f)) -} - -func fieldTypeToCType(md *meta.Metadata, goType string) string { - 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 "int", "int64": - return "int64_t" - case "bool": - return "int" - case "float32": - return "float" - case "float64": - return "double" - } - - for _, dto := range md.DTOs { - if dto.Name == goType { - return fmt.Sprintf("viiper_%s_t", dtoToCTypeName(md, goType)) - } - } - - return fmt.Sprintf("viiper_%s_t", common.ToSnakeCase(goType)) -} - -func optComment(f scanner.FieldInfo) string { - if f.Optional { - return " /* optional */" - } - return "" -} - -func mapFuncDecl(device string, mapInfo scanner.MapInfo) string { - keyType := mapGoTypeToCType(mapInfo.KeyType) - valueType := mapGoTypeToCType(mapInfo.ValueType) - funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) - - return fmt.Sprintf("int %s(%s key, %s* out_value);", funcName, keyType, valueType) -} - -func mapFuncImpl(device string, mapInfo scanner.MapInfo) string { - keyType := mapGoTypeToCType(mapInfo.KeyType) - valueType := mapGoTypeToCType(mapInfo.ValueType) - funcName := fmt.Sprintf("viiper_%s_%s_lookup", device, common.ToSnakeCase(mapInfo.Name)) - - var builder strings.Builder - builder.WriteString(fmt.Sprintf("int %s(%s key, %s* out_value) {\n", funcName, keyType, valueType)) - builder.WriteString(" if (!out_value) return 0;\n") - builder.WriteString(" switch (key) {\n") - - keys := common.SortedStringKeys(mapInfo.Entries) - for _, keyStr := range keys { - value := mapInfo.Entries[keyStr] - cKey := formatCMapKey(keyStr, mapInfo.KeyType, device) - cValue := formatCMapValue(value, mapInfo.ValueType, device) - builder.WriteString(fmt.Sprintf(" case %s: *out_value = %s; return 1;\n", cKey, cValue)) - } - - builder.WriteString(" default: return 0;\n") - builder.WriteString(" }\n") - builder.WriteString("}\n") - - return builder.String() -} - -func mapGoTypeToCType(goType string) string { - switch goType { - case "byte", "uint8": - return "uint8_t" - case "uint16": - return "uint16_t" - case "uint32", "uint": - return "uint32_t" - case "uint64": - return "uint64_t" - case "int8": - return "int8_t" - case "int16": - return "int16_t" - case "int32", "int": - return "int32_t" - case "int64": - return "int64_t" - case "bool": - return "int" - case "string": - return "const char*" - default: - return goType - } -} - -func formatCMapKey(key string, goType string, device string) string { - switch goType { - case "byte", "uint8": - if len(key) == 1 { - switch key[0] { - case '\n': - return "'\\n'" - case '\r': - return "'\\r'" - case '\t': - return "'\\t'" - case '\\': - return "'\\\\'" - case '\'': - return "'\\''" - case 0: - return "'\\0'" - } - if key[0] >= 32 && key[0] <= 126 { - return fmt.Sprintf("'%s'", key) - } - return fmt.Sprintf("0x%02X", key[0]) - } - if len(key) > 0 && (key[0] >= 'A' && key[0] <= 'Z') { - prefix := common.ExtractPrefix(key) - if prefix != "" { - constName := strings.ToUpper(common.ToSnakeCase(key)) - return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) - } - } - return key - case "string": - return fmt.Sprintf("\"%s\"", key) - default: - return key - } -} - -func formatCMapValue(value interface{}, goType string, device string) string { - switch goType { - case "byte", "uint8", "uint16", "uint32", "uint64": - if str, ok := value.(string); ok && !strings.Contains(str, " ") { - if len(str) > 0 && (str[0] >= 'A' && str[0] <= 'Z') { - prefix := common.ExtractPrefix(str) - if prefix != "" { - constName := strings.ToUpper(common.ToSnakeCase(str)) - return fmt.Sprintf("VIIPER_%s_%s", strings.ToUpper(device), constName) - } - } - return str - } - if num, ok := value.(int64); ok { - return fmt.Sprintf("0x%X", num) - } - if num, ok := value.(uint64); ok { - return fmt.Sprintf("0x%X", num) - } - return fmt.Sprintf("%v", value) - case "bool": - if b, ok := value.(bool); ok { - if b { - return "1" - } - return "0" - } - if str, ok := value.(string); ok { - if str == "true" { - return "1" - } - if str == "false" { - return "0" - } - return str - } - return "0" - case "string": - if str, ok := value.(string); ok { - return fmt.Sprintf("\"%s\"", str) - } - return fmt.Sprintf("\"%v\"", value) - default: - return fmt.Sprintf("%v", value) - } -} - -func generateFreeFunction(md *meta.Metadata, dto scanner.DTOSchema) string { - snakeName := dtoToCTypeName(md, dto.Name) - lines := []string{ - fmt.Sprintf("VIIPER_API void viiper_free_%s(viiper_%s_t* v){", snakeName, snakeName), - } - - hasPointers := false - for _, f := range dto.Fields { - if strings.HasPrefix(f.Type, "*") || strings.HasPrefix(f.Type, "[]") { - hasPointers = true - break - } - } - - if !hasPointers { - lines = append(lines, " (void)v; }") - return strings.Join(lines, "") - } - - lines = append(lines, " if (!v) return;") - - for _, f := range dto.Fields { - baseType := strings.TrimPrefix(f.Type, "*") - baseType = strings.TrimPrefix(baseType, "[]") - - if strings.HasPrefix(f.Type, "[]") { - if baseType != "string" && baseType != "uint8" && baseType != "uint16" && baseType != "uint32" && baseType != "uint64" { - lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ ", f.Name, common.ToSnakeCase(f.JSONName))) - for _, dtoSchema := range md.DTOs { - if dtoSchema.Name == baseType { - for _, field := range dtoSchema.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - if fieldType == "string" { - lines = append(lines, fmt.Sprintf("if (v->%s[i].%s) free((void*)v->%s[i].%s); ", f.Name, field.Name, f.Name, field.Name)) - } - } - break - } - } - lines = append(lines, " } free(v->"+f.Name+");}") - } else if baseType == "string" { - lines = append(lines, fmt.Sprintf(" if (v->%s){ for (size_t i=0;i%s_count;i++){ if (v->%s[i]) free((void*)v->%s[i]); } free(v->%s);}", f.Name, common.ToSnakeCase(f.JSONName), f.Name, f.Name, f.Name)) - } else { - lines = append(lines, fmt.Sprintf(" if (v->%s) free(v->%s);", f.Name, f.Name)) - } - } else if strings.HasPrefix(f.Type, "*") && baseType == "string" { - lines = append(lines, fmt.Sprintf(" if (v->%s) free((void*)v->%s);", f.Name, f.Name)) - } - } - - lines = append(lines, " }") - return strings.Join(lines, "") -} - -func generateParser(md *meta.Metadata, dto scanner.DTOSchema) string { - snakeName := dtoToCTypeName(md, dto.Name) - parserName := fmt.Sprintf("viiper_parse_%s", snakeName) - if strings.HasSuffix(snakeName, "_info") { - parserName = fmt.Sprintf("viiper_parse_%s_obj", snakeName) - } - - lines := []string{ - fmt.Sprintf("static int %s(const char* json, viiper_%s_t* out){", parserName, snakeName), - } - - var parserCalls []string - for _, f := range dto.Fields { - baseType := strings.TrimPrefix(f.Type, "*") - baseType = strings.TrimPrefix(baseType, "[]") - - required := !f.Optional - - if strings.HasPrefix(f.Type, "[]") { - if baseType == "uint32" { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_array_uint32(json, \"%s\", &out->%s, &out->%s_count)", f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) - } else { - parserFuncName := fmt.Sprintf("json_parse_array_%s", dtoToCTypeName(md, baseType)) - parserCalls = append(parserCalls, fmt.Sprintf("%s(json, \"%s\", &out->%s, &out->%s_count)", parserFuncName, f.JSONName, f.Name, common.ToSnakeCase(f.JSONName))) - } - } else if baseType == "string" { - if required { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_string_alloc(json, \"%s\", (char**)&out->%s)", f.JSONName, f.Name)) - } else { - lines = append(lines, fmt.Sprintf(" json_parse_string_alloc(json, \"%s\", (char**)&out->%s);", f.JSONName, f.Name)) - } - } else if baseType == "uint32" { - parserCalls = append(parserCalls, fmt.Sprintf("json_parse_uint32(json, \"%s\", &out->%s)", f.JSONName, f.Name)) - } - } - - if len(parserCalls) == 0 { - lines = append(lines, " return 0; }") - } else if len(parserCalls) == 1 { - lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", parserCalls[0])) - } else { - for i, call := range parserCalls { - if i < len(parserCalls)-1 { - lines = append(lines, fmt.Sprintf(" if (%s!=0) return -1;", call)) - } else { - lines = append(lines, fmt.Sprintf(" return %s==0?0:-1; }", call)) - } - } - } - - return strings.Join(lines, "") -} diff --git a/internal/codegen/generator/c/source_common.go b/internal/codegen/generator/c/source_common.go deleted file mode 100644 index 22612f3a..00000000 --- a/internal/codegen/generator/c/source_common.go +++ /dev/null @@ -1,671 +0,0 @@ -package cgen - -import ( - "fmt" - "log/slog" - "os" - "path/filepath" - "text/template" - - "github.com/Alia5/VIIPER/internal/codegen/meta" -) - -const commonSourceTmpl = `/* Auto-generated VIIPER - C Client Library: common source */ - -#include "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 -# 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 */ - void* output_buffer; - size_t output_buffer_size; - viiper_output_cb callback; - void* callback_user; - /* Disconnect callback */ - viiper_disconnect_cb disconnect_callback; - void* disconnect_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 - * ======================================================================== */ - -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) { - int flag = 1; -#if defined(_WIN32) || defined(_WIN64) - setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (const char*)&flag, sizeof(flag)); -#else - setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); -#endif - 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, "\0", 1, 0); - if (wr < 0) return -1; -#else - ssize_t wr = send(fd, line, n, 0); - if (wr < 0) return -1; - wr = send(fd, "\0", 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 (rd == 0) break; - if (ch == '\0') 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 */ -{{range .DTOs}}{{if ne .Name "Device"}} -{{genFreeFunc .}} -{{end}}{{end}} - -/* Parsers */ -static int viiper_parse_device_info_obj(const char* json, viiper_device_info_t* out){ if (json_parse_uint32(json, "busId", &out->BusID)!=0) return -1; if (json_parse_string_alloc(json, "devId", (char**)&out->DevId)!=0) return -1; json_parse_string_alloc(json, "vid", (char**)&out->Vid); json_parse_string_alloc(json, "pid", (char**)&out->Pid); json_parse_string_alloc(json, "type", (char**)&out->Type); return 0; } -{{range .DTOs}}{{if ne .Name "Device"}} -{{genParser .}} -{{end}}{{end}} - -/* ======================================================================== - * 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}}{{$payloadType := payloadCType .Payload}}{{if ne $payloadType ""}}, - {{$payloadType}} {{if eq .Payload.Kind "json"}}request{{else if eq .Payload.Kind "numeric"}}payload_value{{else}}payload_str{{end}}{{end}}{{if .ResponseDTO}}, - {{responseCType .ResponseDTO}}* 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 based on PayloadKind */ - char payload[512]; payload[0]='\0'; - {{if eq .Payload.Kind "json"}} - {{marshalPayload .Payload}} - {{else if eq .Payload.Kind "numeric"}}{{if .Payload.Required}} - snprintf(payload, sizeof payload, "%u", (unsigned)payload_value); - {{else}} - if (payload_value) { - snprintf(payload, sizeof payload, "%u", (unsigned)*payload_value); - } - {{end}} - {{else if eq .Payload.Kind "string"}} - if (payload_str && payload_str[0]) { - snprintf(payload, sizeof payload, "%s", payload_str); - } - {{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: {"status":4xx/5xx} (RFC 7807) */ - if (line && strncmp(line, "{\"status\":", 11) == 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 "Device"}}prc = viiper_parse_device_info_obj(line, out);{{else}}prc = viiper_parse_{{snakecase .ResponseDTO}}(line, out);{{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; - 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 */ - if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { - int rd = recv(dev->socket_fd, (char*)dev->output_buffer, (int)dev->output_buffer_size, 0); - if (rd <= 0) break; - dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); - } -#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 */ - if (dev->callback && dev->output_buffer && dev->output_buffer_size > 0) { - ssize_t rd = recv(dev->socket_fd, dev->output_buffer, dev->output_buffer_size, 0); - if (rd <= 0) break; - dev->callback(dev->output_buffer, (size_t)rd, dev->callback_user); - } -#endif - } - /* Call disconnect callback if registered */ - if (dev->disconnect_callback) { - dev->disconnect_callback(dev->disconnect_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 with null terminator for framing */ - char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); - if (viiper_send_line(fd, pathbuf) != 0) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return VIIPER_ERROR_IO; - } - 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, - void* buffer, - size_t buffer_size, - viiper_output_cb callback, - void* user_data -) { - if (!device) return; - device->output_buffer = buffer; - device->output_buffer_size = buffer_size; - device->callback = callback; - device->callback_user = user_data; -} - -VIIPER_API void viiper_device_on_disconnect( - viiper_device_t* device, - viiper_disconnect_cb callback, - void* user_data -) { - if (!device) return; - device->disconnect_callback = callback; - device->disconnect_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); -} - -/* OpenStream: connect to an existing device's stream channel (device must already exist on bus) */ -VIIPER_API viiper_error_t viiper_open_stream( - 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; - char pathbuf[256]; - snprintf(pathbuf, sizeof pathbuf, "bus/%u/%s", (unsigned)bus_id, dev_id); - if (viiper_send_line(fd, pathbuf) != 0) { -#if defined(_WIN32) || defined(_WIN64) - closesocket(fd); -#else - close(fd); -#endif - return VIIPER_ERROR_IO; - } - 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; -#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; -} - -/* Convenience wrapper: AddDeviceAndConnect (create device on bus then open stream) */ -VIIPER_API viiper_error_t viiper_add_device_and_connect( - viiper_client_t* client, - uint32_t bus_id, - const viiper_device_create_request_t* request, - viiper_device_info_t* out_info, - viiper_device_t** out_device -) { - if (!client || !out_info || !out_device) return VIIPER_ERROR_INVALID_PARAM; - char busIdStr[32]; snprintf(busIdStr, sizeof busIdStr, "%u", (unsigned)bus_id); - viiper_error_t rc = viiper_bus_device_add(client, busIdStr, request, out_info); - if (rc != VIIPER_OK) return rc; - const char* devId = out_info->DevId ? out_info->DevId : NULL; - if (!devId) return VIIPER_ERROR_PROTOCOL; /* missing devId */ - return viiper_open_stream(client, bus_id, devId, out_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/internal/codegen/generator/c/source_device.go b/internal/codegen/generator/c/source_device.go deleted file mode 100644 index 6f4c57cf..00000000 --- a/internal/codegen/generator/c/source_device.go +++ /dev/null @@ -1,54 +0,0 @@ -package cgen - -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 deviceSourceTmpl = `/* Auto-generated VIIPER - C Client Library: device source ({{.Device}}) */ - -#include "viiper.h" -#include "viiper_{{.Device}}.h" - -/* ======================================================================== - * {{.Device}} Map Implementations - * ======================================================================== */ -{{- range .Pkg.Maps }} - -{{mapFuncImpl $.Device .}} -{{- end}} -` - -type deviceSourceData struct { - Device string - HasS2C bool - Pkg *scanner.DeviceConstants -} - -func generateDeviceSource(logger *slog.Logger, srcDir, device string, md *meta.Metadata) error { - pkg := md.DevicePackages[device] - data := deviceSourceData{ - Device: device, - HasS2C: common.HasWireTag(md, device, "s2c"), - Pkg: pkg, - } - out := filepath.Join(srcDir, fmt.Sprintf("viiper_%s.c", device)) - t := template.Must(template.New("device.c").Funcs(tplFuncs(md)).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/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index 083b9d8b..1ccff548 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" - cgen "github.com/Alia5/VIIPER/internal/codegen/generator/c" "github.com/Alia5/VIIPER/internal/codegen/generator/cpp" "github.com/Alia5/VIIPER/internal/codegen/generator/csharp" "github.com/Alia5/VIIPER/internal/codegen/generator/rust" @@ -23,7 +22,6 @@ type Generator struct { type LanguageGenerator func(logger *slog.Logger, outputDir string, md *meta.Metadata) error var generators = map[string]LanguageGenerator{ - "c": cgen.Generate, "cpp": cpp.Generate, "csharp": csharp.Generate, "rust": rust.Generate, diff --git a/mkdocs.yml b/mkdocs.yml index eebf1660..57195b93 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,7 +69,6 @@ nav: - API & Clients: - API Overview: api/overview.md - Go Client: clients/go.md - - C Client Library: clients/c.md - C++ Client Library: clients/cpp.md - C# Client Library: clients/csharp.md - Rust Client Library: clients/rust.md From 6219d1dafd9b60c181518d91c1b4d47c796595d5 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 02:53:12 +0100 Subject: [PATCH 133/298] Update Rust Client --- examples/rust/Cargo.lock | 279 +++++++++++ .../codegen/generator/rust/async_client.go | 135 +++-- internal/codegen/generator/rust/auth.go | 463 ++++++++++++++++++ internal/codegen/generator/rust/client.go | 86 +++- internal/codegen/generator/rust/gen.go | 5 + internal/codegen/generator/rust/project.go | 7 + 6 files changed, 935 insertions(+), 40 deletions(-) create mode 100644 internal/codegen/generator/rust/auth.go diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index 45cbdfd1..5122982e 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -2,6 +2,16 @@ # 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" @@ -26,12 +36,27 @@ dependencies = [ "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" @@ -44,6 +69,72 @@ 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" @@ -56,6 +147,45 @@ 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" @@ -100,6 +230,12 @@ dependencies = [ "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" @@ -123,12 +259,55 @@ dependencies = [ "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" @@ -147,6 +326,36 @@ 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" @@ -211,6 +420,17 @@ dependencies = [ "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" @@ -236,6 +456,12 @@ dependencies = [ "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" @@ -308,19 +534,46 @@ dependencies = [ "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", @@ -442,3 +695,29 @@ 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/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index 763f0490..38546094 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -16,21 +16,83 @@ const asyncClientTemplate = `{{.Header}} use crate::error::{ProblemJson, ViiperError}; use crate::types::*; use std::net::SocketAddr; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; + +/// Stream wrapper that can be either plain or encrypted +#[cfg(feature = "async")] +pub enum AsyncStreamWrapper { + Plain(TcpStream), + Encrypted(crate::auth::AsyncEncryptedStream), +} + +#[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 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), + } + } +} /// 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 } + 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>>( @@ -38,8 +100,14 @@ impl AsyncViiperClient { path: &str, payload: Option<&str>, ) -> Result { - let mut stream = TcpStream::connect(self.addr).await?; - stream.set_nodelay(true)?; + 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 { @@ -74,31 +142,35 @@ impl AsyncViiperClient { {{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).await + AsyncDeviceStream::connect(self.addr, bus_id, dev_id, self.password.as_deref()).await } } /// An async connected device stream for bidirectional communication. -/// Uses split read/write halves to allow simultaneous sending and receiving. #[cfg(feature = "async")] pub struct AsyncDeviceStream { - reader: std::sync::Arc>, - writer: std::sync::Arc>, + 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) -> Result { - let mut stream = TcpStream::connect(addr).await?; - stream.set_nodelay(true)?; + 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 mut stream = if let Some(pwd) = password { + AsyncStreamWrapper::Encrypted(crate::auth::perform_handshake_async(tcp_stream, pwd).await?) + } else { + AsyncStreamWrapper::Plain(tcp_stream) + }; + let handshake = format!("bus/{}/{}\0", bus_id, dev_id); stream.write_all(handshake.as_bytes()).await?; - let (reader, writer) = stream.into_split(); + Ok(Self { - reader: std::sync::Arc::new(tokio::sync::Mutex::new(reader)), - writer: std::sync::Arc::new(tokio::sync::Mutex::new(writer)), + stream: std::sync::Arc::new(tokio::sync::Mutex::new(stream)), cancel_token: None, disconnect_callback: std::sync::Mutex::new(None), }) @@ -110,8 +182,8 @@ impl AsyncDeviceStream { input: &T, ) -> Result<(), ViiperError> { let bytes = input.to_bytes(); - let mut writer = self.writer.lock().await; - writer.write_all(&bytes).await?; + let mut stream = self.stream.lock().await; + stream.write_all(&bytes).await?; Ok(()) } @@ -126,27 +198,27 @@ impl AsyncDeviceStream { timeout: std::time::Duration, ) -> Result<(), ViiperError> { let bytes = input.to_bytes(); - let mut writer = self.writer.lock().await; - tokio::time::timeout(timeout, writer.write_all(&bytes)) + let mut stream = self.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 the read half of the stream and must read the exact number of bytes expected. + /// The callback receives a shared reference to the stream 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, + 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 reader = self.reader.clone(); + let stream = self.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 { @@ -158,7 +230,7 @@ impl AsyncDeviceStream { loop { tokio::select! { _ = cancel_clone.cancelled() => break, - result = callback(reader.clone()) => { + result = callback(stream.clone()) => { match result { Ok(()) => continue, Err(_) => break, @@ -166,10 +238,11 @@ impl AsyncDeviceStream { } } } - if let Some(on_disconnect) = disconnect { - on_disconnect(); + if let Some(cb) = disconnect { + cb(); } }); + self.cancel_token = Some(cancel_token); Ok(()) } @@ -187,21 +260,21 @@ impl AsyncDeviceStream { /// Send raw bytes to the device. pub async fn send_raw(&self, data: &[u8]) -> Result<(), ViiperError> { - let mut writer = self.writer.lock().await; - writer.write_all(data).await?; + let mut stream = self.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 reader = self.reader.lock().await; - reader.read(buf).await.map_err(Into::into) + let mut stream = self.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 reader = self.reader.lock().await; - reader.read_exact(buf).await?; + let mut stream = self.stream.lock().await; + stream.read_exact(buf).await?; Ok(()) } } diff --git a/internal/codegen/generator/rust/auth.go b/internal/codegen/generator/rust/auth.go new file mode 100644 index 00000000..24c543a8 --- /dev/null +++ b/internal/codegen/generator/rust/auth.go @@ -0,0 +1,463 @@ +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) +pub struct EncryptedStream { + inner: std::sync::Arc>, +} + +struct EncryptedStreamInner { + stream: TcpStream, + cipher: ChaCha20Poly1305, + send_counter: u64, + recv_buffer: Vec, +} + +impl EncryptedStream { + fn new(inner: TcpStream, session_key: [u8; 32]) -> Self { + let cipher = ChaCha20Poly1305::new(&session_key.into()); + Self { + inner: std::sync::Arc::new(std::sync::Mutex::new(EncryptedStreamInner { + stream: inner, + cipher, + send_counter: 0, + recv_buffer: Vec::new(), + })), + } + } + + pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + let inner = self.inner.lock().unwrap(); + inner.stream.set_nodelay(nodelay) + } + + pub fn try_clone(&self) -> std::io::Result { + Ok(Self { + inner: std::sync::Arc::clone(&self.inner), + }) + } + + pub fn shutdown(&self, how: std::net::Shutdown) -> std::io::Result<()> { + let inner = self.inner.lock().unwrap(); + inner.stream.shutdown(how) + } +} + +impl Read for EncryptedStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut inner = self.inner.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 { + // Normal EOF + 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.inner.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.inner.lock().unwrap(); + inner.stream.flush() + } +} + +/// Encrypted stream wrapper using ChaCha20-Poly1305 (asynchronous) +#[cfg(feature = "async")] +pub struct AsyncEncryptedStream { + inner: AsyncTcpStream, + cipher: ChaCha20Poly1305, + send_counter: u64, + recv_buffer: Vec, + read_state: ReadState, +} + +#[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 cipher = ChaCha20Poly1305::new(&session_key.into()); + Self { + inner, + cipher, + send_counter: 0, + recv_buffer: Vec::new(), + read_state: ReadState::ReadingLength { buf: [0; 4], pos: 0 }, + } + } + + pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + self.inner.set_nodelay(nodelay) + } + + pub fn into_split(self) -> (tokio::net::tcp::OwnedReadHalf, tokio::net::tcp::OwnedWriteHalf) { + self.inner.into_split() + } +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncEncryptedStream { + 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 AsyncEncryptedStream { + 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) + } +} +` + +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 index 75250e93..fd1910cb 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -16,17 +16,72 @@ const clientTemplate = `{{.Header}} use crate::error::{ProblemJson, ViiperError}; use crate::types::*; use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpStream}; +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 } + 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>>( @@ -34,8 +89,14 @@ impl ViiperClient { path: &str, payload: Option<&str>, ) -> Result { - let mut stream = TcpStream::connect(self.addr)?; - stream.set_nodelay(true)?; + 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 { @@ -70,21 +131,28 @@ impl ViiperClient { {{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) + DeviceStream::connect(self.addr, bus_id, dev_id, self.password.as_deref()) } } /// A connected device stream for bidirectional communication. pub struct DeviceStream { - stream: TcpStream, + stream: StreamWrapper, output_thread: Option>, disconnect_callback: Option>, } impl DeviceStream { - pub fn connect(addr: SocketAddr, bus_id: u32, dev_id: &str) -> Result { - let mut stream = TcpStream::connect(addr)?; - stream.set_nodelay(true)?; + 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 { diff --git a/internal/codegen/generator/rust/gen.go b/internal/codegen/generator/rust/gen.go index 892aa87e..35983774 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -54,6 +54,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { 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 { @@ -102,6 +106,7 @@ pub mod error; pub mod wire; pub mod types; pub mod client; +pub mod auth; #[cfg(feature = "async")] pub mod async_client; diff --git a/internal/codegen/generator/rust/project.go b/internal/codegen/generator/rust/project.go index ef0a1c18..d01f0fd2 100644 --- a/internal/codegen/generator/rust/project.go +++ b/internal/codegen/generator/rust/project.go @@ -29,6 +29,13 @@ 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"] From d8cd21d51e5e19291e4082cb124b5b7afe636daa Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 02:59:21 +0100 Subject: [PATCH 134/298] Ignore codegen from coverage Is only used for dev anyway --- codecov.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codecov.yml b/codecov.yml index 878b321d..cb006eb2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,8 +1,10 @@ ignore: - "examples/go/**/*" - - "**/_testing/**/*.go" + - "**/_testing/**/*" - "cmd/viiper/**/*" - - "docs" + - "docs/**/*" + - "tests/e2e/**/*" + - "internal/codegen/**/*" coverage: status: From 1232a521145b36222e38fb0fc28709a7e1f2eed2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 03:30:55 +0100 Subject: [PATCH 135/298] Rust: Fix deadlock --- .../codegen/generator/rust/async_client.go | 103 ++++++++++--- internal/codegen/generator/rust/auth.go | 142 +++++++++++++----- 2 files changed, 191 insertions(+), 54 deletions(-) diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index 38546094..b7047ee7 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -26,6 +26,20 @@ pub enum AsyncStreamWrapper { 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( @@ -40,6 +54,20 @@ impl AsyncRead for AsyncStreamWrapper { } } +#[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( @@ -74,6 +102,40 @@ impl AsyncWrite for AsyncStreamWrapper { } } +#[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 { @@ -149,7 +211,8 @@ impl AsyncViiperClient { /// An async connected device stream for bidirectional communication. #[cfg(feature = "async")] pub struct AsyncDeviceStream { - stream: std::sync::Arc>, + read_stream: std::sync::Arc>, + write_stream: std::sync::Arc>, cancel_token: Option, disconnect_callback: std::sync::Mutex>>, } @@ -160,17 +223,21 @@ impl AsyncDeviceStream { let tcp_stream = TcpStream::connect(addr).await?; tcp_stream.set_nodelay(true)?; - let mut stream = if let Some(pwd) = password { - AsyncStreamWrapper::Encrypted(crate::auth::perform_handshake_async(tcp_stream, pwd).await?) - } else { - AsyncStreamWrapper::Plain(tcp_stream) - }; - - let handshake = format!("bus/{}/{}\0", bus_id, dev_id); - stream.write_all(handshake.as_bytes()).await?; + 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 { - stream: std::sync::Arc::new(tokio::sync::Mutex::new(stream)), + 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), }) @@ -182,7 +249,7 @@ impl AsyncDeviceStream { input: &T, ) -> Result<(), ViiperError> { let bytes = input.to_bytes(); - let mut stream = self.stream.lock().await; + let mut stream = self.write_stream.lock().await; stream.write_all(&bytes).await?; Ok(()) } @@ -198,7 +265,7 @@ impl AsyncDeviceStream { timeout: std::time::Duration, ) -> Result<(), ViiperError> { let bytes = input.to_bytes(); - let mut stream = self.stream.lock().await; + let mut stream = self.write_stream.lock().await; tokio::time::timeout(timeout, stream.write_all(&bytes)) .await .map_err(|_| ViiperError::Timeout)? @@ -206,19 +273,19 @@ impl AsyncDeviceStream { } /// Register a callback to receive device output asynchronously. - /// The callback receives a shared reference to the stream and must read the exact number of bytes expected. + /// 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, + 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.stream.clone(); + 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 { @@ -260,20 +327,20 @@ impl AsyncDeviceStream { /// Send raw bytes to the device. pub async fn send_raw(&self, data: &[u8]) -> Result<(), ViiperError> { - let mut stream = self.stream.lock().await; + 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.stream.lock().await; + 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.stream.lock().await; + let mut stream = self.read_stream.lock().await; stream.read_exact(buf).await?; Ok(()) } diff --git a/internal/codegen/generator/rust/auth.go b/internal/codegen/generator/rust/auth.go index 24c543a8..1d4af7fc 100644 --- a/internal/codegen/generator/rust/auth.go +++ b/internal/codegen/generator/rust/auth.go @@ -95,7 +95,7 @@ pub fn perform_handshake(mut stream: TcpStream, password: &str) -> Result>, + read: std::sync::Arc>, + write: std::sync::Arc>, } -struct EncryptedStreamInner { +struct EncryptedReadState { stream: TcpStream, cipher: ChaCha20Poly1305, - send_counter: u64, recv_buffer: Vec, } +struct EncryptedWriteState { + stream: TcpStream, + cipher: ChaCha20Poly1305, + send_counter: u64, +} + impl EncryptedStream { - fn new(inner: TcpStream, session_key: [u8; 32]) -> Self { - let cipher = ChaCha20Poly1305::new(&session_key.into()); - Self { - inner: std::sync::Arc::new(std::sync::Mutex::new(EncryptedStreamInner { + 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, + cipher: write_cipher, send_counter: 0, - recv_buffer: Vec::new(), })), - } + }) } pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { - let inner = self.inner.lock().unwrap(); - inner.stream.set_nodelay(nodelay) + 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 { - inner: std::sync::Arc::clone(&self.inner), + 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 inner = self.inner.lock().unwrap(); - inner.stream.shutdown(how) + 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.inner.lock().unwrap(); + 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 { - // Normal EOF return Ok(0); } @@ -225,7 +243,7 @@ impl Read for EncryptedStream { impl Write for EncryptedStream { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut inner = self.inner.lock().unwrap(); + 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()); @@ -245,7 +263,7 @@ impl Write for EncryptedStream { } fn flush(&mut self) -> std::io::Result<()> { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.write.lock().unwrap(); inner.stream.flush() } } @@ -253,13 +271,25 @@ impl Write for EncryptedStream { /// Encrypted stream wrapper using ChaCha20-Poly1305 (asynchronous) #[cfg(feature = "async")] pub struct AsyncEncryptedStream { - inner: AsyncTcpStream, + read: AsyncEncryptedRead, + write: AsyncEncryptedWrite, +} + +#[cfg(feature = "async")] +pub struct AsyncEncryptedRead { + inner: tokio::net::tcp::OwnedReadHalf, cipher: ChaCha20Poly1305, - send_counter: u64, 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 }, @@ -270,27 +300,31 @@ enum ReadState { #[cfg(feature = "async")] impl AsyncEncryptedStream { fn new(inner: AsyncTcpStream, session_key: [u8; 32]) -> Self { - let cipher = ChaCha20Poly1305::new(&session_key.into()); + 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 { - inner, - cipher, - send_counter: 0, - recv_buffer: Vec::new(), - read_state: ReadState::ReadingLength { buf: [0; 4], pos: 0 }, + 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 set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { - self.inner.set_nodelay(nodelay) - } - - pub fn into_split(self) -> (tokio::net::tcp::OwnedReadHalf, tokio::net::tcp::OwnedWriteHalf) { - self.inner.into_split() + pub fn into_split(self) -> (AsyncEncryptedRead, AsyncEncryptedWrite) { + (self.read, self.write) } } #[cfg(feature = "async")] -impl AsyncRead for AsyncEncryptedStream { +impl AsyncRead for AsyncEncryptedRead { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -410,7 +444,7 @@ impl AsyncRead for AsyncEncryptedStream { } #[cfg(feature = "async")] -impl AsyncWrite for AsyncEncryptedStream { +impl AsyncWrite for AsyncEncryptedWrite { fn poll_write( mut self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -448,6 +482,42 @@ impl AsyncWrite for AsyncEncryptedStream { 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 { From d5f3c7835fc6ce5bbe55050031cb0df3aa1853b9 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 04:20:47 +0100 Subject: [PATCH 136/298] linux: fix keyfile paths --- docs/api/overview.md | 3 ++- docs/cli/configuration.md | 5 +++-- docs/cli/server.md | 3 ++- docs/getting-started/quickstart.md | 3 ++- internal/cmd/server.go | 6 +++--- internal/configpaths/keyfile_unix.go | 17 +++++++++++++++++ internal/configpaths/keyfile_windows.go | 8 ++++++++ 7 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 internal/configpaths/keyfile_unix.go create mode 100644 internal/configpaths/keyfile_windows.go diff --git a/docs/api/overview.md b/docs/api/overview.md index 0fdf806d..c83cb8c8 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -103,7 +103,8 @@ The exception to this are the device-control and feedback streams, which are raw On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. Windows: `%APPDATA%\VIIPER\viiper.key.txt` - Linux: `~/.config/viiper/viiper.key.txt` + Linux (user): `~/.config/github.com/Alia5/viiper/viiper.key.txt` + Linux (root/systemd): `/etc/viiper/viiper.key.txt` Remote clients must provide this password to establish a connection. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 555ca2a8..0dd0791a 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -74,8 +74,9 @@ The password file is _intentionally_ separated from the main configuration **Password File:** `viiper.key.txt` - **Location:** - - **Windows:** `%APPDATA%\viiper\` - - **Linux/macOS:** `~/.config/viiper/` + - **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 diff --git a/docs/cli/server.md b/docs/cli/server.md index 38387767..bf8f4a99 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -24,7 +24,8 @@ The server exposes two interfaces: On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. Windows: `%APPDATA%\VIIPER\viiper.key.txt` - Linux: `~/.config/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 diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 45c58669..883cea95 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -24,7 +24,8 @@ This starts two services: On first start, VIIPER generates a random password and saves it to `/viiper.key.txt`. Windows: `%APPDATA%\VIIPER\viiper.key.txt` - Linux: `~/.config/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 diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 8ccfdd5a..be41127b 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -6,7 +6,7 @@ import ( "log/slog" "os" "os/signal" - "path" + "path/filepath" "strings" "syscall" "time" @@ -42,11 +42,11 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Info("Starting VIIPER USB-IP server", "addr", s.UsbServerConfig.Addr) - keyFileDir, err := configpaths.DefaultConfigDir() + keyFileDir, err := configpaths.KeyFileDir() if err != nil { return fmt.Errorf("failed to resolve key file path: %w", err) } - keyFilePath := path.Join(keyFileDir, keyFileName) + keyFilePath := filepath.Join(keyFileDir, keyFileName) if pwd, err := os.ReadFile(keyFilePath); err == nil { s.ApiServerConfig.Password = strings.TrimSpace(string(pwd)) } else { 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() +} From eb09dcdd33fa21ff440bd5b2b3f9e9020762fc38 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 31 Jan 2026 04:25:57 +0100 Subject: [PATCH 137/298] fix codecov yml --- codecov.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/codecov.yml b/codecov.yml index cb006eb2..4ce10cb6 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,5 +12,3 @@ coverage: default: informational: true -assume: - utf_8: true From 787aedbd8f13b640307db6247aa9c2ba9a47e079 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 2 Feb 2026 10:01:58 +0100 Subject: [PATCH 138/298] Update Readme --- README.md | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 32b4f9a5..82f3fc72 100644 --- a/README.md +++ b/README.md @@ -40,23 +40,13 @@ For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. -### ✨🛣️ Features / Roadmap - -- ✅ Virtual input device emulation over IP using USBIP - - ✅ Xbox 360 controller emulation; 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) - - ✅ PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) - - 🔜 Xbox One / Series(?) controller emulation - - 🔜 ??? - 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) -- ✅ **Automatic local attachment**: automatically controls usbip client on localhost to attach devices (enabled by default) -- ✅ Proxy mode: forward real USB devices and inspect/record traffic (for reversing) -- ✅ Cross-platform: works on Linux and Windows, **0** dependencies portable binary -- ✅ Flexible logging (including raw USB packet logs) -- ✅ Multiple client libraries for easy integration; see [Client Libraries](docs/api/overview.md) - MIT Licensed -- 🔜 _libVIIPER_ to link against, directly incoporating VIIPER into your feeder application. +**Emulatable devices:** + + - Xbox 360 controller emulation; 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) + - PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) + - 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) ## 🔌 Requirements @@ -180,10 +170,10 @@ Yes! VIIPER's architecture is designed to be extensible. Check the [xbox360 device implementation](./device/xbox360/) as a reference for creating new device types. In the future there will be a plugin system to load and expose device types dynamically. -### What about the proxy mode? +### You mentioned proxying USBIP? -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. +VIIPER as a proxy mode that sits between a USBIP client and a USBIP server (like a Linux machine sharing real USB devices). +THis intercepts and logs all URBs 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? From e71df769990e0e40f69186c86d04f8d4540511a9 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Sun, 8 Feb 2026 02:12:31 +0100 Subject: [PATCH 139/298] Breaking: Support XInput subtypes thanks to @sanjay900 https://github.com/Alia5/VIIPER/pull/10 Co-authored-by: Sanjay Govind Changelog(feat) --- apiclient/client.go | 7 +- apiclient/stream_test.go | 2 +- apitypes/structs.go | 96 +++++-- device/dualshock4/device.go | 8 +- device/dualshock4/dualshock4_test.go | 5 +- device/dualshock4/handler.go | 2 +- device/keyboard/device.go | 8 +- device/keyboard/handler.go | 2 +- device/mouse/device.go | 8 +- device/mouse/handler.go | 2 +- device/options.go | 5 +- device/xbox360/device.go | 235 ++++++++++-------- device/xbox360/handler.go | 4 +- device/xbox360/inputstate.go | 36 ++- docs/api/overview.md | 12 +- docs/clients/rust.md | 2 + docs/devices/xbox360.md | 84 ++++--- .../rust/async/virtual_keyboard/src/main.rs | 1 + examples/rust/async/virtual_mouse/src/main.rs | 1 + .../rust/async/virtual_x360_pad/src/main.rs | 2 + .../rust/sync/virtual_keyboard/src/main.rs | 1 + examples/rust/sync/virtual_mouse/src/main.rs | 1 + .../rust/sync/virtual_x360_pad/src/main.rs | 4 +- go.mod | 1 + go.sum | 2 + internal/_testing/mocks.go | 6 +- internal/codegen/common/wiresize.go | 13 +- .../codegen/generator/cpp/device_header.go | 100 ++++++-- internal/codegen/generator/cpp/helpers.go | 39 ++- internal/codegen/generator/cpp/types.go | 16 +- .../codegen/generator/csharp/device_types.go | 105 ++++---- internal/codegen/generator/csharp/helpers.go | 21 ++ internal/codegen/generator/csharp/types.go | 16 +- .../codegen/generator/rust/device_types.go | 56 +++-- internal/codegen/generator/rust/helpers.go | 19 ++ internal/codegen/generator/rust/types.go | 10 +- .../generator/typescript/device_types.go | 87 ++++--- .../codegen/generator/typescript/helpers.go | 22 +- .../codegen/generator/typescript/types.go | 9 + internal/server/api/device_registry.go | 2 +- internal/server/api/device_registry_test.go | 10 +- .../server/api/device_stream_handler_test.go | 10 +- internal/server/api/handler/bus_device_add.go | 21 +- .../server/api/handler/bus_device_add_test.go | 42 +++- .../api/handler/bus_device_remove_test.go | 6 +- .../server/api/handler/bus_devices_list.go | 11 +- .../api/handler/bus_devices_list_test.go | 22 +- .../server/api/handler/bus_remove_test.go | 12 +- internal/server/api/server_test.go | 5 +- usb/device.go | 1 + 50 files changed, 830 insertions(+), 362 deletions(-) diff --git a/apiclient/client.go b/apiclient/client.go index 03a6346a..8b9ef4c5 100644 --- a/apiclient/client.go +++ b/apiclient/client.go @@ -108,9 +108,10 @@ func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, o = &device.CreateOptions{} } req := apitypes.DeviceCreateRequest{ - Type: &devType, - IdVendor: o.IdVendor, - IdProduct: o.IdProduct, + Type: &devType, + IdVendor: o.IdVendor, + IdProduct: o.IdProduct, + DeviceSpecific: o.DeviceSpecific, } payloadBytes, err := json.Marshal(req) if err != nil { diff --git a/apiclient/stream_test.go b/apiclient/stream_test.go index 7105911a..de350955 100644 --- a/apiclient/stream_test.go +++ b/apiclient/stream_test.go @@ -155,7 +155,7 @@ func TestDeviceStream_Operations(t *testing.T) { r := apiSrv.Router() if tt.customRegistration { testReg := htesting.CreateMockRegistration(t, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + 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 diff --git a/apitypes/structs.go b/apitypes/structs.go index 738ffa30..b8dffda5 100644 --- a/apitypes/structs.go +++ b/apitypes/structs.go @@ -3,8 +3,11 @@ package apitypes import ( "encoding/json" "fmt" + "math" "strconv" "strings" + + "golang.org/x/exp/constraints" ) // ApiError represents an RFC 7807 (problem+json) error response. @@ -47,11 +50,12 @@ type BusRemoveResponse struct { } type Device struct { - BusID uint32 `json:"busId"` - DevId string `json:"devId"` - Vid string `json:"vid"` - Pid string `json:"pid"` - Type string `json:"type"` + 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 { @@ -64,9 +68,10 @@ type DeviceRemoveResponse struct { } type DeviceCreateRequest struct { - Type *string `json:"type"` - IdVendor *uint16 `json:"idVendor,omitempty"` - IdProduct *uint16 `json:"idProduct,omitempty"` + Type *string `json:"type"` + IdVendor *uint16 `json:"idVendor,omitempty"` + IdProduct *uint16 `json:"idProduct,omitempty"` + DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"` } // UnmarshalJSON implements custom unmarshaling to accept both uint16 and hex string formats @@ -74,9 +79,10 @@ type DeviceCreateRequest struct { 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"` + 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 { @@ -86,7 +92,7 @@ func (d *DeviceCreateRequest) UnmarshalJSON(data []byte) error { d.Type = raw.Type if raw.IdVendor != nil { - val, err := parseUint16OrHex(raw.IdVendor) + val, err := parseNumberOrHex[uint16](raw.IdVendor) if err != nil { return fmt.Errorf("idVendor: %w", err) } @@ -94,24 +100,48 @@ func (d *DeviceCreateRequest) UnmarshalJSON(data []byte) error { } if raw.IdProduct != nil { - val, err := parseUint16OrHex(raw.IdProduct) + 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 parseUint16OrHex(v any) (uint16, error) { +func parseNumberOrHex[N constraints.Integer](v any) (N, error) { + var zero N switch val := v.(type) { case float64: - if val < 0 || val > 65535 { - return 0, fmt.Errorf("value %v out of uint16 range", val) + 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 uint16(val), nil + return N(val), nil case string: s := strings.TrimSpace(val) base := 10 @@ -123,12 +153,34 @@ func parseUint16OrHex(v any) (uint16, error) { base = 16 } } - parsed, err := strconv.ParseUint(s, base, 16) - if err != nil { - return 0, fmt.Errorf("invalid hex/numeric string %q: %w", val, err) + 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 } - return uint16(parsed), nil default: - return 0, fmt.Errorf("expected number or hex string, got %T", v) + return zero, fmt.Errorf("expected number or hex string, got %T", v) } } diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index c18c8ce0..9135b863 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -22,7 +22,7 @@ type DualShock4 struct { usbPacketCounter uint32 } -func New(o *device.CreateOptions) *DualShock4 { +func New(o *device.CreateOptions) (*DualShock4, error) { d := &DualShock4{ descriptor: defaultDescriptor, } @@ -58,7 +58,7 @@ func New(o *device.CreateOptions) *DualShock4 { AccelZ: DefaultAccelZRaw, } - return d + return d, nil } func (d *DualShock4) SetOutputCallback(f func(OutputState)) { @@ -174,6 +174,10 @@ func (d *DualShock4) GetDescriptor() *usb.Descriptor { return &d.descriptor } +func (x *DualShock4) GetDeviceSpecificArgs() map[string]any { + return map[string]any{} +} + func (d *DualShock4) buildUSBInputReport(s InputState) []byte { b := make([]byte, InputReportSize) diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index e8ce25d0..366c0223 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -343,7 +343,10 @@ func TestInputReports(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - dev := dualshock4.New(nil) + dev, err := dualshock4.New(nil) + if !assert.NoError(t, err) { + return + } dev.UpdateInputState(&tc.inputState) built := dev.HandleTransfer(4, usbip.DirIn, nil) bb := append([]byte(nil), built...) diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index c5aa14ed..7e9ee321 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -17,7 +17,7 @@ func init() { type handler struct{} -func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } +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 { diff --git a/device/keyboard/device.go b/device/keyboard/device.go index a290abd7..5ba40822 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -22,7 +22,7 @@ type Keyboard struct { } // New returns a new Keyboard device. -func New(o *device.CreateOptions) *Keyboard { +func New(o *device.CreateOptions) (*Keyboard, error) { d := &Keyboard{ descriptor: defaultDescriptor, } @@ -34,7 +34,7 @@ func New(o *device.CreateOptions) *Keyboard { d.descriptor.Device.IDProduct = *o.IdProduct } } - return d + return d, nil } // SetLEDCallback sets a callback that will be invoked when LED state changes. @@ -216,3 +216,7 @@ var defaultDescriptor = usb.Descriptor{ func (k *Keyboard) GetDescriptor() *usb.Descriptor { return &k.descriptor } + +func (x *Keyboard) GetDeviceSpecificArgs() map[string]any { + return map[string]any{} +} diff --git a/device/keyboard/handler.go b/device/keyboard/handler.go index 2c1d3b72..de38232f 100644 --- a/device/keyboard/handler.go +++ b/device/keyboard/handler.go @@ -17,7 +17,7 @@ func init() { type handler struct{} -func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } +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 { diff --git a/device/mouse/device.go b/device/mouse/device.go index 9e16d691..8b375dcd 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -21,7 +21,7 @@ type Mouse struct { } // New returns a new Mouse device. -func New(o *device.CreateOptions) *Mouse { +func New(o *device.CreateOptions) (*Mouse, error) { d := &Mouse{ descriptor: defaultDescriptor, } @@ -33,7 +33,7 @@ func New(o *device.CreateOptions) *Mouse { d.descriptor.Device.IDProduct = *o.IdProduct } } - return d + return d, nil } // UpdateInputState updates the device's current input state (thread-safe). @@ -179,3 +179,7 @@ var defaultDescriptor = usb.Descriptor{ func (m *Mouse) GetDescriptor() *usb.Descriptor { return &m.descriptor } + +func (x *Mouse) GetDeviceSpecificArgs() map[string]any { + return map[string]any{} +} diff --git a/device/mouse/handler.go b/device/mouse/handler.go index 24ff865c..46dbc385 100644 --- a/device/mouse/handler.go +++ b/device/mouse/handler.go @@ -17,7 +17,7 @@ func init() { type handler struct{} -func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } func (r *handler) StreamHandler() api.StreamHandlerFunc { return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { diff --git a/device/options.go b/device/options.go index d34d0f57..4dd372a0 100644 --- a/device/options.go +++ b/device/options.go @@ -1,6 +1,7 @@ package device type CreateOptions struct { - IdVendor *uint16 - IdProduct *uint16 + IdVendor *uint16 + IdProduct *uint16 + DeviceSpecific map[string]any } diff --git a/device/xbox360/device.go b/device/xbox360/device.go index befd2387..7a0fbf5d 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -2,6 +2,8 @@ package xbox360 import ( + "encoding/json" + "fmt" "sync" "sync/atomic" @@ -18,10 +20,14 @@ type Xbox360 struct { descriptor usb.Descriptor } +type Xbox360CreateOptions struct { + SubType *uint8 `json:"subType"` +} + // New returns a new Xbox360 device. -func New(o *device.CreateOptions) *Xbox360 { +func New(o *device.CreateOptions) (*Xbox360, error) { d := &Xbox360{ - descriptor: defaultDescriptor, + descriptor: MakeDescriptor(), } if o != nil { if o.IdVendor != nil { @@ -30,8 +36,22 @@ func New(o *device.CreateOptions) *Xbox360 { if o.IdProduct != nil { d.descriptor.Device.IDProduct = *o.IdProduct } + if o.DeviceSpecific != nil { + data, err := json.Marshal(o.DeviceSpecific) + var args Xbox360CreateOptions + if err != nil { + return nil, fmt.Errorf("invalid JSON payload: %w", err) + } + err = json.Unmarshal(data, &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 + } + } } - return d + return d, nil } // SetRumbleCallback sets a callback that will be invoked when rumble commands arrive. @@ -83,120 +103,125 @@ func (x *Xbox360) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { return nil } -// Static descriptor/config for Xbox360, for registration with the bus. -var defaultDescriptor = 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}, - }, +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 }, - // 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}, + 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}, }, }, - 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, + // 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}, + }, }, - ClassDescriptors: []usb.ClassSpecificDescriptor{ - { - DescriptorType: 0x21, - Payload: usb.Data{0x00, 0x01, 0x01, 0x22, 0x84, 0x07, 0x00}, + // 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, + }, }, }, - 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}}, }, }, }, - // 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: "\x04\x09", // LangID: en-US (0x0409) + 1: "©Microsoft Corporation", + 2: "VIIPER Controller", //"Controller", + 3: "296013F", }, - }, - Strings: map[uint8]string{ - 0: "\x04\x09", // 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]} +} diff --git a/device/xbox360/handler.go b/device/xbox360/handler.go index e72229f9..59d045b3 100644 --- a/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -17,7 +17,7 @@ func init() { type handler struct{} -func (h *handler) CreateDevice(o *device.CreateOptions) usb.Device { return New(o) } +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } func (r *handler) StreamHandler() api.StreamHandlerFunc { return func(conn net.Conn, devPtr *usb.Device, logger *slog.Logger) error { @@ -40,7 +40,7 @@ func (r *handler) StreamHandler() api.StreamHandlerFunc { } }) - buf := make([]byte, 14) + buf := make([]byte, 20) for { if _, err := io.ReadFull(conn, buf); err != nil { if err == io.EOF { diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 85e0aad9..36062baf 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -7,15 +7,33 @@ import ( // 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 +// 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 + LX, LY int16 + RX, RY int16 + Reserved [6]byte +} + +// 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. @@ -43,13 +61,13 @@ func (x *InputState) BuildReport() []byte { 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)) - // Remaining bytes (14-19) are left zeroed + copy(b[14:19], x.Reserved[:]) return b } -// MarshalBinary encodes InputState to 14 bytes. +// MarshalBinary encodes InputState to 20 bytes. func (x *InputState) MarshalBinary() ([]byte, error) { - b := make([]byte, 14) + b := make([]byte, 20) binary.LittleEndian.PutUint32(b[0:4], x.Buttons) b[4] = x.LT b[5] = x.RT @@ -57,12 +75,13 @@ func (x *InputState) MarshalBinary() ([]byte, error) { 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 14 bytes into InputState. +// UnmarshalBinary decodes 20 bytes into InputState. func (x *InputState) UnmarshalBinary(data []byte) error { - if len(data) < 14 { + if len(data) < 20 { return io.ErrUnexpectedEOF } x.Buttons = binary.LittleEndian.Uint32(data[0:4]) @@ -72,6 +91,7 @@ func (x *InputState) UnmarshalBinary(data []byte) error { 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 } diff --git a/docs/api/overview.md b/docs/api/overview.md index c83cb8c8..586960ce 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -204,6 +204,9 @@ The exception to this are the device-control and feedback streams, which are raw "vid": "0x045e", "pid": "0x028e", "type": "xbox360" + "deviceSpecific": { + "subType": 1 + } } ] } @@ -219,13 +222,15 @@ The exception to this are the device-control and feedback streams, which are raw { "type": "", "idVendor": , - "idProduct": + "idProduct": , + "deviceSpecific": } ``` **Examples:** - `{"type":"xbox360"}` - `{"type":"keyboard","idVendor":1234,"idProduct":5678}` + - `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` **Response:** ```json @@ -234,7 +239,10 @@ The exception to this are the device-control and feedback streams, which are raw "devId": "1", "vid": "0x045e", "pid": "0x028e", - "type": "xbox360" + "type": "xbox360", + "deviceSpecific": { + "subType":7 + } } ``` diff --git a/docs/clients/rust.md b/docs/clients/rust.md index 72801e4c..fc1e2aab 100644 --- a/docs/clients/rust.md +++ b/docs/clients/rust.md @@ -77,6 +77,7 @@ viiper-client = { path = "../../clients/rust" } r#type: Some("keyboard".to_string()), id_vendor: None, id_product: None, + device_specific: None, }, ).expect("Failed to add device"); @@ -133,6 +134,7 @@ viiper-client = { path = "../../clients/rust" } r#type: Some("keyboard".to_string()), id_vendor: None, id_product: None, + device_specific: None, }, ).await.expect("Failed to add device"); diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 667b6ec6..653b2629 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -10,10 +10,38 @@ Use `xbox360` as the device type when adding a device via the API or client libr 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. +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. +and send/receive them via the device control and feedback stream. + +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. + +For example: + +- `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` + +### Subtypes + +| 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 | See: [API Reference](../api/overview.md) @@ -23,37 +51,37 @@ The device stream is a bidirectional, raw TCP connection with fixed-size packets ### Input State -- 14-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 +- 14-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 ### Rumble Feedback -- 2-byte packets: - - LeftMotor: uint8, RightMotor: uint8 - 0-255 intensity values +- 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 | +| 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 | diff --git a/examples/rust/async/virtual_keyboard/src/main.rs b/examples/rust/async/virtual_keyboard/src/main.rs index 74e7f557..a930d36a 100644 --- a/examples/rust/async/virtual_keyboard/src/main.rs +++ b/examples/rust/async/virtual_keyboard/src/main.rs @@ -58,6 +58,7 @@ async fn main() { r#type: Some("keyboard".to_string()), id_vendor: None, id_product: None, + device_specific: None, }).await { Ok(d) => d, Err(e) => { diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs index 48674916..3a6217fc 100644 --- a/examples/rust/async/virtual_mouse/src/main.rs +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -58,6 +58,7 @@ async fn main() { r#type: Some("mouse".to_string()), id_vendor: None, id_product: None, + device_specific: None, }).await { Ok(d) => d, Err(e) => { diff --git a/examples/rust/async/virtual_x360_pad/src/main.rs b/examples/rust/async/virtual_x360_pad/src/main.rs index e9e8d25e..35686402 100644 --- a/examples/rust/async/virtual_x360_pad/src/main.rs +++ b/examples/rust/async/virtual_x360_pad/src/main.rs @@ -58,6 +58,7 @@ async fn main() { r#type: Some("xbox360".to_string()), id_vendor: None, id_product: None, + device_specific: None, }).await { Ok(d) => d, Err(e) => { @@ -124,6 +125,7 @@ async fn main() { ly: (20000.0 * 0.7071) as i16, rx: 0, ry: 0, + reserved: [0; 6], }; if let Err(e) = stream.send(&state).await { diff --git a/examples/rust/sync/virtual_keyboard/src/main.rs b/examples/rust/sync/virtual_keyboard/src/main.rs index 3e7ead7c..2ea5e753 100644 --- a/examples/rust/sync/virtual_keyboard/src/main.rs +++ b/examples/rust/sync/virtual_keyboard/src/main.rs @@ -58,6 +58,7 @@ fn main() { r#type: Some("keyboard".to_string()), id_vendor: None, id_product: None, + device_specific: None, }, ) { Ok(d) => d, diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs index 3dbb59f7..c5d441f3 100644 --- a/examples/rust/sync/virtual_mouse/src/main.rs +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -58,6 +58,7 @@ fn main() { r#type: Some("mouse".to_string()), id_vendor: None, id_product: None, + device_specific: None, }, ) { Ok(d) => d, diff --git a/examples/rust/sync/virtual_x360_pad/src/main.rs b/examples/rust/sync/virtual_x360_pad/src/main.rs index 28594081..e70688af 100644 --- a/examples/rust/sync/virtual_x360_pad/src/main.rs +++ b/examples/rust/sync/virtual_x360_pad/src/main.rs @@ -1,6 +1,6 @@ +use std::net::ToSocketAddrs; use std::thread; use std::time::Duration; -use std::net::ToSocketAddrs; use viiper_client::{devices::xbox360::*, ViiperClient}; fn main() { @@ -58,6 +58,7 @@ fn main() { r#type: Some("xbox360".to_string()), id_vendor: None, id_product: None, + device_specific: None, }, ) { Ok(d) => d, @@ -127,6 +128,7 @@ fn main() { ly: (20000.0 * 0.7071) as i16, rx: 0, ry: 0, + reserved: [0; 6], }; if let Err(e) = stream.send(&state) { diff --git a/go.mod b/go.mod index 47b12920..11fc9b90 100644 --- a/go.mod +++ b/go.mod @@ -21,4 +21,5 @@ require ( github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect ) diff --git a/go.sum b/go.sum index ca5b53ed..ef5b3ec3 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= diff --git a/internal/_testing/mocks.go b/internal/_testing/mocks.go index 44608f09..db89a762 100644 --- a/internal/_testing/mocks.go +++ b/internal/_testing/mocks.go @@ -12,10 +12,10 @@ type mockRegistration struct { deviceName string handlerFunc api.StreamHandlerFunc - createFunc func(o *device.CreateOptions) usb.Device + createFunc func(o *device.CreateOptions) (usb.Device, error) } -func (m *mockRegistration) CreateDevice(o *device.CreateOptions) usb.Device { +func (m *mockRegistration) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return m.createFunc(o) } @@ -26,7 +26,7 @@ func (m *mockRegistration) StreamHandler() api.StreamHandlerFunc { func CreateMockRegistration( t *testing.T, name string, - cf func(o *device.CreateOptions) usb.Device, + cf func(o *device.CreateOptions) (usb.Device, error), h api.StreamHandlerFunc, ) api.DeviceRegistration { return &mockRegistration{ diff --git a/internal/codegen/common/wiresize.go b/internal/codegen/common/wiresize.go index b8574c81..b18aa2c2 100644 --- a/internal/codegen/common/wiresize.go +++ b/internal/codegen/common/wiresize.go @@ -2,6 +2,7 @@ package common import ( "sort" + "strconv" "strings" "github.com/Alia5/VIIPER/internal/codegen/meta" @@ -34,11 +35,17 @@ func CalculateOutputSize(tag *scanner.WireTag) int { total := 0 for _, field := range tag.Fields { - baseType := field.Type - if strings.Contains(baseType, "*") { + 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(baseType) + total += WireTypeSize(wireType) } return total diff --git a/internal/codegen/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go index 5a04ad08..95863135 100644 --- a/internal/codegen/generator/cpp/device_header.go +++ b/internal/codegen/generator/cpp/device_header.go @@ -5,6 +5,8 @@ import ( "log/slog" "os" "path/filepath" + "strconv" + "strings" "text/template" "github.com/Alia5/VIIPER/internal/codegen/common" @@ -18,9 +20,11 @@ const deviceHeaderTemplate = `{{.Header}} #include "../error.hpp" #include #include +{{- if or .HasMaps .HasFixedWireArrays}} +#include +{{- end}} {{- if .HasMaps}} #include -#include #include #include #include @@ -90,7 +94,11 @@ constexpr std::uint64_t {{$mapName}}_{{$entry.Key}} = {{formatValue $entry.Value struct Input { {{- range $fields}} {{- if isArrayType .Type}} - std::vector<{{cpptype (baseType .Type)}}> {{camelcase .Name}}; +{{- 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}} @@ -100,10 +108,42 @@ struct Input { std::vector buf; {{- range $fields}} {{- if isArrayType .Type}} - buf.push_back(static_cast({{camelcase .Name}}.size())); - for (const auto& v : {{camelcase .Name}}) { - buf.push_back(static_cast(v)); - } + {{- $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"}} @@ -218,24 +258,40 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md } } + 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 + } + } + } + } + } + data := struct { - Header string - DeviceName string - Constants []scanner.ConstantInfo - Maps []scanner.MapInfo - HasInput bool - HasOutput bool - HasMaps bool - OutputSize int + Header string + DeviceName string + Constants []scanner.ConstantInfo + Maps []scanner.MapInfo + HasInput bool + HasOutput bool + HasMaps bool + HasFixedWireArrays bool + OutputSize int }{ - Header: writeFileHeader(), - DeviceName: deviceName, - Constants: devicePkg.Constants, - Maps: devicePkg.Maps, - HasInput: hasInput, - HasOutput: hasOutput, - HasMaps: hasMaps, - OutputSize: outputSize, + Header: writeFileHeader(), + DeviceName: deviceName, + Constants: devicePkg.Constants, + Maps: devicePkg.Maps, + HasInput: hasInput, + HasOutput: hasOutput, + HasMaps: hasMaps, + HasFixedWireArrays: hasFixedWireArrays, + OutputSize: outputSize, } if err := tmpl.Execute(f, data); err != nil { diff --git a/internal/codegen/generator/cpp/helpers.go b/internal/codegen/generator/cpp/helpers.go index b6d99e05..99565cb2 100644 --- a/internal/codegen/generator/cpp/helpers.go +++ b/internal/codegen/generator/cpp/helpers.go @@ -2,6 +2,7 @@ package cpp import ( "fmt" + "strconv" "strings" "text/template" @@ -19,6 +20,7 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { "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<"), ">") @@ -27,7 +29,26 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { "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, "*") }, - "baseType": func(t string) string { return strings.Split(t, "*")[0] }, + "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 { @@ -60,8 +81,21 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { } } +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 strings.HasPrefix(base, "map[") { + return "json_type" + } cppBase := goBaseToCpp(base) if isSlice { @@ -163,6 +197,9 @@ func hasCharLiteralKeys(entries map[string]interface{}) bool { 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", diff --git a/internal/codegen/generator/cpp/types.go b/internal/codegen/generator/cpp/types.go index ece0d270..650027cb 100644 --- a/internal/codegen/generator/cpp/types.go +++ b/internal/codegen/generator/cpp/types.go @@ -35,16 +35,26 @@ struct {{pascalcase .Name}}; // {{.Name}} struct {{pascalcase .Name}} { {{- range .Fields}} - {{cpptype .Type}} {{camelcase .Name}}; + {{fieldcpptype .}} {{camelcase .Name}}; {{- end}} static {{pascalcase .Name}} from_json(const json_type& j) { {{pascalcase .Name}} result; {{- range .Fields}} -{{- if .Optional}} - result.{{camelcase .Name}} = detail::get_optional_field<{{cpptype .Type | unwrapOptional}}>(j, "{{.JSONName}}"); +{{- 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 .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}}"]); diff --git a/internal/codegen/generator/csharp/device_types.go b/internal/codegen/generator/csharp/device_types.go index aa1ca485..c72c15fe 100644 --- a/internal/codegen/generator/csharp/device_types.go +++ b/internal/codegen/generator/csharp/device_types.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" "text/template" @@ -61,31 +62,25 @@ func generateWireClass(outputPath, device, className string, tag *scanner.WireTa Device string ClassName string Fields []wireField - HasArray bool - ArrayInfo *arrayFieldInfo }{ Device: device, ClassName: className, } for _, field := range tag.Fields { - wf := wireField{ - Name: toPascalCase(field.Name), - GoType: field.Type, - CSType: mapGoTypeToCSharp(field.Type), - } - - if strings.Contains(field.Spec, "*") { - parts := strings.Split(field.Spec, "*") - if len(parts) == 2 { - data.HasArray = true - data.ArrayInfo = &arrayFieldInfo{ - FieldName: wf.Name, - CountFieldName: toPascalCase(parts[1]), - ElementType: wf.CSType, - } - wf.IsArray = true + 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) @@ -105,16 +100,11 @@ func generateWireClass(outputPath, device, className string, tag *scanner.WireTa } type wireField struct { - Name string - GoType string - CSType string - IsArray bool -} - -type arrayFieldInfo struct { - FieldName string + Name string + CSType string + IsArray bool CountFieldName string - ElementType string + FixedLen int } func mapGoTypeToCSharp(goType string) string { @@ -173,19 +163,20 @@ namespace Viiper.Client.Devices.{{.Device}}; /// public class {{.Device}}{{.ClassName}} : IBinarySerializable { -{{range .Fields}} public required {{.CSType}}{{if .IsArray}}[]{{end}} {{.Name}} { get; set; } -{{end}} +{{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) { -{{if .HasArray}} // Write fixed fields -{{range .Fields}}{{if not .IsArray}} writer.Write({{.Name}}); -{{end}}{{end}} - // Write variable-length array - for (int i = 0; i < {{.ArrayInfo.CountFieldName}}; i++) - { - writer.Write({{.ArrayInfo.FieldName}}[i]); - } -{{else}}{{range .Fields}} writer.Write({{.Name}}); +{{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}} } /// @@ -193,25 +184,23 @@ public class {{.Device}}{{.ClassName}} : IBinarySerializable /// public static {{.Device}}{{.ClassName}} Read(BinaryReader reader) { -{{if .HasArray}} // Read fixed fields -{{range .Fields}}{{if not .IsArray}} var {{toCamel .Name}} = reader.Read{{readerMethod .CSType}}(); -{{end}}{{end}} - // Read variable-length array - var {{toCamel .ArrayInfo.FieldName}} = new {{.ArrayInfo.ElementType}}[{{toCamel .ArrayInfo.CountFieldName}}]; - for (int i = 0; i < {{toCamel .ArrayInfo.CountFieldName}}; i++) - { - {{toCamel .ArrayInfo.FieldName}}[i] = reader.Read{{readerMethod .ArrayInfo.ElementType}}(); - } - - return new {{.Device}}{{.ClassName}} - { -{{range .Fields}}{{if not .IsArray}} {{.Name}} = {{toCamel .Name}}, -{{end}}{{end}} {{.ArrayInfo.FieldName}} = {{toCamel .ArrayInfo.FieldName}} - }; -{{else}} return new {{.Device}}{{.ClassName}} - { -{{range .Fields}} {{.Name}} = reader.Read{{readerMethod .CSType}}(), -{{end}} }; -{{end}} } + {{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/helpers.go b/internal/codegen/generator/csharp/helpers.go index 3350cf64..653a5b5f 100644 --- a/internal/codegen/generator/csharp/helpers.go +++ b/internal/codegen/generator/csharp/helpers.go @@ -1,6 +1,8 @@ package csharp import ( + "strings" + "github.com/Alia5/VIIPER/internal/codegen/common" ) @@ -14,6 +16,9 @@ func toCamelCase(s string) string { func goTypeToCSharp(goType string) string { base, _, _ := common.NormalizeGoType(goType) + if base == "any" || base == "interface{}" { + return "object" + } switch base { case "uint8": @@ -47,4 +52,20 @@ func goTypeToCSharp(goType string) string { } } +func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", "", false + } + keyType = typeStr[len("map["):closeIdx] + valueType = typeStr[closeIdx+1:] + if keyType == "" || valueType == "" { + return "", "", false + } + return keyType, valueType, true +} + func writeFileHeader() string { return common.FileHeader("//", "C#") } diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go index c2abdc02..2a02d415 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -12,7 +12,8 @@ import ( "github.com/Alia5/VIIPER/internal/codegen/meta" ) -const dtoTemplate = `{{writeFileHeader}}using System.Text.Json.Serialization; +const dtoTemplate = `{{writeFileHeader}}using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Viiper.Client.Types; @@ -72,6 +73,19 @@ func fieldTypeToCSharp(field interface{}) string { typeStr := v.FieldByName("Type").String() typeKind := v.FieldByName("TypeKind").String() + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + keyType, valueType, ok := parseGoMapType(typeStr) + if !ok { + return "Dictionary" + } + _ = keyType + 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) diff --git a/internal/codegen/generator/rust/device_types.go b/internal/codegen/generator/rust/device_types.go index 951c748e..9301c15e 100644 --- a/internal/codegen/generator/rust/device_types.go +++ b/internal/codegen/generator/rust/device_types.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" "text/template" @@ -45,16 +46,27 @@ pub struct {{.StructName}} { impl DeviceOutput for {{.StructName}} { fn from_bytes(buf: &[u8]) -> Result { let mut offset = 0; -{{range .Fields}}{{if .IsArray}} // Read array (size determined by remaining bytes) - 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; - } -{{else}} if offset + std::mem::size_of::<{{.RustType}}>() > buf.len() { +{{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() )); @@ -80,6 +92,7 @@ type rustWireField struct { ElementType string IsArray bool CountName string + FixedLen int } type deviceTypeData struct { @@ -124,23 +137,31 @@ func generateDeviceWireStruct(outputPath, deviceName, className string, tag *sca rustName := common.ToSnakeCase(field.Name) wireType := field.Type baseType := wireType - - isArray := isWireArray(field.Spec) + countToken := "" + isArray := strings.Contains(wireType, "*") + fixedLen := 0 var countName string var elemType string if isArray { - countName = extractArrayCount(field.Spec) idx := strings.Index(wireType, "*") - if idx > 0 { - baseType = wireType[:idx] - } + 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 { - rustType = fmt.Sprintf("Vec<%s>", elemType) + if fixedLen > 0 { + rustType = fmt.Sprintf("[%s; %d]", elemType, fixedLen) + } else { + rustType = fmt.Sprintf("Vec<%s>", elemType) + } } fields = append(fields, rustWireField{ @@ -150,6 +171,7 @@ func generateDeviceWireStruct(outputPath, deviceName, className string, tag *sca ElementType: elemType, IsArray: isArray, CountName: countName, + FixedLen: fixedLen, }) } diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go index 256a3e3d..9c3f59d3 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -10,6 +10,9 @@ import ( 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 { @@ -51,6 +54,22 @@ func goTypeToRust(goType string) string { return rustType } +func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", "", false + } + keyType = typeStr[len("map["):closeIdx] + valueType = typeStr[closeIdx+1:] + if keyType == "" || valueType == "" { + return "", "", false + } + return keyType, valueType, true +} + func wireTypeToRust(wireType string) string { baseType := strings.TrimSuffix(wireType, "*") if strings.Contains(wireType, "*") { diff --git a/internal/codegen/generator/rust/types.go b/internal/codegen/generator/rust/types.go index ce08c0ca..6da7d736 100644 --- a/internal/codegen/generator/rust/types.go +++ b/internal/codegen/generator/rust/types.go @@ -109,7 +109,15 @@ func fieldTypeToRust(field interface{}) string { var rustType string - if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + keyType, valueType, ok := parseGoMapType(typeStr) + if ok { + _ = keyType + 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 { diff --git a/internal/codegen/generator/typescript/device_types.go b/internal/codegen/generator/typescript/device_types.go index efecd404..5072b1bd 100644 --- a/internal/codegen/generator/typescript/device_types.go +++ b/internal/codegen/generator/typescript/device_types.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" "text/template" @@ -38,11 +39,23 @@ func generateDeviceTypes(logger *slog.Logger, deviceDir string, deviceName strin type tsWireField struct { Name string - GoType 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 { @@ -101,19 +114,22 @@ func generateWireClassTS(outputPath, device, className string, tag *scanner.Wire Device string ClassName string Fields []tsWireField - HasArray bool - ArrayInfo *tsWireField }{Device: device, ClassName: className} for _, field := range tag.Fields { - wf := tsWireField{Name: common.ToPascalCase(field.Name), GoType: field.Type, Writer: writerFor(field.Type), Reader: readerFor(field.Type)} - if strings.Contains(field.Spec, "*") { - parts := strings.Split(field.Spec, "*") - if len(parts) == 2 { - data.HasArray = true - wf.IsArray = true - wf.CountName = common.ToPascalCase(parts[1]) - copy := wf - data.ArrayInfo = © + 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) @@ -139,34 +155,39 @@ import { BinaryWriter, BinaryReader } from '../../utils/binary'; import type { IBinarySerializable } from '../../ViiperDevice'; export class {{.Device}}{{.ClassName}} implements IBinarySerializable { -{{range .Fields}} {{.Name}}!: {{if or (eq .GoType "u64") (eq .GoType "i64")}}bigint{{else}}number{{end}}{{if .IsArray}}[]{{end}}; +{{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 { -{{if .HasArray}} // Write fixed-size fields -{{range .Fields}}{{if not .IsArray}} writer.{{.Writer}}(this.{{.Name}} as any); -{{end}}{{end}} // Write variable-length array - for (let i = 0; i < (this.{{.ArrayInfo.CountName}} as number); i++) { - writer.{{.ArrayInfo.Writer}}((this.{{.ArrayInfo.Name}} as any[])[i]); - } -{{else}}{{range .Fields}} writer.{{.Writer}}(this.{{.Name}} as any); +{{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}} { -{{if .HasArray}} // Read fixed-size fields -{{range .Fields}}{{if not .IsArray}} const {{.Name | toCamelTS}} = reader.{{.Reader}}(); -{{end}}{{end}} const {{.ArrayInfo.Name | toCamelTS}}: any[] = new Array(Number({{.ArrayInfo.CountName | toCamelTS}})); - for (let i = 0; i < Number({{.ArrayInfo.CountName | toCamelTS}}); i++) { - {{.ArrayInfo.Name | toCamelTS}}[i] = reader.{{.ArrayInfo.Reader}}(); - } - return new {{.Device}}{{.ClassName}}({ -{{range .Fields}}{{if not .IsArray}} {{.Name}}: {{.Name | toCamelTS}}, -{{end}}{{end}} {{.ArrayInfo.Name}}: {{.ArrayInfo.Name | toCamelTS}}, - }); -{{else}} return new {{.Device}}{{.ClassName}}({ -{{range .Fields}} {{.Name}}: reader.{{.Reader}}(), +{{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}} }); -{{end}} } + } } ` diff --git a/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go index 4018fda1..bde95a30 100644 --- a/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -1,21 +1,41 @@ package typescript import ( + "strings" + "github.com/Alia5/VIIPER/internal/codegen/common" ) func goTypeToTS(goType string) string { base, _, _ := common.NormalizeGoType(goType) switch base { - case "uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64": + 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.ToPascalCase(base) } } +func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { + if !strings.HasPrefix(typeStr, "map[") { + return "", "", false + } + closeIdx := strings.Index(typeStr, "]") + if closeIdx < 0 { + return "", "", false + } + keyType = typeStr[len("map["):closeIdx] + valueType = typeStr[closeIdx+1:] + if keyType == "" || valueType == "" { + return "", "", false + } + return keyType, valueType, true +} + func writeFileHeaderTS() string { return common.FileHeader("//", "TypeScript") } diff --git a/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go index 5a53cb96..f926a37c 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -57,6 +57,15 @@ func fieldTypeToTS(field interface{}) string { typeStr := v.FieldByName("Type").String() typeKind := v.FieldByName("TypeKind").String() + if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { + k, val, ok := parseGoMapType(typeStr) + if ok { + _ = k + return "Record" + } + return "Record" + } + if typeKind == "slice" || strings.HasPrefix(typeStr, "[]") { elem := strings.TrimPrefix(typeStr, "[]") return goTypeToTS(elem) + "[]" diff --git a/internal/server/api/device_registry.go b/internal/server/api/device_registry.go index 82eed183..887017db 100644 --- a/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -11,7 +11,7 @@ import ( // and stream handler registration. type DeviceRegistration interface { // CreateDevice returns a new device instance of this type. - CreateDevice(o *device.CreateOptions) usb.Device + CreateDevice(o *device.CreateOptions) (usb.Device, error) // StreamHandler returns the handler function for long-lived connections. StreamHandler() StreamHandlerFunc } diff --git a/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go index 7c7a3408..dc59a586 100644 --- a/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -23,6 +23,9 @@ func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { 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) { @@ -77,7 +80,7 @@ func TestDeviceRegistry(t *testing.T) { reg := th.CreateMockRegistration( t, tt.expectedDevice, - func(o *device.CreateOptions) usb.Device { return &mockDevice{name: tt.expectedDevice} }, + func(o *device.CreateOptions) (usb.Device, error) { return &mockDevice{name: tt.expectedDevice}, nil }, mockHandler, ) @@ -89,7 +92,8 @@ func TestDeviceRegistry(t *testing.T) { if tt.shouldFind { assert.NotNil(t, retrieved, "expected to find registered device") if retrieved != nil { - dev := retrieved.CreateDevice(nil) + dev, err := retrieved.CreateDevice(nil) + assert.NoError(t, err) mockDev, ok := dev.(*mockDevice) assert.True(t, ok, "expected mockDevice type") if ok { @@ -152,7 +156,7 @@ func TestGetStreamHandler(t *testing.T) { reg := th.CreateMockRegistration( t, tt.registerName, - func(o *device.CreateOptions) usb.Device { return &mockDevice{name: tt.registerName} }, + func(o *device.CreateOptions) (usb.Device, error) { return &mockDevice{name: tt.registerName}, nil }, mockHandler, ) diff --git a/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go index fa3953ff..02420b46 100644 --- a/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -29,7 +29,8 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { bus, err := virtualbus.NewWithBusId(90001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := keyboard.New(nil) + dev, err := keyboard.New(nil) + require.NoError(t, err) devCtx, err := bus.Add(dev) require.NoError(t, err) @@ -55,7 +56,7 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { handlerCalled := make(chan bool, 1) testReg := th.CreateMockRegistration(t, "keyboard", - func(o *device.CreateOptions) pusb.Device { return keyboard.New(o) }, + 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 @@ -91,7 +92,8 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { bus, err := virtualbus.NewWithBusId(70001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) - dev := keyboard.New(nil) + dev, err := keyboard.New(nil) + require.NoError(t, err) devCtx, err := bus.Add(dev) require.NoError(t, err) meta := device.GetDeviceMeta(devCtx) @@ -110,7 +112,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { handlerCalled := make(chan struct{}, 1) testReg := th.CreateMockRegistration(t, "keyboard", - func(o *device.CreateOptions) pusb.Device { return keyboard.New(o) }, + 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 diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 8d495b7b..7d060589 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -49,11 +49,15 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } opts := device.CreateOptions{ - IdVendor: deviceCreateReq.IdVendor, - IdProduct: deviceCreateReq.IdProduct, + IdVendor: deviceCreateReq.IdVendor, + IdProduct: deviceCreateReq.IdProduct, + DeviceSpecific: deviceCreateReq.DeviceSpecific, } - dev := reg.CreateDevice(&opts) + 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)) @@ -100,11 +104,12 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } payload, err := json.Marshal(apitypes.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, + 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)) diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index 6a220bcd..97263c06 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -44,7 +44,37 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80001"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80001, "devId": "1", "vid":"0x045e", "pid":"0x028e", "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", @@ -100,7 +130,11 @@ func TestBusDeviceAdd(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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 { @@ -109,7 +143,7 @@ func TestBusDeviceAdd(t *testing.T) { }, pathParams: map[string]string{"id": "80005"}, payload: `{"type": "xbox360"}`, - expectedResponse: `{"busId":80005, "devId": "1", "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, + expectedResponse: `{"busId":80005, "devId": "1", "deviceSpecific": {"subType":1}, "vid":"0x045e", "pid":"0x028e", "type":"xbox360"}`, }, { name: "autoattach fails returns error", @@ -191,7 +225,7 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { defer apiSrv.Close() testReg := th.CreateMockRegistration(t, "xbox360", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, func(conn net.Conn, devPtr *pusb.Device, l *slog.Logger) error { return nil }, ) diff --git a/internal/server/api/handler/bus_device_remove_test.go b/internal/server/api/handler/bus_device_remove_test.go index ac58c794..8bc98e1d 100644 --- a/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -32,7 +32,11 @@ func TestBusDeviceRemove(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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) } }, diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go index 026b67ba..6d8e5f0a 100644 --- a/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -35,11 +35,12 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { 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.Dev.GetDescriptor().Device.IDVendor), - Pid: fmt.Sprintf("0x%04x", m.Dev.GetDescriptor().Device.IDProduct), - Type: dtype, + 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(apitypes.DevicesListResponse{Devices: out}) diff --git a/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go index 996e8e7a..a941bdf1 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -45,12 +45,16 @@ func TestBusDevicesList(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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","vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + expectedResponse: `{"devices":[{"busId":60009,"devId":"1","deviceSpecific":{"subType": 1},"vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, }, { name: "list devices with multiple additions", @@ -62,15 +66,23 @@ func TestBusDevicesList(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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","vid":"0x045e","pid":"0x028e","type":"xbox360"},{"busId":60010,"devId":"2","vid":"0x045e","pid":"0x028e","type":"xbox360"}]}`, + 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", diff --git a/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go index a2357635..a75e4b67 100644 --- a/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -65,10 +65,18 @@ func TestBusRemove(t *testing.T) { if err := s.AddBus(b); err != nil { t.Fatalf("add bus failed: %v", err) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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) } - if _, err := b.Add(xbox360.New(nil)); err != nil { + 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) } }, diff --git a/internal/server/api/server_test.go b/internal/server/api/server_test.go index 3ed041c0..46c39b4c 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -46,7 +46,8 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { bus, err := virtualbus.NewWithBusId(70002) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(bus)) - dev := xbox360.New(nil) + dev, err := xbox360.New(nil) + require.NoError(t, err) _, err = bus.Add(dev) require.NoError(t, err) @@ -60,7 +61,7 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { sentinel := fmt.Errorf("boom") mr := th.CreateMockRegistration(t, "xbox360_error_stream", - func(o *device.CreateOptions) pusb.Device { return xbox360.New(o) }, + func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, func(conn net.Conn, d *pusb.Device, l *slog.Logger) error { return sentinel }, ) diff --git a/usb/device.go b/usb/device.go index 2112d05e..bfa4742b 100644 --- a/usb/device.go +++ b/usb/device.go @@ -8,6 +8,7 @@ type Device interface { // For IN transfers, return the payload to send; for OUT, consume 'out' and return nil. HandleTransfer(ep uint32, dir uint32, out []byte) []byte GetDescriptor() *Descriptor + GetDeviceSpecificArgs() map[string]any } // ControlDevice is an optional interface for devices that need to handle From 370517f475964c7a53d3000162babdce929272cd Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Sun, 8 Feb 2026 14:19:59 +1300 Subject: [PATCH 140/298] Revise Xbox 360 controller documentation Updated Xbox 360 controller documentation to note that the input state is 20 bytes and has the reserved bytes at the end --- docs/devices/xbox360.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/devices/xbox360.md b/docs/devices/xbox360.md index 653b2629..3be2d0dd 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -51,12 +51,13 @@ The device stream is a bidirectional, raw TCP connection with fixed-size packets ### Input State -- 14-byte packets, little-endian layout: +- 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 From 71ef81a8915b5850ba8025d1caa2c8a98364db99 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 15 Mar 2026 14:46:33 +0100 Subject: [PATCH 141/298] Upgrade to go 1.26 --- .github/workflows/build_base.yml | 2 +- README.md | 2 +- docs/getting-started/installation.md | 2 +- go.mod | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index 0f78a37e..84eadcb0 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -77,7 +77,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: stable + go-version: 1.26 cache: true cache-dependency-path: | go.sum diff --git a/README.md b/README.md index 82f3fc72..82ea2b41 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ See the [API documentation](./docs/api) for details ### 🧰 Prerequisites -- [Go](https://go.dev/) 1.25 or newer +- [Go](https://go.dev/) 1.26 or newer - USBIP installed - (Optional) [Make](https://www.gnu.org/software/make/) - Linux/macOS: Usually pre-installed diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8897f7a8..7a9170dc 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -224,7 +224,7 @@ Building from source is only necessary if you need to modify VIIPER or target an ### Prerequisites -- [Go](https://go.dev/) 1.25 or newer +- [Go](https://go.dev/) 1.26 or newer - USBIP installed - (Optional) [Make](https://www.gnu.org/software/make/) - Linux/macOS: Usually pre-installed diff --git a/go.mod b/go.mod index 11fc9b90..616b6047 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Alia5/VIIPER -go 1.25 +go 1.26 require ( github.com/alecthomas/kong v1.13.0 From fcdbc6ecd03102bf41c6512dc99c2e43929d12e7 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 15 Mar 2026 14:55:20 +0100 Subject: [PATCH 142/298] Clean changelog gen --- .github/scripts/generate-changelog.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 1388ee4d..0e4fa372 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -69,7 +69,7 @@ for commit_hash in "${COMMITS[@]}"; do fi fi 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') entry="- $commit_msg" if [ -n "$body_content" ]; then entry=$(printf "%s\n%s" "$entry" "$(echo "$body_content" | sed 's/^/ /')") From fd4d973d80906352cada9ad630ad274ab325f77f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 15 Mar 2026 17:29:12 +0100 Subject: [PATCH 143/298] Add auto update Changelog(feat) --- .github/workflows/release.yml | 22 ++++ .github/workflows/snapshots.yml | 22 ++++ cmd/viiper/viiper.go | 12 ++ go.mod | 6 + go.sum | 16 +++ internal/config/config.go | 13 +- internal/updater/msgbox.go | 32 +++++ internal/updater/updater.go | 215 ++++++++++++++++++++++++++++++++ 8 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 internal/updater/msgbox.go create mode 100644 internal/updater/updater.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f01b0f36..79815fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,6 +220,28 @@ jobs: 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 diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index aac8c7cf..67c525c5 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -116,5 +116,27 @@ jobs: ⚠️ 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/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index 87b619f9..f580411e 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -7,10 +7,12 @@ import ( "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 @@ -54,6 +56,16 @@ func main() { 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) } diff --git a/go.mod b/go.mod index 616b6047..1a966d7c 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,15 @@ require ( require ( github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 // indirect github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c // indirect + github.com/akavel/rsrc v0.10.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect + github.com/josephspurrier/goversioninfo v1.4.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/ncruces/zenity v0.10.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/image v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index ef5b3ec3..1d7d6c37 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 h1:a7BoGQPgciWaIwBlh github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1/go.mod h1:mJcKev/gsLraiuXsB/EPyh4hdYnsUHyNU5CXch3cv0A= github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c h1:3z1BdpfvUbaP7oXjPabl7STN7zz88S432hZJ8M095kI= github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c/go.mod h1:xpUxPkAb7v0Ffn/NGp1XpD+tZly4dpSPI7DTAFT37es= +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.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= @@ -13,26 +15,39 @@ github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfR 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= +github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b h1:/KAOJuXR4cWaQIiA9xBMDSQJ1JXq5gZHdSK8prrtUqQ= github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= +github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= 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-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= +github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= @@ -40,5 +55,6 @@ golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/config/config.go b/internal/config/config.go index a8bbcb2e..dc9dae83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,14 @@ 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 `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"` @@ -14,8 +22,9 @@ type Log struct { // 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."` + 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."` Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server"` Proxy cmd.Proxy `cmd:"" help:"Start the VIIPER USB-IP proxy"` 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..fbe462b4 --- /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() + 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() + 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) + } + } +} From ee448d89838de36f3007fd3e4072b16e5c1ef415 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 21 Apr 2026 19:52:13 +0200 Subject: [PATCH 144/298] Update deps / Go --- go.mod | 27 ++++++++++++--------------- go.sum | 52 ++++++++++++++++++++++------------------------------ 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 1a966d7c..a79cf767 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,28 @@ module github.com/Alia5/VIIPER -go 1.26 +go 1.26.2 require ( - github.com/alecthomas/kong v1.13.0 + 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.9.0 - golang.org/x/crypto v0.47.0 - golang.org/x/sys v0.40.0 - golang.org/x/term v0.39.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.50.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 // indirect - github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c // indirect github.com/akavel/rsrc v0.10.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect - github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b // indirect - github.com/josephspurrier/goversioninfo v1.4.1 // indirect + github.com/dchest/jsmin v1.0.0 // indirect + github.com/josephspurrier/goversioninfo v1.5.0 // indirect github.com/kr/text v0.2.0 // indirect - github.com/ncruces/zenity v0.10.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/image v0.34.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 index 1d7d6c37..a8985e99 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,9 @@ -github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1 h1:a7BoGQPgciWaIwBlh/IMkuqDDU0otZkzbnl4sPZZKm0= -github.com/Zyko0/go-sdl3 v0.0.0-20260125144524-02de3d449cb1/go.mod h1:mJcKev/gsLraiuXsB/EPyh4hdYnsUHyNU5CXch3cv0A= -github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c h1:3z1BdpfvUbaP7oXjPabl7STN7zz88S432hZJ8M095kI= -github.com/Zyko0/purego-gen v0.0.0-20250727121216-3bcd331a1e0c/go.mod h1:xpUxPkAb7v0Ffn/NGp1XpD+tZly4dpSPI7DTAFT37es= 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.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= -github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +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= @@ -15,17 +11,14 @@ github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfR 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= -github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= -github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b h1:/KAOJuXR4cWaQIiA9xBMDSQJ1JXq5gZHdSK8prrtUqQ= -github.com/ebitengine/purego v0.9.0-alpha.2.0.20250124174847-29f0104e3c2b/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= +github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= 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.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= -github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +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= @@ -36,25 +29,24 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v 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-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= -github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= -golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 36cf8c35c39474d09897e77b03b31eb7578a0831 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 27 Apr 2026 11:12:07 +0200 Subject: [PATCH 145/298] libVIIPER Changelog(feat) --- .vscode/settings.json | 5 + Makefile | 20 ++ examples/libVIIPER/CMakeLists.txt | 34 +++ examples/libVIIPER/main.c | 121 +++++++++ internal/server/api/handler/bus_create.go | 1 - lib/viiper/bus.go | 73 +++++ lib/viiper/dualshock4.go | 253 +++++++++++++++++ lib/viiper/keyboard.go | 316 ++++++++++++++++++++++ lib/viiper/mouse.go | 164 +++++++++++ lib/viiper/postbuild/main.go | 64 +++++ lib/viiper/server.go | 136 ++++++++++ lib/viiper/viiper.go | 47 ++++ lib/viiper/xbox360.go | 237 ++++++++++++++++ 13 files changed, 1470 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json create mode 100644 examples/libVIIPER/CMakeLists.txt create mode 100644 examples/libVIIPER/main.c create mode 100644 lib/viiper/bus.go create mode 100644 lib/viiper/dualshock4.go create mode 100644 lib/viiper/keyboard.go create mode 100644 lib/viiper/mouse.go create mode 100644 lib/viiper/postbuild/main.go create mode 100644 lib/viiper/server.go create mode 100644 lib/viiper/viiper.go create mode 100644 lib/viiper/xbox360.go diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..79baf32e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "go.toolsEnvVars": { + "CGO_ENABLED": "1" + } +} diff --git a/Makefile b/Makefile index eb31767a..9c578a09 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,7 @@ help: ## Show this help message @echo. @echo Build Targets: @echo build Build VIIPER for current platform + @echo libVIIPER Build libVIIPER shared library (DLL on Windows, SO on Linux) @echo clean Remove build artifacts @echo test Run tests @echo test-coverage Run tests with coverage @@ -151,6 +152,25 @@ ifeq ($(OS),Windows_NT) endif cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) +############################################################ +# libVIIPER shared library +############################################################ + +LIBVIIPER_DIST_DIR := dist/libVIIPER + +.PHONY: libVIIPER +libVIIPER: +ifeq ($(OS),Windows_NT) + @if not exist $(LIBVIIPER_DIST_DIR) mkdir $(LIBVIIPER_DIST_DIR) + set CGO_ENABLED=1 && go build -buildmode=c-shared -o $(LIBVIIPER_DIST_DIR)/libVIIPER.dll ./lib/viiper + cd $(LIBVIIPER_DIST_DIR) && gendef libVIIPER.dll + go run ./lib/viiper/postbuild +else + @mkdir -p $(LIBVIIPER_DIST_DIR) + CGO_ENABLED=1 go build -buildmode=c-shared -o $(LIBVIIPER_DIST_DIR)/libVIIPER.so ./lib/viiper + go run ./lib/viiper/postbuild +endif + .PHONY: clean clean: clean-versioninfo ## Remove build artifacts -@$(RM_DIR) $(DIST_DIR) 2>$(NULL_DEVICE) diff --git a/examples/libVIIPER/CMakeLists.txt b/examples/libVIIPER/CMakeLists.txt new file mode 100644 index 00000000..d06b9c22 --- /dev/null +++ b/examples/libVIIPER/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/main.c b/examples/libVIIPER/main.c new file mode 100644 index 00000000..fc8abc3e --- /dev/null +++ b/examples/libVIIPER/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/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go index cbb0b5df..15d3d5bc 100644 --- a/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -14,7 +14,6 @@ import ( ) // 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 req.Payload != "" { diff --git a/lib/viiper/bus.go b/lib/viiper/bus.go new file mode 100644 index 00000000..57f0d4f9 --- /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/dualshock4.go b/lib/viiper/dualshock4.go new file mode 100644 index 00000000..ed33fe5a --- /dev/null +++ b/lib/viiper/dualshock4.go @@ -0,0 +1,253 @@ +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 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" + "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). +// +//export CreateDS4Device +func CreateDS4Device( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DS4DeviceHandle, + 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 := 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..1a0d9674 --- /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..2629886a --- /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/postbuild/main.go b/lib/viiper/postbuild/main.go new file mode 100644 index 00000000..f86ac4d4 --- /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.Split(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) +} diff --git a/lib/viiper/server.go b/lib/viiper/server.go new file mode 100644 index 00000000..0442f9dc --- /dev/null +++ b/lib/viiper/server.go @@ -0,0 +1,136 @@ +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 + } + if writeBatchFlushInterval == 0 { + writeBatchFlushInterval = 1 * time.Millisecond + } + + 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 <-errChan: + 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/viiper.go b/lib/viiper/viiper.go new file mode 100644 index 00000000..218f07f7 --- /dev/null +++ b/lib/viiper/viiper.go @@ -0,0 +1,47 @@ +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() {} + +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..db0e6f3a --- /dev/null +++ b/lib/viiper/xbox360.go @@ -0,0 +1,237 @@ +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 + } + var deviceSpecific map[string]any + err = json.Unmarshal(str, &deviceSpecific) + if err != nil { + return false + } + opts.DeviceSpecific = deviceSpecific + } + 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 +} From a82b7a9815cab424c05442ce85f991d0f9c7f7e5 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 14:00:59 +0200 Subject: [PATCH 146/298] Fix swallowing error --- lib/viiper/server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/viiper/server.go b/lib/viiper/server.go index 0442f9dc..e82c954b 100644 --- a/lib/viiper/server.go +++ b/lib/viiper/server.go @@ -101,7 +101,8 @@ func NewUSBServer(config *C.USBServerConfig, outHandle *C.USBServerHandle, logCa deviceHandles: make(map[uint32][]deviceHandle), })) return true - case <-errChan: + case err := <-errChan: + logger.Error("NewUSBServer: ListenAndServe failed", "error", err) return false } } From b7dcec9d4882fd15ef7bf79ed2f21e2e2fe1904d Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 147/298] Add libVIIPER c# example --- examples/libVIIPER/{ => C}/CMakeLists.txt | 2 +- examples/libVIIPER/{ => C}/main.c | 0 examples/libVIIPER/csharp/.gitignore | 2 + examples/libVIIPER/csharp/Program.cs | 179 ++++++++++++++++++ .../libVIIPER/csharp/libVIIPER_example.csproj | 18 ++ 5 files changed, 200 insertions(+), 1 deletion(-) rename examples/libVIIPER/{ => C}/CMakeLists.txt (93%) rename examples/libVIIPER/{ => C}/main.c (100%) create mode 100644 examples/libVIIPER/csharp/.gitignore create mode 100644 examples/libVIIPER/csharp/Program.cs create mode 100644 examples/libVIIPER/csharp/libVIIPER_example.csproj diff --git a/examples/libVIIPER/CMakeLists.txt b/examples/libVIIPER/C/CMakeLists.txt similarity index 93% rename from examples/libVIIPER/CMakeLists.txt rename to examples/libVIIPER/C/CMakeLists.txt index d06b9c22..51136f96 100644 --- a/examples/libVIIPER/CMakeLists.txt +++ b/examples/libVIIPER/C/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(libVIIPER_example C) -set(LIB_DIR "${CMAKE_CURRENT_LIST_DIR}/../../dist/libVIIPER") +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}") diff --git a/examples/libVIIPER/main.c b/examples/libVIIPER/C/main.c similarity index 100% rename from examples/libVIIPER/main.c rename to examples/libVIIPER/C/main.c 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 + + + From 9df9750b2eab08e8f704234512ebf0437c09dd69 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 148/298] Update dotnet 8.0 -> 10 Changelog(misc) --- examples/csharp/virtual_keyboard/VirtualKeyboard.csproj | 2 +- examples/csharp/virtual_mouse/VirtualMouse.csproj | 2 +- examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj | 2 +- internal/codegen/generator/csharp/project.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj b/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj index 760e2860..dbc51a0a 100644 --- a/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj +++ b/examples/csharp/virtual_keyboard/VirtualKeyboard.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 enable enable diff --git a/examples/csharp/virtual_mouse/VirtualMouse.csproj b/examples/csharp/virtual_mouse/VirtualMouse.csproj index 760e2860..dbc51a0a 100644 --- a/examples/csharp/virtual_mouse/VirtualMouse.csproj +++ b/examples/csharp/virtual_mouse/VirtualMouse.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 enable enable diff --git a/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj b/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj index 760e2860..dbc51a0a 100644 --- a/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj +++ b/examples/csharp/virtual_x360_pad/VirtualX360Pad.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 enable enable diff --git a/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go index 43123926..8d9f60c6 100644 --- a/internal/codegen/generator/csharp/project.go +++ b/internal/codegen/generator/csharp/project.go @@ -13,7 +13,7 @@ import ( const projectTemplate = ` - net8.0 + net10.0 Viiper.Client enable latest From 7fddb69f345078186c6e8ffc2473efc26fd02f4d Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 149/298] Update Readme --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 82ea2b41..a1731f1d 100644 --- a/README.md +++ b/README.md @@ -31,22 +31,25 @@ These virtual devices are indistinguishable from real hardware to the operating (USBIP still requires a kernel driver, but this is generic and device _emulation_ code still lives in Userspace) - Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. -VIIPER _currently_ comes in a single flavor: +VIIPER comes in two distinct flavors: - a self-contained, (no dependencies) portable, standalone executable. providing a lightweight TCP based API for feeder application development. -- There will eventually be a library version (libVIIPER) that you can link against directly from your application. -For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) + +- libVIIPER, a single shared library that allows you to emulate devices using USBIP directly from within your application. + See Examples for C and C# [here](./examples/libVIIPER) + +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](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) - - PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) - - 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) +- Xbox 360 controller emulation; 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) +- PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) +- 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) ## 🔌 Requirements @@ -68,11 +71,11 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio ## 🥫 Feeder application development -VIIPER _currently_ comes in a single flavor: +You have two options for developing feeder applications that control the virtual devices created by VIIPER: -- a standalone executable that exposes an API over TCP. -- There will eventually be a library version (libVIIPER) that you can link against directly from your application. -For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) +- Use the standalone VIIPER server and interface via the exposed TCP-API (prefferably using one of the available client libraries) +- Integrate libVIIPER directly into you application. + See [Examples](examples/libVIIPER) for examples in either C or C#. ### 🔌 API @@ -143,14 +146,12 @@ See the [issues page](https://github.com/Alia5/VIIPER/issues) for bugs and featu 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 is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself +### Why choose the 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 - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. -- **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. - Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) ### Can I use VIIPER for gaming? From 6549f8c41cc638bbc9d1abfdd66ca269bf2cb972 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 150/298] Add metaInfo to libVIIPER.dll --- Makefile | 8 ++++++++ lib/viiper/versioninfo.json | 41 +++++++++++++++++++++++++++++++++++++ versioninfo.json | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 lib/viiper/versioninfo.json diff --git a/Makefile b/Makefile index 9c578a09..8e861d20 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,8 @@ BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" # Windows resource embedding VERSIONINFO_JSON := versioninfo.json RESOURCE_SYSO := cmd/viiper/resource.syso +LIBVIIPER_VERSIONINFO_JSON := lib/viiper/versioninfo.json +LIBVIIPER_RESOURCE_SYSO := lib/viiper/resource.syso .PHONY: all all: test build @@ -143,7 +145,9 @@ endif .PHONY: clean-versioninfo clean-versioninfo: ## Remove generated Windows version info resource -@$(RM_FILE) $(RESOURCE_SYSO) 2>$(NULL_DEVICE) + -@$(RM_FILE) $(LIBVIIPER_RESOURCE_SYSO) 2>$(NULL_DEVICE) -@$(RM_FILE) versioninfo.tmp.json 2>$(NULL_DEVICE) + -@$(RM_FILE) libviiper.versioninfo.tmp.json 2>$(NULL_DEVICE) .PHONY: build build: ## Build for current platform @@ -162,6 +166,10 @@ LIBVIIPER_DIST_DIR := dist/libVIIPER libVIIPER: ifeq ($(OS),Windows_NT) @if not exist $(LIBVIIPER_DIST_DIR) mkdir $(LIBVIIPER_DIST_DIR) + @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(LIBVIIPER_VERSIONINFO_JSON)" "libviiper.versioninfo.tmp.json" + @cd $(SRC_DIR) && goversioninfo -64 -o $(LIBVIIPER_RESOURCE_SYSO) libviiper.versioninfo.tmp.json + @del libviiper.versioninfo.tmp.json set CGO_ENABLED=1 && go build -buildmode=c-shared -o $(LIBVIIPER_DIST_DIR)/libVIIPER.dll ./lib/viiper cd $(LIBVIIPER_DIST_DIR) && gendef libVIIPER.dll go run ./lib/viiper/postbuild 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/versioninfo.json b/versioninfo.json index 4310cebb..31809956 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -24,7 +24,7 @@ "FileDescription": "VIIPER - Virtual Input over IP EmulatoR", "FileVersion": "0.0.0.0", "InternalName": "viiper", - "LegalCopyright": "Copyright (C) 2025 Peter Repukat - GPL-3.0", + "LegalCopyright": "Copyright (C) 2025-2026 Peter Repukat - GPL-3.0", "LegalTrademarks": "", "OriginalFilename": "viiper.exe", "PrivateBuild": "", From 9d08b0258e3ef87911507e8a3eaebe1e3af754e9 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 151/298] Update docs --- README.md | 6 +- docs/clients/csharp.md | 2 +- docs/devices/dualshock4.md | 328 +++++++++++--------- docs/devices/keyboard.md | 133 +++++--- docs/devices/mouse.md | 82 +++-- docs/devices/xbox360.md | 222 ++++++++----- docs/getting-started/installation.md | 446 ++++++++++++--------------- docs/index.md | 33 +- docs/libviiper/overview.md | 124 ++++++++ mkdocs.yml | 128 ++++---- 10 files changed, 882 insertions(+), 622 deletions(-) create mode 100644 docs/libviiper/overview.md diff --git a/README.md b/README.md index a1731f1d..3bb2ed2f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ VIIPER comes in two distinct flavors: - libVIIPER, a single shared library that allows you to emulate devices using USBIP directly from within your application. See Examples for C and C# [here](./examples/libVIIPER) -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) +For why you should pick one over the other see the [FAQ](#why-choose-the-standalone-executable-and-interfacing-via-tcp-over-the-shared-object-libviiper-library) Beyond device emulation, VIIPER can proxy real USB devices for traffic inspection and reverse engineering. @@ -73,8 +73,8 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio 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 (prefferably using one of the available client libraries) -- Integrate libVIIPER directly into you application. +- 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 [Examples](examples/libVIIPER) for examples in either C or C#. ### 🔌 API diff --git a/docs/clients/csharp.md b/docs/clients/csharp.md index eb033a6e..574d5573 100644 --- a/docs/clients/csharp.md +++ b/docs/clients/csharp.md @@ -7,7 +7,7 @@ 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 8.0 with nullable reference types +- **Modern .NET**: Targets .NET 10.0 with nullable reference types - **Zero external dependencies**: Uses only built-in .NET libraries !!! note "License" diff --git a/docs/devices/dualshock4.md b/docs/devices/dualshock4.md index 2c866a51..f064d9de 100644 --- a/docs/devices/dualshock4.md +++ b/docs/devices/dualshock4.md @@ -1,148 +1,180 @@ -# 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), and touchpad finger coordinates. - -Use `dualshock4` 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/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 °/s) - - Accelerometer: AccelX, AccelY, AccelZ: int16 each (6 bytes, fixed-point m/s²) - -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** - -These are the bounds used by VIIPER’s DS4 implementation; see `/device/dualshock4/const.go`. - -### IMU (Gyro + Accelerometer) - -#### Fixed-Point Physical Units - -VIIPER uses **fixed-point physical units** for IMU values on the wire (still stored as `int16`), to avoid float serialization differences across client languages. - -Constants (see `/device/dualshock4/const.go`): - -- `GyroCountsPerDps = 16` -- `AccelCountsPerMS2 = 512` - -#### Conversion Formulas - -**Gyro (degrees/second):** - -```text -raw_gyro = round(gyro_dps * GyroCountsPerDps) -gyro_dps = raw_gyro / GyroCountsPerDps -``` - -**Accelerometer (m/s²):** - -```text -raw_accel = round(accel_ms2 * AccelCountsPerMS2) -accel_ms2 = raw_accel / AccelCountsPerMS2 -``` - -#### Resolution and range - -With the default scales: - -- **Gyro** (`GyroCountsPerDps = 16`): - - Resolution: `1/16 = 0.0625 °/s` - - Approx max magnitude: `32767/16 ≈ 2048 °/s` -- **Accelerometer** (`AccelCountsPerMS2 = 512`): - - Resolution: `1/512 ≈ 0.001953125 m/s²` - - Approx max magnitude: `32767/512 ≈ 64 m/s²` (≈ 6.5 g) - -Conversions saturate to the `int16` range if inputs exceed representable values. - -#### Default (Neutral) Report Gravity - -On device creation, VIIPER initializes the accelerometer to represent a controller lying flat on a table, with gravity "downwards": - -- `g = 9.81 m/s²` -- Default accel is: `(0, 0, -g)` - -In raw fixed-point units, this means: - -- `AccelX = 0` -- `AccelY = 0` -- `AccelZ = round(-9.81 * 512) = -5023` - -Helpers for converting between physical units and raw values are provided in `/device/dualshock4/helpers.go`. +# 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), and touchpad finger coordinates. + +=== "TCP API" + + Use `dualshock4` 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/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 ad38cd54..7bc814d1 100644 --- a/docs/devices/keyboard.md +++ b/docs/devices/keyboard.md @@ -3,58 +3,115 @@ A full-featured HID keyboard with N-key rollover using a 256-bit key bitmap, plus LED status feedback (NumLock, CapsLock, ScrollLock). -Use keyboard as the device type when adding a device via the API or client libraries. +=== "TCP API" -## Client Library Support + Use `keyboard` as the device type when adding a device via the API or client libraries. -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. + ## Client Library Support -You don't need to manually construct packets, just use the provided types -and send them via the device stream. + 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. -See: [API Reference](../api/overview.md) + You don't need to manually construct packets, just use the provided types + and send them via the device stream. -## (RAW) Streaming protocol + See: [API Reference](../api/overview.md) -The device stream is a bidirectional, raw TCP connection with variable-size packets. + ## (RAW) Streaming protocol -### Input State + The device stream is a bidirectional, raw TCP connection with variable-size packets. -- Variable-length packets: - - Header: Modifiers (1 byte), KeyCount (1 byte) - - Followed by KeyCount bytes of HID Usage IDs for currently pressed non-modifier keys + ### Input State -### LED Feedback + - Variable-length packets: + - Header: Modifiers (1 byte), KeyCount (1 byte) + - Followed by KeyCount bytes of HID Usage IDs for pressed non-modifier keys -- 1-byte packets: LEDs bitfield - - Bit 0: NumLock - - Bit 1: CapsLock - - Bit 2: ScrollLock + ### LED Feedback -See `/device/keyboard/inputstate.go` for details. + - 1-byte packets: LEDs bitfield + - Bit 0: NumLock + - Bit 1: CapsLock + - Bit 2: ScrollLock -## Reference + See `/device/keyboard/inputstate.go` for details. -### Modifiers + ## Reference -| Modifier | Hex Value | -| -------- | ----------- | -| LeftCtrl | 0x01 | -| LeftShift | 0x02 | -| LeftAlt | 0x04 | -| LeftGUI | 0x08 | -| RightCtrl | 0x10 | -| RightShift | 0x20 | -| RightAlt | 0x40 | -| RightGUI | 0x80 | + ### Modifiers -### Keycodes + | Modifier | Hex Value | + | -------- | ----------- | + | LeftCtrl | 0x01 | + | LeftShift | 0x02 | + | LeftAlt | 0x04 | + | LeftGUI | 0x08 | + | RightCtrl | 0x10 | + | RightShift | 0x20 | + | RightAlt | 0x40 | + | RightGUI | 0x80 | -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). + ### Keycodes -Helper functions for common operations are in `/device/keyboard/helpers.go`. + 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 615b669f..a1809c2a 100644 --- a/docs/devices/mouse.md +++ b/docs/devices/mouse.md @@ -3,38 +3,70 @@ A standard 5-button mouse with vertical and horizontal scroll wheels. Reports relative motion deltas. -Use `mouse` as the device type when adding a device via the API or client libraries. +=== "TCP API" -## Client Library Support + Use `mouse` as the device type when adding a device via the API or client libraries. -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. + ## Client Library Support -You don't need to manually construct packets, just use the provided types -and send them via the device stream. + 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. -See: [API Reference](../api/overview.md) + You don't need to manually construct packets, just use the provided types + and send them via the device stream. -## (RAW) Streaming protocol + See: [API Reference](../api/overview.md) -The device stream is a bidirectional, raw TCP connection with fixed-size packets. + ## (RAW) Streaming protocol -### Input State + The device stream is a bidirectional, raw TCP connection with fixed-size packets. -- 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 + ### Input State -Motion and wheel deltas are consumed after each report and reset; -buttons persist until changed. + - 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 -See `/device/mouse/inputstate.go` for details. + 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/xbox360.md b/docs/devices/xbox360.md index 3be2d0dd..8d46a0b2 100644 --- a/docs/devices/xbox360.md +++ b/docs/devices/xbox360.md @@ -3,86 +3,142 @@ The Xbox 360 virtual gamepad emulates an XInput-compatible controller that most operating systems and games understand out of the box. -Use `xbox360` 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/xbox360`), -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. - -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. - -For example: - -- `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` - -### Subtypes - -| 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 | - -See: [API Reference](../api/overview.md) - -## (RAW) Streaming protocol - -The device stream is a bidirectional, raw TCP connection with fixed-size packets. - -### 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 | +=== "TCP API" + + Use `xbox360` 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/xbox360`), + 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. + + 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. + + For example: + + - `{"type":"xbox360", "deviceSpecific": {"subType": 7}}` + + ### Subtypes + + | 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 | + + See: [API Reference](../api/overview.md) + + ## (RAW) Streaming protocol + + The device stream is a bidirectional, raw TCP connection with fixed-size packets. + + ### 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 7a9170dc..1a26120e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,248 +1,198 @@ -# Installation - -VIIPER _currently_ comes in a single flavor: - -- a standalone executable that exposes an API over TCP. -- There will eventually be a shared-library version (libVIIPER) that you can link against directly from your application. -For more information, see [FAQ](https://github.com/Alia5/VIIPER#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) - -## 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. - - **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" - - #### 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 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 - ``` - -## Installing VIIPER - -VIIPER does not require system-wide installation. -The `viiper` executable is completely self-contained (and fully portable without any dependencies, except USBIP) and can be: - -- Placed in any directory -- Shipped alongside your application -- Run directly without installation -- Bundled with your application's distribution - -This makes VIIPER ideal for embedding in applications or distributing as part of a software package. - -!!! 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 - -Regardless of portability, it can be convenient to have VIIPER start automatically on system boot, especially if end users want to use your application through a network or you want to enable that possibility. - -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're 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 - -**Version-Specific Installation:** - -The install scripts are version-aware based on where you download them from: - -- **Latest stable release:** - `curl -fsSL https://alia5.github.io/VIIPER/stable/install.sh | sh` - -- **Specific version (e.g., v0.2.2):** - `curl -fsSL https://alia5.github.io/VIIPER/0.2.2/install.sh | sh` - -- **Latest _pre_-release (development snapshot):** - `curl -fsSL https://alia5.github.io/VIIPER/main/install.sh | sh` - -## System Startup Configuration - -The `install` and `uninstall` commands configure automatic startup for the VIIPER binary. - -!!! info "What These Commands Do" - These commands **do not copy or move** the VIIPER binary. They configure your system to automatically run the binary from its **current location** when the system boots. - - Make sure the binary is in a permanent location before running `viiper install`! - -### `viiper install` - -Configures VIIPER to start automatically on system boot: - -```bash -viiper install -``` - -- **Windows**: - - Adds entry to Registry RunKey: `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\VIIPER` - - Value: `"" server` - - Kills any previous autorun instances - - Starts the server - -- **Linux**: - - Creates systemd service: `/etc/systemd/system/viiper.service` - - Service ExecStart points to current binary path - - Enables and starts the service - -### `viiper uninstall` - -Removes VIIPER from system startup and stops any running instance: - -```bash -viiper uninstall -``` - -- **Windows**: - - Removes Registry RunKey entry - - Kills any running autorun instances - -- **Linux**: - - Stops and disables the systemd service - - Removes `/etc/systemd/system/viiper.service` - -## Building from Source - -Building from source is only necessary if you need to modify VIIPER or target an unsupported platform. - -### Prerequisites - -- [Go](https://go.dev/) 1.26 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 + make 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/index.md b/docs/index.md index b1b993bf..1b116dab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,9 +7,10 @@ ## 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? @@ -21,12 +22,16 @@ These virtual devices are indistinguishable from real hardware to the operating - Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. - Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. -VIIPER _currently_ comes in a single flavor: +VIIPER comes in two distinct flavors: -- a self-contained, (no dependencies) portable, standalone executable. - providing a lightweight TCP based API for feeder application development. -- There will eventually be a library version (libVIIPER) that you can link against directly from your application. -For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) +- **VIIPER server** +a self-contained, (no dependencies) portable, standalone executable. + providing a lightweight TCP based API for feeder application development. +- **libVIIPER** +a single shared library that allows you to emulate devices using USBIP directly from within your application. + See [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. @@ -34,11 +39,11 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio ## 🥫 Feeder application development -VIIPER _currently_ comes in a single flavor: +You have two options for developing feeder applications that control the virtual devices created by VIIPER: -- a standalone executable that exposes an API over TCP. -- There will eventually be a shared-library version (libVIIPER) that you can link against directly from your application. -For more information, see [FAQ](#why-is-this-a-standalone-executable-that-i-have-to-interface-via-tcp-and-not-a-shared-object-library-in-itself) +- 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 @@ -81,14 +86,12 @@ See the [API documentation](api/overview) for details 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 is this a standalone executable that I have to interface via TCP, and not a (shared-object) library in itself +### 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 - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. -- **That said**, there **will be** a _libVIIPER_ that you can link against, eleminating multi-process and potential firewall issues. - Note that this **will require** your application to be licensed under the terms of the GPLv3 (or comptible license) ### Can I use VIIPER for gaming? diff --git a/docs/libviiper/overview.md b/docs/libviiper/overview.md new file mode 100644 index 00000000..827ca5df --- /dev/null +++ b/docs/libviiper/overview.md @@ -0,0 +1,124 @@ +# 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) +- [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/mkdocs.yml b/mkdocs.yml index 57195b93..0943d2f8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,32 +8,32 @@ 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: @@ -41,43 +41,49 @@ 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 - - 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 - - Devices: - - Xbox 360 Controller: devices/xbox360.md - - DualShock 4 Controller: devices/dualshock4.md - - Keyboard: devices/keyboard.md - - Mouse: devices/mouse.md - - Community & Support: misc/support.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 + - Keyboard: devices/keyboard.md + - Mouse: devices/mouse.md +- Devices: + - Xbox 360 Controller: devices/xbox360.md + - DualShock 4 Controller: devices/dualshock4.md + - Keyboard: devices/keyboard.md + - Mouse: devices/mouse.md +- Community & Support: misc/support.md +- Changelog: changelog/ From baa5a07b4f49d475b7a0bcb8c99d170a7437730f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:33:38 +0200 Subject: [PATCH 152/298] Windows: Update install script / USBIP-Driver Changelog(misc) --- .github/scripts/generate-changelog.sh | 2 - .github/workflows/generate-changelog.yml | 2 - scripts/install.ps1 | 112 +++++++++++------------ 3 files changed, 52 insertions(+), 64 deletions(-) diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 0e4fa372..1db04442 100644 --- a/.github/scripts/generate-changelog.sh +++ b/.github/scripts/generate-changelog.sh @@ -84,9 +84,7 @@ for commit_hash in "${COMMITS[@]}"; do fi done -# Write changelog { - echo "## Changelog for $VERSION_TITLE" echo "" echo "$CONTEXT_MSG" echo "" diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index f78acc0c..012dc28b 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -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/scripts/install.ps1 b/scripts/install.ps1 index 4c908717..1d871de0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -108,72 +108,64 @@ try { Write-Host "" Write-Host "Checking USBIP drivers..." -ForegroundColor Cyan - - $driverInstalled = Get-PnpDevice -Class USB -ErrorAction SilentlyContinue | - Where-Object { $_.FriendlyName -like '*usbip*' } - - $needsReboot = $false - - if (-not $driverInstalled) { - Write-Host "USBIP drivers not found. Installing..." -ForegroundColor Yellow - Write-Host "This requires administrator privileges." -ForegroundColor Yellow - - $driverUrl = "https://github.com/OSSign/vadimgrn--usbip-win2/releases/download/0.9.7.5-preview" - $driverFiles = @( - "usbip2_filter.cat", - "usbip2_filter.inf", - "usbip2_filter.sys", - "usbip2_ude.cat", - "usbip2_ude.inf", - "usbip2_ude.sys" - ) - - $driverDir = Join-Path $tempDir "usbip_drivers" - New-Item -ItemType Directory -Path $driverDir -Force | Out-Null - - foreach ($file in $driverFiles) { - Write-Host " Downloading $file..." -ForegroundColor Cyan - $fileUrl = "$driverUrl/$file" - $filePath = Join-Path $driverDir $file - try { - Invoke-WebRequest -Uri $fileUrl -OutFile $filePath -ErrorAction Stop - } - catch { - Write-Host " Warning: Failed to download $file - $($_.Exception.Message)" -ForegroundColor Yellow - } + + $usbipTargetVersion = [Version]"0.9.7.7" + $usbipInstalledVersion = $null + + $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -eq 'USBip' } | + Select-Object -First 1 + if (-not $usbipEntry) { + $usbipEntry = Get-ItemProperty "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -eq 'USBip' } | + 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 { } } - - $filterInf = Join-Path $driverDir "usbip2_filter.inf" - $udeInf = Join-Path $driverDir "usbip2_ude.inf" - - if ((Test-Path $filterInf) -and (Test-Path $udeInf)) { - Write-Host "Installing USBIP drivers (UAC prompt will appear)..." -ForegroundColor Yellow - - $installScript = @" -Set-Location '$driverDir' -pnputil.exe /add-driver usbip2_filter.inf /install -pnputil.exe /add-driver usbip2_ude.inf /install -"@ - - try { - Start-Process powershell -Verb RunAs -ArgumentList "-NoProfile", "-Command", $installScript -Wait - Write-Host "USBIP drivers installed 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/OSSign/vadimgrn--usbip-win2/releases" -ForegroundColor Yellow - } + } + + $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 "Warning: Could not download all required driver files" -ForegroundColor Yellow - Write-Host "Please install usbip-win2 manually from:" -ForegroundColor Yellow - Write-Host " https://github.com/OSSign/vadimgrn--usbip-win2/releases" -ForegroundColor Yellow + Write-Host "USBIP drivers outdated (installed: $usbipInstalledVersion, required: $usbipTargetVersion). Updating..." -ForegroundColor Yellow } } else { - Write-Host "USBIP drivers already installed" -ForegroundColor Green + 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) { From 1f6377ca24a767a92692a2955a30cb0faaf3bf28 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:44:26 +0200 Subject: [PATCH 153/298] Gofmt --- lib/viiper/bus.go | 146 ++++---- lib/viiper/dualshock4.go | 506 ++++++++++++++-------------- lib/viiper/keyboard.go | 632 +++++++++++++++++------------------ lib/viiper/mouse.go | 328 +++++++++--------- lib/viiper/postbuild/main.go | 128 +++---- lib/viiper/server.go | 274 +++++++-------- lib/viiper/xbox360.go | 474 +++++++++++++------------- 7 files changed, 1244 insertions(+), 1244 deletions(-) diff --git a/lib/viiper/bus.go b/lib/viiper/bus.go index 57f0d4f9..8bbd914b 100644 --- a/lib/viiper/bus.go +++ b/lib/viiper/bus.go @@ -1,73 +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 -} +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/dualshock4.go b/lib/viiper/dualshock4.go index ed33fe5a..1882c188 100644 --- a/lib/viiper/dualshock4.go +++ b/lib/viiper/dualshock4.go @@ -1,253 +1,253 @@ -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 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" - "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). -// -//export CreateDS4Device -func CreateDS4Device( - serverHandle C.USBServerHandle, - outDeviceHandle *C.DS4DeviceHandle, - 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 := 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 -} +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 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" + "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). +// +//export CreateDS4Device +func CreateDS4Device( + serverHandle C.USBServerHandle, + outDeviceHandle *C.DS4DeviceHandle, + 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 := 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 index 1a0d9674..349eff94 100644 --- a/lib/viiper/keyboard.go +++ b/lib/viiper/keyboard.go @@ -1,316 +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 -} +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 index 2629886a..67a8c3a1 100644 --- a/lib/viiper/mouse.go +++ b/lib/viiper/mouse.go @@ -1,164 +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 -} +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/postbuild/main.go b/lib/viiper/postbuild/main.go index f86ac4d4..281f535b 100644 --- a/lib/viiper/postbuild/main.go +++ b/lib/viiper/postbuild/main.go @@ -1,64 +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.Split(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) -} +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.Split(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) +} diff --git a/lib/viiper/server.go b/lib/viiper/server.go index e82c954b..0924794f 100644 --- a/lib/viiper/server.go +++ b/lib/viiper/server.go @@ -1,137 +1,137 @@ -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 - } - if writeBatchFlushInterval == 0 { - writeBatchFlushInterval = 1 * time.Millisecond - } - - 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 -} +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 + } + if writeBatchFlushInterval == 0 { + writeBatchFlushInterval = 1 * time.Millisecond + } + + 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/xbox360.go b/lib/viiper/xbox360.go index db0e6f3a..d7bd6df4 100644 --- a/lib/viiper/xbox360.go +++ b/lib/viiper/xbox360.go @@ -1,237 +1,237 @@ -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 - } - var deviceSpecific map[string]any - err = json.Unmarshal(str, &deviceSpecific) - if err != nil { - return false - } - opts.DeviceSpecific = deviceSpecific - } - 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 -} +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 + } + var deviceSpecific map[string]any + err = json.Unmarshal(str, &deviceSpecific) + if err != nil { + return false + } + opts.DeviceSpecific = deviceSpecific + } + 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 +} From feb16d49a776281b12103ae0a472c626acbe249c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 19:52:27 +0200 Subject: [PATCH 154/298] Update pipeline --- .github/workflows/build_base.yml | 134 ++++++++++++++++++++++- .github/workflows/clients_ci.yml | 36 +++--- .github/workflows/docs-deploy.yml | 4 +- .github/workflows/generate-changelog.yml | 2 +- .github/workflows/release.yml | 14 +-- .github/workflows/snapshots.yml | 8 +- 6 files changed, 160 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index 84eadcb0..b075f089 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -20,12 +20,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: stable cache: true @@ -50,7 +50,7 @@ jobs: run: go test -count=1 -v -coverpkg="./..." -coverprofile="coverage.txt" ./... - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} directory: . @@ -70,12 +70,12 @@ jobs: - { goos: windows, goarch: arm64, ext: ".exe" } steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: 1.26 cache: true @@ -145,8 +145,130 @@ jobs: - name: Upload artifact if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 + 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 }}${{ matrix.target.ext }} 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 + with: + go-version: 1.26 + cache: true + cache-dependency-path: | + go.sum + + - name: Build + env: + CGO_ENABLED: 1 + run: | + mkdir -p dist/libVIIPER + go build -buildmode=c-shared -trimpath -o dist/libVIIPER/libVIIPER.so ./lib/viiper + go run ./lib/viiper/postbuild + + - name: Package + run: cd dist/libVIIPER && zip libVIIPER-linux-amd64.zip libVIIPER.so libVIIPER.h + + - 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: 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 + with: + go-version: 1.26 + cache: true + cache-dependency-path: | + go.sum + + - name: Install mingw cross-compiler + run: sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools + + - name: Generate Windows version info + run: | + VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") + git fetch --tags --force || true + if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then + VERSION="v0.0.0-dev" + fi + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest + + VER_STRIPPED=${VERSION#v} + IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" + MAJOR=${PARTS[0]:-0} + MINOR=${PARTS[1]:-0} + PATCH=${PARTS[2]:-0} + BUILD=0 + if [ ${#PARTS[@]} -gt 3 ]; then + BUILD_STR="${PARTS[3]}" + if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then + BUILD=$BUILD_STR + fi + fi + if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then + MAJOR=0; MINOR=0; PATCH=0; BUILD=0; + fi + + jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ + --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ + '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | + .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.FileVersion.Build = ($build | tonumber) | + .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | + .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | + .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | + .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | + .StringFileInfo.FileVersion = $verStr | + .StringFileInfo.ProductVersion = $prodVer' \ + lib/viiper/versioninfo.json > libviiper.versioninfo.tmp.json + + goversioninfo -64 -o lib/viiper/resource.syso libviiper.versioninfo.tmp.json + + - name: Build + env: + CGO_ENABLED: 1 + GOOS: windows + GOARCH: amd64 + CC: x86_64-w64-mingw32-gcc + run: | + mkdir -p dist/libVIIPER + go build -buildmode=c-shared -trimpath -o dist/libVIIPER/libVIIPER.dll ./lib/viiper + cd dist/libVIIPER && gendef libVIIPER.dll && cd ../.. + GOOS= GOARCH= CGO_ENABLED=0 CC= go run ./lib/viiper/postbuild + + - name: Package + run: cd dist/libVIIPER && zip libVIIPER-windows-amd64.zip libVIIPER.dll libVIIPER.def libVIIPER.h + + - 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 index 7558ca0b..b216424b 100644 --- a/.github/workflows/clients_ci.yml +++ b/.github/workflows/clients_ci.yml @@ -30,10 +30,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: stable cache: true @@ -52,7 +52,7 @@ jobs: fi - name: Upload generated clients - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: generated-clients path: clients/ @@ -64,16 +64,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download generated clients - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: generated-clients path: clients/ - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" cache: "npm" @@ -106,7 +106,7 @@ jobs: - name: Upload TypeScript Client Library tarball if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: typescript-client-library${{ inputs.artifact_suffix }} path: clients/typescript/viiperclient-typescript-client-library${{ inputs.artifact_suffix }}.tgz @@ -118,16 +118,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download generated clients - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: generated-clients path: clients/ - name: Set up .NET SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: "8.0.x" @@ -142,7 +142,7 @@ jobs: - name: Upload C# Client Library nupkg if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: csharp-client-library-nupkg${{ inputs.artifact_suffix }} path: artifacts/nuget/*.nupkg @@ -154,16 +154,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download generated clients - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: generated-clients path: clients/ - name: Set up CMake - uses: jwlawson/actions-setup-cmake@v2 + uses: jwlawson/actions-setup-cmake@v2.2.0 with: cmake-version: "3.26.x" @@ -185,7 +185,7 @@ jobs: - name: Upload C++ Client Library headers if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: cpp-client-library-headers${{ inputs.artifact_suffix }} path: cpp-client-library-headers${{ inputs.artifact_suffix }}.zip @@ -197,10 +197,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download generated clients - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: generated-clients path: clients/ @@ -234,7 +234,7 @@ jobs: - name: Upload Rust Client Library crate if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v4 + 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 diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c2c33a22..a576bc01 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -26,7 +26,7 @@ jobs: git config user.email github-actions[bot]@users.noreply.github.com - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 012dc28b..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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79815fba..e0b92e15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,12 +39,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts @@ -80,20 +80,20 @@ jobs: ls -la release_files/ - name: Set up Node.js (for npm publish) - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" registry-url: "https://registry.npmjs.org/" - name: Set up .NET SDK (for NuGet publish) - uses: actions/setup-dotnet@v4 + 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) id: login - uses: NuGet/login@v1 + uses: NuGet/login@v1.2.0 with: user: ${{ secrets.NUGET_USER }} @@ -157,7 +157,7 @@ jobs: - name: Authenticate with crates.io (OIDC) id: crates_io_auth - uses: rust-lang/crates-io-auth-action@v1 + uses: rust-lang/crates-io-auth-action@v1.0.4 - name: Publish Rust client library to crates.io working-directory: release_files @@ -214,7 +214,7 @@ jobs: - name: Create Release id: create_release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.build_info.outputs.tag_name }} name: "VIIPER ${{ steps.build_info.outputs.version }}" diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 67c525c5..c7d795f9 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -15,7 +15,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -62,12 +62,12 @@ jobs: if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts @@ -104,7 +104,7 @@ jobs: echo "Version from git: $GIT_VERSION" - name: Update Dev Snapshot Release - uses: andelf/nightly-release@main + uses: andelf/nightly-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From c028a59f5b3071065a8112c6b2a6262180dc0c86 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 28 Apr 2026 20:34:27 +0200 Subject: [PATCH 155/298] Windows: Add Tray Icon Changelog(feat) --- go.mod | 2 + go.sum | 4 ++ internal/cmd/server.go | 5 ++ internal/tray/icon.ico | Bin 0 -> 68288 bytes internal/tray/tray.go | 5 ++ internal/tray/tray_windows.go | 117 ++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+) create mode 100644 internal/tray/icon.ico create mode 100644 internal/tray/tray.go create mode 100644 internal/tray/tray_windows.go diff --git a/go.mod b/go.mod index a79cf767..5e90da3f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Alia5/VIIPER go 1.26.2 require ( + fyne.io/systray v1.12.0 github.com/alecthomas/kong v1.15.0 github.com/alecthomas/kong-toml v0.4.0 github.com/alecthomas/kong-yaml v0.2.0 @@ -20,6 +21,7 @@ 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 diff --git a/go.sum b/go.sum index a8985e99..2bb9d868 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/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= @@ -15,6 +17,8 @@ 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= diff --git a/internal/cmd/server.go b/internal/cmd/server.go index be41127b..3375e660 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -36,6 +37,10 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { } func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go tray.Run(cancel) + s.UsbServerConfig.ConnectionTimeout = s.ConnectionTimeout s.ApiServerConfig.ConnectionTimeout = s.ConnectionTimeout s.UsbServerConfig.BusCleanupTimeout = s.ApiServerConfig.DeviceHandlerConnectTimeout diff --git a/internal/tray/icon.ico b/internal/tray/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9d7a418744b44abe034a20783e8a5531dd46b38a GIT binary patch literal 68288 zcmafZWl$Z#67Ind5a8hM?!h%c2=4Cg?yiU6?(Xgo+}$C#JHaiu>&w0O@2mH=YImx4 zdwQ#8x4Xajx&Z(P01N;G0{k0E0BKMFfEoY*z`^;S41@y!F8>cDsWt?2|;PC7=S3)pw=jVjhZU3ls+-l``Q| z7*dE-NMQnQhdL3!r zL#fYwNu*lTiVGipkrdPH^HO|fhUg@GiuvPcv*JVs%wCok4C^{)E<%iWMtV%ep_0OrCF((x2vL1Zel0+de1)e;=f^AA??g%d{Vi)7Ya$r3_T0#rG`4H17;lUDv(&vrWmB1^>y8^F&ht@|3e+gliq7;$1J`jBSMpY>1%#zf=x zh7)txytAt+z5W(VeQC}2MvU56!Uzq4EXtU_vqroHhQkpxIGv!@G1u5H5Jn3Pmvnf! zlT0UZaNHt{GOO3AwMV%p$HvpV26wE%oo@RJm{k^2m8Ha21Cuh zhqrbl>)2NMz+>2Re!RDLPYomz38j)8FrGcXEa*c30H2>HqftLXIzs<>EC3)WDkoAS zZ1D5{;+OwC9f0#+zua;E_5=XHPXEs@vy!}RFqScgFSGghI@lWI_jE67>NN4!&zAAv zq9Xqak@UqwHc~*7R$hsVNa3Mks#+=~H)Lu8ILm&#Rco~pMbkuh|Hzo_I(j`J@;!>%L9{AeA=xlKJ0>t#YF=eSmA4{u9_+uyARk%-WtY4+6dHg}VgdGl@OZbxPYhcdoRO3ltOhx9yno-B$`YYfl zcO43&u2?Be5S|Y6y$^yz*JhIorOT2Gn>Oq9R|T#bmU8*;(l|$)8Fq_{mq{Ed%MR4Y zbr&YS2UCbae?bd0K|^cH$0TtkICmS1S^tL{=Nmk!h6-oJjNE7|S)&-Okx~s|E_ol# z+i3t0Y{?U!B-ugU@_9lirY^2M57)z|w%E89nHSwOmSB;a-9?D$)KZTE`J^8yp`JGy zGQcMxZmS=Q^lJdxA^-oTyh}Y+O zj^29A+{rs)Nf~Q8nUY&yt#R!;a$Od`och>#iT1Jg}W zX;MmgzP0yPTwElwvx#l|d#TlPGJAp{PIGX#@06W(L@U1|ZlgV&mzDKyAlh61R8W>& zPOmYc%c10sA6cMAIAFIo@TYS8Rvx9^EkzH3D|jU*l$UGLGze(FGS2(AS^-*U%2t2f zri2w8FI5|Pz?>@m#=S9K?aXj{83j5xJlt!!cZq<@FWM9@GkESfl18rM{YUM)fa{-S z@gFOLV$!!-v;w)O{_3XdPj#rwn;Z2s)M#pi8M{aNfYp%b+8TkkYV%cz*dIsbdOsA5 zwKVU`gN;z3hLOMMa$>GBp9;C*c0MVX^JyPA!f-v_x9j^Kc)-U!uxf6uh$vW%ow_u^ z!}#GyP@3ZIoNW76Jq%sil|@Y|NHqhBSE?H^a-A%tDUmBGL?tKF%_z^BI7Dt>_1(nY zkYNC9g1HihRHf$gm$|(1cn4p~2MgO78@HgUF@7Z&G_VuD^A$j4%eS>4`KgYLFKU(l zqVT(MWJ9kIUBc_>eU^V64I{5f48`Y=_t=RY8EnR@|m!reP-EQjK6_=NdVES0W zm#cO}^_pmT*-a|{j|Y8Qf|GoBf$J%oD^-}IrsheHt(Jo@RYj$-&VJ(q-tel9ge=C1J zo?Ga54aSV-QPLkEQ&|-TP&}3ap6U2rb@NjeCeF&|-$W`mG6n~QZkA2~{H~x6Ps6*4 zRUZBElg{H2=B{{sFA`Y8x?$$lNZE3|qU18k!rZ?p4L(l#Ei2z4 zMB4_>Yz8UB#XXMCU@U|Ad~MzP+gx&#cU{)++PEbDM}be1>;qEv#@urQ7pd-cSIf&- zzIkJT)p0T!e?8;*mFLEwbS=TU6LUXKJS4jdmiBRn5az#lPY&3H02*M6VWA4C^h1x4 zFQtEPimq=Oha)jO7UTJoILvfGmRB=YqEV-5rPg+$(0ZSGnZ+t9IgAX)7l_nJe5?ie z>*B1aM*!jLQ%qc^rv|8al4@8Gy8Wf5(0(VR`aYj8X6t+Dp;z=_3Wr+OcMOVb6m{CEDppEq~P}_!Rg`4Z}{mMlW zJBR3uHGWfZq`9sX>ow}*$#X6M5S9EOfPe0TiZ*zC(7fyYc%@3pGmG-+jN=I*^n?d! z1tWPtlj#nnX#_$j#({Yum1cLPh9BYU`$o?IKd}LT7LF>U^77-~J^zsF=jR`0Rm|E) zW&i(A9whnqoaDcG(AmrD4gf%;`d=QbO7V73S@s>7ZmaF;aCdKF{%hqvbQ%Eiss@Z{ zfEj*45BKks>+e`9$!C;jFUg%1&3o`lCOVFU&r1@t{8(Fw)?P1yVyVk6g89NUi~}kG z=NwmyYDvEO^;~RuKMKghxjPWG#*92XPB(b>Y)`*uZ@*{%0LP;4S=ownJNDGn)T}RT z+A2=?lzUusQOct2f%+Ahcs(EXay}`35;v8(H*Lx-bNP%oEfY!o5ChX?$d3(s6vxSA zyt*fpVCAYyQPuZdzHMq6y1t!ELxQ)_E-ZUm4rgn9))zX&sfXH-Dd+@b<2m{b>{RQe zS*qhypNbup)>>?_RWLQb9H}KavEDrc7MfQ#UN5|JR(}OY5qsf=6`EVa;!jSXm&#jK zlpv93&9cMP{!~%*eYjgMG*T>B+^?5qDwgxr0-U>V5gESzeH1*FB6#MB1a_IeE+PF1 z{JgT=BS-mCq7qY$btJZtyu%<3S0ggG%Cd)*C?Xv6@jf524GAXIC>^6yqO68@3_i!N z{g8flqDi<|U7*@NoTKlxku_F(w3Qcd>dBLQ`&vlO!^HdTSrg!AjwsP!ZM*ouw;pWJ zDTH^|syG^|y;Qrny|t+6eR)B6WnTLhT3Qfr{m2}9jH$ZS+UoGmlP){yV+W|8t(Ss$ z`|f)Siw;fkc0nGPv}wjMv^T~u__c`qdSj;J(uhuM|7%gSV}4yA0Pxot3UI(sTRib) zerp#Z=p?2vR3Bwe_&bS&ur6}&kZ`=}%`;>~Xn{XvkhuP9)t?r~jWWngyc9Jufqs+0 z?*k>#g|~gjDKA#P+#iwcFH`K~osE6}yjKqBtJ6lJuxTW+X3F_sfny0oxJ0~po8yNo zN|AKmq0Ji8lAa=js^qwcUyjTnbwAu*8>V{{Ky+l~R$3nmd`GFyq7PAd#qt3jS)abl z!)Jm>BzQ+;=ktInH38IKS=>%SZ_=7q#QNrNvSs!?Zb_q%MqiB=ziBXV57V>e==%cy(X+KxVGW= zBavY!6!bAh(8O0%>Iv}U^)YAj*6=<3Rz~v5z#679Gs&E+^4P3t%9prfi7E0&8gE0X z$agB|x2#^b?6O874_1yu$c*~PN7#uP*+wB*gCN-&c5#o zo~*qnm^zL6-A`gix(e7&%L=Nhl1FPl3Rkq)3wXcvkSHul2Kt|g86Sm6-Hb{z1M9~2 zK8XQ+3vT>!wbq_Er!Fo5>O*Oji{J354J!7f71mR( zdzR}pTa(4CBM^al%(xbT55sjWK7C68361QkF8p^S!_zWlC*+|mB*ji}*y$PVFugk# zj(bZLgyqe1m-{m1Lv>`lB)CO~LPC zC2qhbg=wgH1u<_PJpG0l4{l8O$$nNwY`P_)X|iTMB)n1(FBCM;0u)+QT&w=oZsnrS z!niUiDu%z#yy0bEoe0;$Uh}BJk=MoAYO^WS@7|39LUQQ#IQ8IWgS902#tS?{0LD1< zI0h?dBHbbU2`&uES6e&qJBOE(NrI7GJYum%8qnuoD$-vbO@C=FwNVR&1WG}H#D!pj zbatYR80HI;M0J2uSp+)fOVrU0#MJ;}X@^loFLgF-cOm ztg1p$wIEg54?cv{;ZVoW0g(>ASBJHW*AUYqyN0BBH_1P}H2$`uyS{Ig-*uh2XorGj zohmlSh9bpSGRTG`e#RN0en)`&5i%okGSl)QgAT37X7r8^0F-ht?CzRM$ z@3zn%a^tr!leb^%wA)cL&r#r`<_&cW3t)tbbP)3>;N9x^h6mryV(Uq^|734phBR~* z7Eclq#laiV+m@jSR?Mj8uJh^~8&=rE1i9;FAvI5TbTqW*bGf>s*>&j5NAK_=K;Tom zbUIp}sI56^%4|a3=N8UHX%TKU{G=*tGA<=&O6X$~QN+`xy+HD3K!v^XNd`y9zOa|v zn0bZc@__#fW&f$OrB3iZb}1{E#DoPEGefAv?DQ_)OkUR2V0%Z%UipV_@{BFDp)ohGZCLdm7n_uv_A z0JN#fxR7i-HfjDc{WT?ZqrJZYUWN|sKzofJOCsKGDbvr9k23xBmP!;?+f$7n1~~7L z`q^2z5mbN5MPm4OoavCoVsWEk4H~q1-BG%wV1AO4cXe~nCP$=|mn-h2s&hmw{V{4M zDR5HkueO7nX(Tq?qj$L#6Y<7(2Vw1XqIIQEkyJ;fwQS@wM@My5V>JY2$lJA==egTy z%d2nyDg5+YQdO4yc)u0A=0oY0$RPPebo6|((~>h~Ss-EP#sxznDx67AN>xADew1T5 z;IL7A(|>pK=$F*{HLH)``m*i1oV-XBvsoi>{-rfR9{HLLTY>@ul~k;so;S8~b!LXM z%rc{-e(1Kop#R^xW%WGfWKWeaK5BqhIjprJc8cZ?(bdRUpdAn}6TWG&Jok~zJM;%@ z>mVjC4niK(({9*&tx$s|jH3ib0Z72y9QAQrTT8^$)l*`C{3(G}SG$AqTHO)&eu_dT zO9dv(Qiow3RCM}-7b~`hV7P~8eV+q~{r;H)xnAtE)mcq-_?#82|#tme`s*>?wo{?^*Vu1 z6=ZDi$#K@rT>oa22F<8xCZz4{@AGmEbe`indK&o`&6e^m+dH?1P306v)_xa|3W6_0ylzSkI?jM71opG*W zd<#)9t`Ub$3r*YgGpHVO>#xlLl49 z%-G;p6>eD}6al1PL+~rVj%;;M3axzg-FrdaP)Sy;{{wF8U{r;dV-4EG9{_9ZtDDIS zjnB{7Xf69`7@2M@tc9W1s0FF4R5%nL+$6J+DTxq*JRwn&!Zfo?fq<&hRSHx8=QTth zo5MkIqC-_zti4n|y~s*NBpKu0G7r5BF3d zB~HBt?a0ASmF51+ltpM{u#WHz%l0bIPFg^n$XQuGI7#^On~kC$I>_CFp~D}4_i9-I zGZ&o1A$!{+fVbUA4v}MFfF&=5bkB~lvi@scJWlN=Ch$+H;2Sz%w7&3@TT#1~zSFm& zkGGYtUse|PsXUxUso4B^W#fq$;ZtGJR!l53_6H!-Z!Z>FGm%TG?z#8VwZ1aZ5O`+{ z==B2_h(WEP1RdYW2Q<}1u;84U6sZ)gyxm0OuTdj^FrHX*d9W0{Lks=FCvAlec%i_~ z_PiXn|HS_fyuKsKCAp{ zPA4)mY|bg}DE0QbeDv*j)vf3@Kh*P?i3}g}9d-XWEv-DQzO7U~%G%};4eU{77rj?y z&#!?6Fa)rF!^e$E$dGA77j71o2rcX&*M0FU2j#@J5=(e+i&9w)3H&|JC0J6LuHZ(qtEnggzJ?#}E`JS~V;|g13yB zPRlcsi!Fz8Mq5f>R);6O+FxK{v>f9pbh%M()?eq(3#j?^?6~~b+RAo9on6em5#}Ji z&&KW-8%Zn*+|9tIBxWFT1ly`bWuXXnGjoM~S?V)6oT{Y@d*5Wng)m<9{LI{1?z73! zrW4i8LNKI*9b)DeMVkEg3ZiPRZ~1cadAF}7CK*^@R%{cw_>Crkk%XSZ_@1e zQ4!&U_V_0STBUfW@{C#AYb`z}1(D`*wR{}DCoSxwhu0DxOwNA4bP;Je5u()uMKA`9 z=4|4WAx13OOjH(MiN|IRE=^34wV;!h`$6 zVwiyZOq%DdV8p|cajD3|kCWh1D;*dZ_R{4*aX%M0dCFl7FwK{*F+zchB3~iu$vWg* zAEf-fj(j14gm;6X|1I7OHfNXW`&~(WZ{|8iP-EJhRhaRW!|K`ehg)NNeIu%{1ux&@ zvE=rLY0K7Gkk~rVv=P%m7^eu$kuT0kV(4l~r);nHpQR6(sOzc0DMITz4otMeSs#)E zIWDdRF{8S)n;G)6P|RdN%zWjEO2+I-6II$#DXQv{F#wjWiWY9tB|;|ZiMjD+hn}r& zr0~dMIepxmGn(=tD+h@R>a&LH=fKEEzXH&csuTnX4Ggfm!P|Q1f|Ik|ZgNioB|w6_ zAbK|yV0*C0vi~3=?Ywz{k4%>gh|&3cmo=Dr&Xdq4RfOF=FLOEh`xaG_TQZS!5C93- z^=(2OwWaP8%{|;`;VQ`$yWWF&mn=N!t4xTfe}w5#KCrNN5izWgCCJ+>d z9RzMi1v8!a!|a^vOs#uB1za5^^+lvd9HI{Eo*VKM(sC_evdi6J>-X4Pdvw&UyDVq7 zh+#9QX_qb5n?@9b=)K-{1PZ<`+8;^QM*$&TAxLT?X`ZQY@F)BZ^nzuzNrRR_kaB=v zeX1Zh@=t9*ZtW~KrydOOjm5-OwXvlj(lK>T%Uv-#15uB#~NI4tL@>8jgZ z+jW$&23Z}!f-%T&pS^DPo1H_B&6&5_ROychEBfbHEfL(M?Pn`7q(c}eAq3bAZ`?hf zc#?F>N!+aK-$HoO@IJe&&rb*KBlrmiNV0MbK0E;aq=z7J20%2e)z`DgN?Q6jv2*cS zDI2rUa}}Bj$%(icZRe#hS^k0qn(A7*;*1Q81*?s{tvR2W`wYRZ9zIXZ&lT8b8S2_` zw4S85i6l@8je<;FPbQ_+QuoeRsK-#YJ`{j19~TpmJ?&+Wa`ZcZTVueVN6<)qeduzw znGRD!hSa|T>djH{6n%}PQd9^j;41%!i|UTinY?P!ROW(@q!{J$&q={4uBN|!Kuy{1 z!pOIgt0fe-3+t*%L!;{=w4AVnYYB`HGISRjjHS_fofLfO!}#L<7?;EAn@5DTE&C^;DJ%c}hoE&xRJAig;rbThC}i~v)d(3GJ=F8} z?baHz!(!z`_|f9nk0J9;3r#i>5efi>65n=QexZyAJ6j?kZZ>t<&1y}c?0NOw-91AV zRzc}pRIf$ZzA3Hxi|l%0c~s9!{UWRGTpW}SzqLjC3OueXy6qp{UG2{u*2!E!9B+SZ zWT~eV=J`ZsZScQfp`Z8np;+`$a3=N+3vGKnUzV6>441jP0vAn4GX05L%|Y!hceQfA z9*^X}Rf<)vcs-T`7=Q^dfZ?4Gh(m|?nno=@N;5*{$u?5l<2&DkIPGo#D`!9KBUL*5 zbJNPDihbbptF`0Ba$EvW((3xuWT#4#PO}Kt-8>xfiNU+AZ^G^T*Mr?27sSY7>=5RUVkIm94usze0sq#wWo3{}}2i_5#u>&;kp z1$)UrnT5zi)UrdTaGggM#cg)3NY8@=A8uZXSQJcW3D68GB)ACJNsRM%?Dt3Y0e}piHRFpx8-r8w_fZQAvFT`G%+STEUL(@{ z-RpeZVVG@;LPR=dSRhM!3^|(GP5N0XGtc`3oWI7#*6N=BeRXyDM_+s=!^E$qVoe+Z zB4g4?6L7(J++r>yEFzik90ZBNO?X%@d!-i#j%)643eFOU^-vT(*`83{jF|X z$9z+~9c9Cjk6@BADP3p`g}(5csTJ>5s4C2S)*0&$e%ssp#$Z>-p}7esb!ut@|IDut}BN&ku-3sPgo$vl)b z-93)!!l6#U1Mc)d00fG7)O7=Enq$(KGPLnMFBJmlj@DHKTs7n<=f)k>>$LkXzo0bmJ5n;&@pC-So9c_4?xqcID-P2Y^mBwh; zw72o}H(Gy)9l;I3<-kRnd(idPwpp%z`4Xv_#lHm~?62J+jY*lE-+hyopAP}6j|)or zwM`kx_JT{OPVo|xCguNPpz^*MBAgD^ifHn%Uq7s1Q|1xq; zX2d&I=_Se#pVeY}i_sU=S)>z4W0wod#X7IzjG2el#StZ-&yw=6vS#7mh}IM4Le98X z?c`r>fIQEFsTp69i6ntWZh6h^{R3a4yqE4m=CswS9jdzR+8S=}a6iqvi2`3c1SvA+ z5E2C3(0){Vvcx-~zH%%_zJ$Dl*vQK(;Nojh3=Q~N(Qz7CXqpTm7%-;(4li~0|6&Q) zG5QHV9!#aNG)7o>7os+3P`fuf5%-h7>Yj;=+*B^XT1@Dayj<^w}d=Ar5RO@;vrCx{j%luWi4c3dmHiPHQ!~ zhLo$FxAK2~68i{dBwnNSmONSs*+$Ki0G0JZKV8}V3>SF2i41e}=(zn;-{dvdOh{s`emg?IckV@EWi&v#mQ{G0KfHjdJITT zJW&+Pto=DU{RZ`c>Tz39NeJ83Er-$Y&E9sCNYDA~KJogS>t2RG;m57CJcqp9&EY!n zd7)**hQnli1A|gHWkNR+zCK2-@Ac$!&{EXQkSl7kd=X9LEh~_8VQuwW@Z*~Us{hU4 z)=WzFWL$}@2%6x~7;vI}yddPTZ33PY{RHYVm;{O4$snl2MU+FZPS@2{>zvTl5`jL_ zqB1c7+7U2{76+3v93PL9h^*)0^w}(7>-u~?XJr4j*A;4X-=u2#Wgw0CX~=%xe*11x zGIZgf$Oi@5hPmyZrk{^61^L<#$mFi2?4g>8R&B< zts+VC-uuwoh?=(P31JUV7P$NJMCz1!3_pP|0TIeVj@u}Gsf+IW4D)2{81kF^K3aPAdJjzzH3-ax1%p`S--!JEiykF~8^X|S* z;6&4*?X6X34+oD0)gFWAL1X!kkNJP!yXs~0Qh%9*8I?f7U`r)otrg89*Jn>SP)q$jOh8rq9bFZH|oHGuxbc*Casy# z$J>i5vm;1&LV3E|$L0EGn|LI`rgM(pE;h`+_9U<0TpR4Wt9SfR5MOPG2+ltT&M9^B zCp3TEy>RG_rgxBF?E0(!HM zasx_pd2_He^d8*t^1FaSGAfW^2&6^fX>kEl2!9c^Z<>qM{iebfY??bh`N+M5Vl96)T2>U{LxgcP%HzI#45dO}HKfGSkJ1o3w9+mS zh=NaH6&GCntfgm?`?#qqJ@LMLc`$EM#k}=|} zRD_EC++LkdyE`s5zK(G(y*~(|d}}*7h@4)uj&Q8U3%D9fxK%v39ozp_BGYWhQ1Vwu z^@~2vLb{L06i?jhY^cqa78+sp*G2|80MNj1M!e&)H9EHY)eXXb|9DThY;Telpkuhl zd(>j5FXU~7wA!sGCOcBtIJp>y82vO@NW3VMtCo{HtCG6n{%ewtpH=Nny=fk|J#RRT zWNgMKY7tZ6`#=yR!}mM_h$q${@GBXPa9oFx;HbG}?=L!5KK?ZMs9Da~->Mu_U!lg8@7>XxzdhbSkPS&tl+Slu0L4CyyoI$H1w09%(bXNC@Zb^pkM6xyuzjS| z6=qPJ@=;BG)(~9$Dhs0dGo%T9<1Cf7g1S;>VL(ABdv+ke%A%oow%aMbU~pJAX&%>< ze{1lF%U~RGt3>_V=f6_3%f#W_&ojF_YIowVmJwC}At1uYqf+m=Qc?i=Jtk!^ZR~ow zfX9fN3o0xh1{Nl;zWXAoTnCzxW^u?_c^M_kzIuqPot;|Xl+x1 zJwfD6#_^Cz&Hl1FVMPKsUob3_%EdS(jwc^@m}|0jylRUMx;&q2yLj^B?y}D0Tf0AD z03n2%L)f%z``}CZtPTGWx)i?j6?_lYPf$7=;)!jTr4 zvj(AMu*`0q+GR=3tgZ2WKVniazO<8zgxEf<-9u4k{sjA}Dyv;AEXol#>UAf5*Me2d z8qO1;_V{G}@TH1Z#T)e`bWuI$`kgamBH-79TROLB)JT1|EiluFsc^@29FcB3uFxU7 zcOAC%N_2PI9dAjO>k5b>FZaDHTB%6M%ZlJ}@Zg4Gl*6mXBbN-KZd7HX7{ukKdE!WK zCa25AN&#!wAD#iYz0tYt_o4y)9h#*bX+r&U*F{MGD%S-15Gn^%^W3}Gl2n}93VEnO zd_lB;m;RC_V83e+07eL0`g?T7b0WcX{4c z>-r;~nl9xKXV1xwe6|z<{}YwOGn#+sjXtQl^cf8yz6;dP+_^0a3BVu>)o4N$jzNUj zwsB}KDw3@s1@`HJgpv5QkBw!HUZyejY~*SytALqDlNh+6ZG3IU+DWd?Dt+U{bl}yk zCq8?f6oFsz)+aVQlUUD-buTp)XOfQGaHXb8X_bew?D6%mVs+eZ`D(>8MhPL0AA zB&o*p0jh9srEuQf!*oD4Um3GiHyYXv2y6gLLLvwBfUCGdCEak`T6%!N8uZM(fCX(J z0ji+EUjUGt2;aB;bJl_Xh|>RX(WU&HZ4?u)sQ|JaYdwW`A;_|3^FZzp@5t4Di>-n z5@H8Z!cSsl{5Sn9*aw4ve5_pa#q!kc$?y(gY z<^_MO480*s(L!RB3Uy$2Y=;v~69ODb41>Ii Rm4Vh*c)NZh%BrKeEw{YT{JKCYfgOC-G)X6c%@6c z1vZq{nK8Zov*>t84^GnwR4eR)1=Wg!Z$a^yw%FrQzuoqpu?Zry>Xb^uqICg)j5aiR zr?v-_u8#`S{cLx&0*2|6W(L$Qs)(FRkC#lo!}@KYIkI^bxz+1VL)WbQ{cUF2NZz6;6(Q=eqQK1@0g4Dy7TPRfF9q9ZR=q$25S zL=2}GD~U(pYC-CL>Zbz*!Fwj3oDiO%ycqyR6fr_VI%`tQ%xY`o9GcjV$a|y4f19I% zdb8z}|2j9kd?Cxi%rZpC(%!%>fT?%a@Xd^)_4oB5TTM%Fv$9eI5fo{6FTcHgCovdcDz5`-`V8+8+;ZG|95Mvzifv(m4fl z;f8W?`(fC!E3@@=@crM_#W-$nq)=JhP91Akx9ql;&Co(@i-89GSZyY?BfEB$>&$nA z7-I(tkj6oHUFz`(g(~LQOa@Z2F1?~*o2OMpYPyzFN^*TLM2}oD^&^h!g#& zVq%pjA6Ee~WBeIk+p@#j38>T|ges4Yb6NY-`%lUMX`D@We4{(0&|kXnrkyWZCu%FA zI?f|RN|dHh+#)Ky9b$d;;SxqV_BDbNbYi}H5An(FI&33|sKD`s0y}k*>a;rEerlNd zSoJ;-Lug@!hkTorzCrNF=DpW2O`t5Veb4z6-<>gt=pndKCoFk6if#G?mI{_8Ln@SF zm^(#%Vw2ebU6Csmmx{FQ*G)BA`W~C*zqrLzI{%t$Zdn8*sIaToOEQhbnGl`cKM)e! z6Im@Kjx<4|{_yLTF)v%F?P17{jgZXriUL4S<*i$4=%v+swQ!CRWpYwjJly9dTQ0t4xqpy_;4IuPefz3qU(GAD zgDD-`(%G*dpXhpKjgWK@XN@khuV#j&a*|@NHNnkfU=9YD$kmpAt)i1s!*OA&afJTS zsP)*q<@mZw`$*`Pc8W&k2#YF*2$Ltv*oBSihf7B@a@8iwmtfb;!M5PnwSR-0-g0S= z#TdikT$e!1l(5cY<9CFMjTU@Ie)T=dI%dnExJp6-$toTcFo^5Q7iEo&afN3(XJg{f z+??e8{uBy;gH3;wiW$LsWhW+1WTu28qXvp#ohkLVM}yK)0rCi zKwhNzLrBfvlx(E(ebgJd{hh}?k6^6D4A{EIyj;i~qrI2bGKFho%A5$BD-~jPoON!G&oIo+TPVYk!8um-nttlve;ebhY{`N3SZt{Z$=qzk=ngm_g%jFr(4isPB+mcR|LgNomaz=Y`WWd&*KH}6iyk5g+gRF;TqpH9D% zIhO%XY!z*H8})?sYEeL;ft9Rd3)lJXuJT5f*k#M%cc7cEqM1zVqx#^WiABVrebdEC zJOYLDM}41_sLJlM4E}BTJ`N}G~oD-1wB%eDzg(Mo0<|*R0cTJ$Y#Pz9G>jH!4V2GVp-y>EzptG+JR^<%eJ4maUt(R zf;1*DR}(X=j4R7Mr(aEVkzSLLMSN|h6d1G>qoO{~6x&hU{QYZFG!An&jdwfaumQ=) zkXWsLZkcwYNv7+_gGU_2L6LVQ=vpgSppSF~){S72O^o{+Pca7gU&JySUa>D}wvNDF zFn{0AXa7>yMIBjSYvJsQ*v3C`q(!xAS$oa?cPiP`QzJf&k}a6**l5p%WY6O=KZWk5 zeFST7^k9K}MBU;L+gwIX4Vbr-!eqnR)5GI1b)igDpT@xVm#25dZS!px9NdL_01y(j zu~9oOPR;mza6ptxpe(mqG(&xkaUN*f@RzUuWQ?`}sZ5$)6Bx!<>h)M2dB|n<{?vC@ zuIV-$&;QfZJUP%KE}!+*idorOH*4#7 zH|m-Uo3M7ju^r>+Bl}ygW!2>}qG} z->FYcy#0m>7nkK4fi*w!EWIo|1d?yMg@tKWB6qL5^h!#+&P2X4({V8Vw}=I{$6dNq zASk#V2IbtZGmE>HtN6@!#B{=W#(rv1(%a)h#W_&Wg%M9h?-fbERio;DH9%-Kck59h z5%6+t+=gGvR#f#1sM<-wnoEqDeo-VKw5U(%`9sl{oqnGf8rlRCES{_wMp6FQE2|H` zVtHlQqR60eDi&L^4J^ej4f=4ZHy3C0T+JYYC z!p%svh@L1U)aDW^g_@0|a>;2#>>+)R=Z7tyOF}9A_kT+y=mU}{#_4r@?>o%r|M^a0Vhy0Nd?*sNZBtp<^??8330c+YW=&xk$ycFE zxw(T%MpZKfnb*yqmCS_$CauXCp0vR+eaKa&I6E#HEV7^MoeW=I&&p&7Z@kf{7SQ0S zAKc+s8Urma0HO&n6f9+PqiZ;vIPIJplF83-gd5 z0x`|9`WH(iQWhjOS%2(6#Y-AtwDy~&ebQ+IC1bnAi?l|CmTwDmFN^!m`E zPChn{PI`-j=L;o0-c%smHvwN;jL1XR<6ThL$IXEUNR`1qqll5FZuAapgHN zF#eMw9d1G>KaMB+n*Wc2F?qX8+S z^Jc%oa;um4=|(oxnJ+9es>a_A0SnZ2f}RzP}JnzBjJZ;qQS0$P2zt zjC`_890ugceK#JnXMYikk~$W*ZH(ZTL%DCt!9L%>ukCO;n)Y*$PtvRXw^=CvF;KWD zO3$o=7ZV&*kMU!mv23Vg_NHBG^@NtR0CggidCg=(K`4yjy236ct0Po8r*tAollr3V47 z3GVN>P+2Rd)zk1j6q3?Ak^us=PR#;S?p3xKV@xqvYg0uFaXomMeC`Scks=jwPh?Iz z5Qnuf8@TV*HOxlc^p>J>S!2Mdp=Dv@@c5lL;Rnc`$SvB5~HmV=Lv>+JEt z{XFlPobT3&ZJo^q41-P= z)o)L#wMzNGc^mJ2(l@L1DPWBl~sF=1HI^=9!J>G0yHgaPJlq6qwPo^_5W++BXC z-nmv|Z<)IG;?Nmej1(LICfK>>9F>bqBq$VfT62ou$0ZC36B4dA6vW#TPg_a@M$rp@ z@v(#a1r>ma6Cu<~8i#B)s!4;Mu6Wfr%9NSeDMv&htWw@3LtF`^*Jko2KoK6Tw4Ngw zm`ec~5{>y7mf-Kz9F)|AG5{~j-4Hrpf1K zbhcL@sxz9J{)Xy0|eTg>8VyPOqt^1QVh}D4`z%4(9UU7e^u$tA_Zx zx@kiOdX}GCr7R^sQugJXGEl}214LXjebo~5#SFF==M1ppgf}NBk6d3EaOyh3pWh+k za>BoSG{hzv{X;g`7b0NQ#C3!m|2jSuvD`j)I%|^_uI;yr1~(_3VHrKh!+j@gLW52+ z3t@ROJXGyU+ysKF;ROr!slX;}1_I;n-~LdMk2y_uisBdkl}qi}?j*IOPEBiKUCCSz zJ#S8(&_yD3#+HM-mO>XBa;-1JflppDFJ_u z0QdRG4q40BX`Y1zZ-#df?sx8MEFmjJ0#G$r5*FN^_bc3yp^Uj_*~Xqa_SfLNs{vvg zficf@xX}I)qm_srDl3CVGxFab34t{)9Xd>ZhnBaDUUnA!zmV-Vj&X1gD}BD!3a09M z+%LZ|!b^Wzr6098I$D0OMo~Fi`l&PztC#vIZ0aJ6xhjSbUlf<@qi|Q!XJMbg|FY_F z_pL#mgQVcHJ>2?8I506vUL5vwbqszRX687N*(}P}(@FacyGNyVY8)BQ%6fBChj~?3 zaY`)LAawAi=5`0SKC zESY<)H@1B4ugo~@HtB zY!{GE4AGiiuQBnN$+Pc!uNO6A(-Rlrb7x}JYMK29Zm*0?2qZqL^+?gb+i!uT->5er z4G5sCZmgxHezgA|0MI}$zrG5CmOPKCZ68YF;kUUVv4W}fA-x2BklR*+B>A0l-Ibr; z{lTZ(^34|8#M|>wPk(a$@=Zw(+;hkFZ{KxmAtHwK=l{+(Z(FC)sJHz1=fC%>%tHfR z8zf+5oG~$At$``kt7T2pesI|qOq@U3=VS>PJaUS~5R(L4OcKbl+yPEomu%@XlHFVy zD*5S5kPNQ^Pzxa_5a1h#7?qKpV!r81SNO@cX4AEuE$y9Ihp(YpZ%<>((cCY>>CZ7c zqs$t@fZBBmqcP}KoOjsTOfn_9_LYxK4~XN;TvFDI9(D52A-{pVpeVon$=^P_tfl&6 z_{C4YTgDiZCLP9K{O+lBS*07b|9So0G9n7~CS-TscEiT^UwyWW=Q*$6BMlrpS27Qj z1-&RWPwl z?TY%z=Qv$X?VaEG$+;P&8?=A;%RjffT<+#z{V!Wwa&AlYC(TKT@qUB&h!WU3^?c@g z(?`WlMAjjIQnm~NIvxw~AHBtIFW^xBereGd0zkmz%XlsT^}&8e0%(?dX`;ofs?qIq|bJk=18( z)NC66xvzhqbMif$vUk@^azr?DIiU(b8()+#b$+XejdO%LUvhbpoNcvZ=08i!YDUBv zz3yb&klzBA!4PH9Xw<>71LMjirRTI(f9$#I_M3v{!K>J{cm9<({C^vg3sxs@y84#Z zXa4odwzh+ZDqS8=JIAr;cVGVd(3H3CVI?rqSMGNs#`0KV)PIh^i^XXDr;w`4E%k0(uUYHpKPEIC{a z?Vm5bK6%HZZn}F%IP*E7761=S8_>wHpl)uGAdbjhx5f7}M#a{IN(ed`0_s8%{r1;a zFJBT?m~4Ekamgod{X2W}E6XC=YRg>rEXfxWkT9U*nE-7a*A}X0C>RgIV*-9h5V}1(Kp~iH~ zS?olFKeFptHF2m6FRo<{*TkH%&J8ZRKK;LpwL| z%CR>&5luYHcYge<053-bx*HOT{{Vvx_K79{$jV4{zV^-sJA3;EK63~{`ums@V1%=p z6FLQO4Rb?d0WFj84)moE9G)lp!84tEum@3R2$`OW5IYsIiCeY#CGGms<)Sh+A;7iS z;&6y__XJ^KcfM}FlHacmiIN7dfM+wcCtgn}U=H@U^%&&}gTIGs7c zYXA*i02uIVk|Y(0;-RT$awW*P8~N}X zUAo~L-=3!0G5ek-y?CWMdH#e4U|mDgJioXhg2yAlKM~;MECOPHhn+AqWSZK>cwPYj zBZwH_%F0E>l<9Jd&Si}Gz47HBar3|Zx?Dlsdyf~!=%|G+KljA;sqN@>XA13ml_blps8FxuvP&w)rbeSZ zYh|}R;QS*yiBP?_KK*|m_}Qk3ep2(xxUAxUH#s84Bg2~l`XQ1?mcBk|jUd8V&dIO< z0$fimTg#GyoykQTEK}RG*?Amdh%k6w6?DGudvpH&&;MALS#nOnCD+~Fw7s%=t1QbW zB=O~j)yvPl`mzhFr?xXmNEF*@5@kuOWh(7@F1utq5y?_l<4Rc+YA2tIBu_lYL|aP! z7ET{?E^>+`V~I*N8Q*FSq#%DgfzDrPJ!84-~sW_C<>eP+`gKakT&P2{po4 zArOixUOLPa%8*GrB$s`+<(+4jPi`xJ@1^a9c{v3iZLMhd{zH%E9qj53x~njiqWs*0 zmt45fbpD3bQr^7mXq{H0GYbdR$*{xL_T=Xga_K&RW-GZ zu?>wa*@7s9T{+kXCT5j0L(;mjAU$($wLEZW9pWTkji{JvE-q6hS@MD;h$MOeL=j2! zD9Adr07;IX+NV!mhpCRA;ztxA!WbtfEqHRtxjfGT;?VP-`DX*CR{O>BxBdK&jpX&t zCuB{A(iK7T2985A4UYcz3BMeFx8Cr@oxgtgd*fM%q|QU8>K{DvJJse7%Vx|9d{T&t ztLBpD6(B+s$#B5fDRQ67ZY{c%>J4r2xEAMumz4rceSh6*(=#)Zc!= zcy~l3I!txShysWZjFVLugz3^sI}p(?4S4L8SC%JUcfDUG-JX};EO$1wY+$y zGuGB0-TZtP+07IEDisQ^sd0xB(LbNQ%jItQ>vR9t_t+DEUlNe<_xM2Je%_?e9OSc# z1!UZ`bqj)COVlT|>`MOZci7`F)rL%-8sY5bWR(UZl_A+TzxT_p=WK3Hi@);fzR3kX zm`3fg*EYnf64M4bt!`%XUGUb5gWVG`^pY%d>sBqT%1lr7OFO1e@Nuh_FV6bkJ8zM0 zx#61HqJnuXk|h=G4I05i% za=YCfrk?(R`oM|LJN6dP&)fO#D(%8J5=Q&ZQ)jHziV=Z~vnWLo( zifk*FE|eCQ6h_6z#uNa+gk@o~+xO=zIxpl7Mz|278yIr|p$tSkkgP?ZpbCp*s&({8 zD~@;LXC5atAAlgyuZYMm5C~;21YO!U!Q2uDP3faq8m8T=7u;-o47Z{Pk#B6wkHyKTqzm7f0o zit*o(F{8_^&!;$%>=Wq`PV5oQrV5+EEdqC((6l>3RG4H;D1!CVF1 z-2n+vB1D*uldb@K=u#Ixf(*VtwcU8Zri$d-zCM-;oB*WZ532iRyDK%EK1r4bM&Ec^ z3zFcs#aF%M^~$V_w3DU;2*8?CdJboIx&o>p$wc=%O?AIIIg&*<8#(zo0Rg~4gOB*$ zfK%JeAAh*U*?eFu&prZhhE+vj&uNjdMBUm;1A=f?Z~Eer1Fh{r(}14{5Fq;Ad%h7M z*O6lnKuSSKPsa!m=9-h`2jGw0E-0gt>H**;oKgYPQoc3C`1wm3m_m`n)XH@4koAD< zbcZ!aIK3f580>8zr*)$8kSxpGGyi%eW7KTjdG7j^#4#4KO7C&NyK~px?f3rZmy0|e zZ(vFL)$O2l(7!inINm;#KWO)GhVA-ij8J2Myu^8fT_5gALBCAoF*t)1B@ z%VFg+B$7>|Z~avdUT?raXN<{TyZ)N0d+xl2Pf1Q#2mmt{9yuD^Zg1j=uv$IpgNMXFT!! zw_CQ=uM4+Z$z|Vde&^X`IL-4$N9pWWT(YV0iqBu*T39kaCdL?@d88KqY;oG1&aRfW z&cP2p-G;Bcu{pQnU{_e{7Ly@@%?_{l#Ypmw2*;CCO9A)>dh&w#3XWAFVt{|XcG04J z8TWlZ9|5$seH~S%82|{+!HoTWWHmJ2u8BYcy(! z=NQN`pxfnPqZSM6>FZZ?9_rP#9Xy!OKQNpyn|n+L5$%&C@ruZPeuU%2DW?E@Xi`e9 zVhmrMx*p&|w_?SfjC;O2p8%MpqM^mxJ2GR*uyH(H2R?CBeQ^Gq3p`5}l@_<$_{Gb6 zEMCPmZ(^&SXwW1ps5~0j2jb#9szG zrdzUXZ~DF8%k$aE=56&RVbD?-Zolq>OdP;J!n~XU zL=3PJB$;~GeC`^lz4K5&5>yz=2uy!;!svaGz5WPu$tkMuW9p;bzy%c3RU^|X6CcpW@U$(DWzNk}Toazby4kr&;_$(rPHP}JaYArdx`E9czCT`xy$9t@j#SvB=K_9Z2bQZI#u{S6z0Ijpw+KZ{)d#=efB5x#fCmx2dnOzOgxI5)?$_5pkV@ z@c&arB}FUL@%shmU}VcM!mKzQ6aXAn0t4?Tq7r@6_J9RE1VDS`&TMyQXM6k=7n@ui z{aN9fHi)QErRApaNttDyuI2 z|3VF)kg*nl!ipUjhX}0h_PQG9-m5oHhoPG<#h1g1?b{XQc61QA0HI?kBTreA-VnN#a#ofD8`xyxC* z-Q;emSsjkw0LO7sc4lf%UQYH%Qc}DpHrgOC4#Dm5;^5GTvb(2O*V5LRI5aXEHyb%J z(^7h$eC*+t)yo&J@)>Y?wA z&M%m^a=PtEr_0s+yCMR?aUP}e?Z!k+`$K0vIDbU8AGehXCl?h!}Erg8)cfv|_twXk>e3 z_5P>+@nZDW?Nx{t7p5u zFwWA&CG9s{dzoqDx>bs_)TGQ~@gN+>Y_{$D=S#0zpZmWzay!GUgBS{;v4W#JiReQ@ z{J>?Z-3< zEjK@xoT&=nFsVgxT2wrU0C!BcNC=Y^LH?qA@(K)jKALe{SRRFN_zU0^NBVX-YRb}N z^Wcni3<~pe5B~f|-|jqT?aBp+c(i!{?VptG-2JWlA1dh|7&_s(1c{)Th&Ch9+m2qj z>`bjdq)RXP6$1NSIOiU=n&jU*1c7mJRv1H0ivSxV}1rsr;CP$ zMyaW_o!_&sK6=-lx-6^BK5Hi@BC-MD2L$w%6NI;+r)hY$eVoE@+O(4Q8L|t)y?}_? zB%+(#J@pmGSF~f}tWW^sXwv4dLQp)y5N7;C5}?51QY$egB@G$NL|##&icb*~@rg?r z<7V8ihr_k`288~D`yG4B=aIt_(w%ogK8?nC^YvHlz5T1#aakE@3w=$Kj|-yM`P_@I zb^i3xCzjgnp*;i@3f_Ckrj2zsefdh;@+GB7daX7;9N&a-vE5#8M`KfK&&#jB%fIyB zcZz4L5XQ`5f~^SfDtU$fI)|EiXLJ22NqTXtj<{Wjm>cdTL?i;hy$(~|?~ku=N6%TM z0LIa!6`v2lJ&cGm<`pC&LSteTi_g$9jxQx5E2q`%QzoWGE23i;BF_975`Zkb9X0O8 zU4py4emT<2i-sDt#`&ddE~)$CE=+oih(jZz_5b(ZcWh5Q{rrN#;gMbSSSJnUIzHgaJ<`*oQTvmN9AtG~k zPhZ>XZ-3x<?@YJV91J3O(_wO6 z2hi#{e$vgJvswX+qfS}4fkV0<8D0|3`NS!ESyI*kri)pG2xD__LatsJpEROMNLj>q zKCEoQvf$b0YT0XXAK0@DB{85<&~#jK;l`RLfAf$^uh$mO=DHbWlI7un;gQa|`X>99 zZB@!ow(dx2Y46OM%X`3KrP2a`e{#5-&%#Knb*}qAp6Jp_??J@JW_ukGz3nj7U3Ds1 z0uco;30+#rLISuC0k4~B*8q;x>Sr;jyI5>W3NkkKHYWf#r&R4xB&DkqhPb7S&Fm~h zM0U|USmUhS9wQBQ&zR&iEj77sU-?@`pYEE)KpIN|9$)Y%(jlMWAYuC zEktA^fWHyCf9vXQ2`HR;s-jITeV#GAacaA|XnvzOJZumL2ZH)^9GMQ>c0z;UCbUXDN_PYwG8oabRYrt@=?*vs+7zeUag&R5pe7pQEYX0 z?C*9p?3~uGAT>3q@Ar@V@W6Q+RxLdm(_a*2)8GH~pN?NV_S90l!!hH|Lnv`|8jX`v z@DdTB%jHpvqHt8%lZeO*0MEz(kGM>A-A5_Ui6bRNt7q}M0MX9^Gj6(QXVNXVE*X7) zbLGH4pA9;-Bw7BBqo;n>7R2!&q5!5Ny*O4Yb9W-bH^U7pCZ(#2OUgZn>Zs+2hqv^` z77}^IUR6ShOOcSWoFUGbRD=Lrl6h#CV_$jfbXI|0t93l^-8=T&`js!HD-`^U>kuBV zx8pZ|_-pSU|MJ{wDU6U~M5NLMMQs;sSlz#N^-{4YFGm|=j4`TIia10x1__1sh@v=b zce;kVO?{Ti-FxNtJ}QgdzO%Ye5XBjl!&qzr(jOcS=g&{|1w&nWX(s_YW$`da16e0>BEb>&^RP|Q?*|!-&Sak@9X5o7r9Vi%)hOK|}$}5^Zwv#lZ334ab)N4u5k!EH0yx#U{-|WE1o2F{?nK z+@nlPmz1&bD;Q(ptQCw1y$AO>YPQTLmp!E0a%M(a-;ck0SMwzoZOpRU9p<0>;&JJv z|Gu+yx`7`TV;sHbu3PJF{NfeybG@(_1owf~_C5DJ@Qc{0J$s9$JCTUW1Q2icni^Y< zvrI=xu~L`DHz9(b^_BDJ?{=tCQ`h=_9@)Hk>%en?(*Yv+5r?V%hbL0nsYDdOQKC(q zzXTZn9VGZtSPO;(5{pl*V6kc0i0D`Rg8;jgG4XAxG@M4c0szF$pFY&=YHaXRtiI`vL{#AJsXuhWg+4+=0URUh zl;TXrxc^0fuZOc3M+72^&!}KANrlLmU)pa1aBy0EwK^jwi&tr9oE{JX$qq}UZRdOO z;lv+IX4SHVO;11mb6Z+U(u&z!cbt&6RqWn={nx*}+~szKT#zK9*BnmAEvMYtfT1)- zr-va#oS1eoV^Xu>bJyj?UbwtUp;xb#1)=T0Enm%=TGg6ldfL%5<|K4dh$w)$!cZEc z)xlj1;cf_0`fDl#%*m2+b}>WTGMwVIM+7n|Rl784Ia)=up>*cHKo+edJMBB($soIV z+8hU|$w~eH_w3_s^9$y!Il+H_-~7XUBo!GulH9~^FbIOsdF73Fx+-^vo)-A#&XO!{ zI@OAz=+YJ*LWBv+j+nw3cl8}wV(3Y@Y;`B&xPp=QH*cM~GcdOMAyB4cp#G#}a*il~ zxh5q=t78eb1H!!sFyo#&af*H}y|9g`v}=PNXWTw@TDGW)iJvi@83A~`{f8YG6#P2y>AX&`?YVb^tinteH@7Bpd?G@o=QqUpH{km>Xu;4wcm0e zfR2xiIuNsQQ9j7>@PV)0WB^h0+Xa$Ie!yv}4_XKFcoR_o$Bkm8CY8U9Aw3XQ<{_P- zmP^UkBV$2>O^C4uRZ5ma9hW$x$xbp2IO}(`dYbnHRVtZjDLrNHzAzf4*N04W5iX)A z_tezZxBdI&H&k!D_sM*l&3;_mk*6|NC(%pmPu81rD!R1NMnv?pTw<>I<|rbLd5Xqt zT3Vrq(ykf!&r91!UVqIm21O>??=;ngUFCW%i70^MPO(y-%6**>et?M6X8aioMNY}9 zVEWj#h#~U-G1jO`%XX+@5~j^~&c4MD;9?r2Qdjz zmV3%~RJAmj3&7!S+_lHGf9Hl^$M3l1`ih_a;GTtuIFrYtqrS1Z<`<7W74`ln z=!X2oKK4;o=R0>6vHoGWcrf5Xb zVXLx!^kO~;o&e>uplDvl`>#DGtCh-ttvIXIR`=(Bykz;))6XvrHjfX5(GX=>wy30Q z`O<}!g~bI@Rz^A>A0MO9X*GHU&uchtET@73!1rt z+GQ>K)Nd+BM1Oaf>b`!WWF9A?08SFBgyKA2$^RA+E}!;9T}%y^nwK$M+AtyzIDOO( zZC1hJ=^_zHrXFkgYopSjDPZoe(P*3>zy93bg1qdtvP{F}6}##m{Kezxb&bv8_!-c$ zMI|lQUVc&E`5V_Nvog~&c*f=|s7R7!)5lwPw0`FYzbNYM8wl!>O++6$5qQLh-inyR5xROin%7D08TR6 z^o18A(G!Rm+7X6R^mEyz1B~OR-4Y~#AyraZlPWQNU8qDQ1mJZy?y3wmCD^!rW&O6D zHH9+;_2{%3$BoxrQG4T;uB4*;+=64sYd9O+FTC=0<^Q|yp>rt6cXw=IP==2I#*&vZ z0?-1X0TJ|opdtbr61D3*S3TA-@D#VcA_qascz20u4f{m0Kx|$CO2B8)W`C?93{!jz&O$q$QnkdBbk(h zoRXpJGiwTnTd!b0)ZES+%lmXL3YWAc0?v2&^-+COQCK~1R|F)zk+F_ zf(~52a~WT&%PrFJT3tw`A`EtKwU)iM0cY_YP%0GO8?U=^&t13QfLR%7i{>aCdDa{} z)Lpsyf@_zFl5|w**vBCv3jkIS$rT8=-62Sovt3KjBrjOO@m$s9HWnS*VZ88$fN2XC zt?P*U+>#t&Xt=8F?mJgbk;UpT)rFNuVlIg&fH9USlxfavh0J9W$Fh)QpC-;Az%)Qi zLZSo!NSJLFnBxH^WNu}~0tLSq zWGFdY@PUc|Aoo-l++kx#sN|Az7GoH``@~qIZeFp9*XV-!7swvx-qCmenI9@6QBB#K zRT=3iC$t<|xIFypC$|6LFaJEQ^JXIG2Baz?+J!Py%bcgd)!ovsODicR!rxCVPKz2{ zzwwe=jKRv+qf#-N@P$<|?n7PM58ijLzb^nlowK`k+8v$a!5OUpbV&;nWga#l&{_mo z34lw&PVjN6qm9|irR5il=Ny<0B62AcQ+I1pGJ{SZWUr@o^zCQzP!h13XRf^$+OIO8NcU6`3{O-3?76IWohpG1F6DH`m zOegw=#~DtYT)K|q=~4i^7zj$|x_;j&h0d^#OD)U~M+uCfRPWRmlq_J33-H#HT#h~F zcmGj}6j;Y#@3yygrKcpH3PD2wy!Y|eEjNARI~%9A$6tSAdE8}}Edc~Al5e_%0?G2I zz+V=9Iy;>z@z?)VFrrT0Aj=PK)# zEq+#&3~;-%yY_E$A;)Yu69xybO)uSq1YZP%D`qMMHXC#+SJoKM+jP_kE3MnM>TKJ~ z13mUsYDdE`^ZU;SltE8Qj2o=4*vugum-vBW#Wrur`SR2RBg%}- zj+|dVnuUiKV?!c3U3INr|JsZJSK**pvaw8^Te=<*f=(y-*yyI1jSF*{Z@pO{x7#lO zOc30;p8n>JxsYQvoVE%;n^L+AIJg-Ju0LK|P|+LDuS)sGHx?lT&ga5K7&Y(ieCWr? z;y}NjJ1(b>8qIzD551gb=Hwfq-Ck#U{4wU*zdyL|eoXQXJ|Q!x-+u_q>dd?jr7ced`z4K3`w$DId@x2l%rvPA$S5K z(3I)v2eW?iu!Ym<79FkF1n3xj|GnM)|9tiwzil_A%9Z=*WA)1T_?ZKI5G3=KE!+D4 z^5=jrx(`grE90VKgB}aoAAz7w$=IbzOkF#ro$R|m+9h;01}yyT{P2~Eyxgo)P6=tcCRv`6c~umu6miHf$A8Nc#evuUTR!sgOMZ)Z#F&`%qo3^16cm&o zvN4N?i0tGQdWE6E0Y^iFV5_W%cQ!Q^O^xlJiXthqPE)eZ!WrY1BBEATnqAiax5qQR zhr0Z%FCzGj!&Dnq?ZLU`v`_%*)RGJi+1)UfbOXYw!0SVmo!xF&x1mSBWSK&dl$e1W z7cv-!06gCQzM8(j|1H{8UspWk7$sbP!?w6DT(VlNn{O+^v zscFYzj0W?8T_1Saltd=S5kZ#8B8r05>Gn7r4yVgvwR(q!My3A2A?P0*#@@aGMNi+L z`h!o)=X<6oa4s&T(Qw|iV{cYeD+knBNiE7mqXeQvwua7hPs@RB+t%0op51uCri$d- zzCNRp^C<;kcxb<6>sG=1{(JK!n=LNn$(+KjDOy&;8FYeu$H(VSJuJ%dI(JX~j@f=V zbHORC01g-Rqz3@lSHhX&@=3|2*h|0Au3xxFsYr;=AJ^cs#nsWiz4M_5mx9;hSDTNE zi|@_&;lmA_N_Er@Frw4#>iofXmx9OTCtJ)_H@v4?xKz(nygZgKLy|xiL{4^ldCBh5 zOLljx$G@>6K88J>* z|1cZ~P>Q|wn@$7>s8t|Ok#`S{4ZsPz2FuEndIKlSO1iRQ?m9NFaJgzQ>wyVjB+(q zm%HkB1Z?Y}F?lmSk$_y+ROu)*$h%~hSkSrh&FuT}E8s8ykklO(o` zyz#d*l!b|%l(D`1%WgBOQsVb(vJ-8Jcta}VSjfmszy63U_I-Qz_Wk9l1(L-)4lT$c*HnY>=obWMvf2bix3KTdwrR6&!>KGZ$3S5PxR`~hi$sU5ubj`NB=n} zoBIo<9%J*C*3A?*5YQiipvf+5R89&nCZeIy*Z-mhNjPZ(_*l`DtS#3REt}STS~L%C zw|?}JpVBm5b!P|TxQV$ss#OE(ti%>gMgp>EO(6peZSo(++VvanL0bq0w0&3=@PX+JJxcB~@+LBVAHNa$<4w2K{?KRuI!l*+b zS?mVU;!GlsH$Y1}_E(jA8Y}(2G=__Atl*+zj%fjm2&|)TJcS?%6DBFDb-l(*zQ#>W z5E>4$ARHKdiLZMv(7L>07Lr1B?6~Xri{0_54Dy#E8h!yaq&1p32`wa zsVONVX{kx}$S4acpe-Y;otpeYesuVr(gCZ)JB_PF1dNe$V5x|o3&DZ z=Y;Z`&Y_10ND&u(K%JA^uS$+JFg0I{1QR<2vM9C=z523g_^mhBq8#W~;olcoL8faR zz4af4{YlIrCtU&PQWjnhj2@jSTMj5y&e)4Cu8G;SNx{d*7a}mNEQx)>$VjhaPql0K zjn|4LvpFCcXY{7aK2oO5FOu!9fUG#dAR_z7>rdfi*I4a}O=aq=qGKKep5Bf!$M(0+ zo%BrRm`iURLXJP`D#5PCUFE?d``ElCdl{!#JnIfQZf{ia@tan9@VKfg|9kFxZ+g*a)O)ckS+;D;z2Fj>F%Te-gcL$T zPe_1dQy?Up#wJ-(*i9gigqEd*r7WR@-Ym)8mL*$~t=>mvr0MO|d;i~ztg%Kjl4fL+ z!2o_p%=h!y3-oEm64ui)a!K?W=cRVWO{{)YPQS4U?|4sH(z;M zConh{fHlljB`Hme)cflGFS_B?%hvDJFW;101|)-nn!L<5O<7imH5;R zR#Qq9iqH>&wwC*oeb2eVSyfY=4b+*-%NZ5^BLKQ^J~_nYC9$>h2yv! zG?ip@wuwgC^ER$K_U=awG3J#2!~r*Onx&K?f&h&J!;UaIqfcecva-gsce7v(_pAL!PF5#X+H`Wz)>-)N5ZuvrBvci9O)+ zL3B*4Q)d;}F{g@{CF z0z@)1Gfw|}>{zp^+W+oH4Kdd!p$bGcTEtY!L5iHC_*^LDb4@j6Nz3TLELLLPM3hDO zpa39FO98#XQ}(Ohb}?pCf$Vk-Prm$;;{EW$IL9OaT&dR?j!{j`XM>|Hjf=T|s`!Tr z08MUn5rdhh0ML%PPJK;9WqaNocllHq)~Qg_0)6bzlh0>n zs+7W2lv0;8(z|kc3|;m5M!>LO{e3bJx{jR*^Qg2=Rd&&${0S6MffD3Glz0&(0V*pz zw&pugPfN_}03j8X_+y~bY)bVWfRG{B-+9bXu_7V>Fw2%k zPbup7v7)3nKR%o`Pfs*Jv_NuC6v$(}F*W_HK5cOvgBY?YW<(%L)F+U8+t->oOIk$O ztJ1AGc{ksNvOafR%iu5W(}}avya7-d!f3xff5mqLw$|S-Y7QyjA0_~_xhp<}F@Aim z7&V)nJ6!nP?+vQ*@-`5)&&e4l>vWt{*hr7AWh=8GB_bu%*_*t{}fG4}_A7}lE8p-kAK z-#3$%!Bd1hfKaqvfi>uBN`LW7(e?hj_4X&8h%qszxFX?q`uv)mA9p9De}n*Fbon*+ zV}Nfb{HsbG%)9HGM~t;=!W(~HTb+irIYB-i>KpY1yKO6xC`VLkakzI6AQUxtvB}!U zS+Y|;VP(fSKl68Bm_-W!LWo58(6G!0CWznXN3s~r0FyjU>S=5Af+Cv`LRqJO<^`R{ zV+#OUFj|fxpEIWLh=zQ6WLca506?5DGSJDEE{zBPF1si$6vzvLw5Y*9MwkqSxV#Z6 zi*qK~P)bEI-07c5utI@x^Y)Q+$>-h2nk}G8&svT-E@>hZRd$IZG+~SW?rR#X^0(SF z#^t5;+KRkY(}DmHsM0UowcW6K&7r>UeP{XX0wDnSlwPM_Nwv#96C63!lS5^&2)c<=^qGOw8bUZDC50O&FT!@}KOlBtaq0vn})-kJ&(m&6nC| zcWX@s)bEMe@QTODv--KecM^uk4=bVI1PS>AN-%(kDCnsyWg&|4PtPGT4~iKV5XCZe zA6IcC6nG9RmBDBYP@gmQqR5|>ZZ7-WQS}sSb#Z=}V2GdhWc_DAt5(Ge0KZ#{4DtR& zN~u>F?d?Dz@A?_nC|kC&3jo#=K7d3%fQ0cO1+%I~Rdzn*EZJ)@o?k;{EwRBUz(@~- zs{Jj!+XLOhW7Z4S3}6)-4LPbZGB;NI@zI{%yT9j_$46rh!6~jtcE2uf#qGh7)+bYc zBJ<;869Br*>KvqI-^BY`H9Fnw-AIXYR{D9wJ`VUgIlXZy0AwPD^C9I- z4ugHtAuV09h)Bc72v)_#X>HUqv0mZS4Um>)1YoleB|Z}Jjw$1P%djHH_=#|SRXwm= z%z*?57%EF^1zSH7YyXv0Cf+=hVcEW_L6vTZ`j@au zRbKYMe>sMJabMf)s!ssW0KgNvf|}akaLd1iWpnCJCNVx10iZ2dl@FAD00<>4vt+Cp zqnz1-eTNU#$sP|VfdEE|1dP#4G-c(WEnl)ia(P=^4K15Mp+)nFTOvulfKW8)MN_eQ za51NUWL`#di~vw3N2ACgRY{#k^gc}|+L-e;E!eW~ABseWGXMZ}*tSuJZRX|5I8{Ms zZe|ST1tK%FL>e0^k;ZN9+R{qO8BB3yys~{bItd#27u$3N2xZ-SnztF&F0IoRXGJSS zF@UxB2S3t{J@)5A?hikRixOZ2cj)tLN&`0K8iY=Y^UpImoO1y%mCj2(0)RP_TNKA+ z?%;ht&YkP;>gx6T^wpcTC$+zXfN$onY+6>zb~~krN*?EN(dXkSrDSIPfjoRNhbdm^ z#sDeBcH^K@$1D_3lr*`w2{DB$4q=sg&Rq3mK?#Xl5WuMxO=dC;CE$k{;|Vh*KO$~+U%QeUC-s^*H1k2R6MaLz~#Dp_Afyj z`r^5uG*7dQJeH;BeE2G%Bj>z7wX7@Fcui-s`IuS8s-PP zRwG5Zm?^B@H}f?h3PsA`$w^8`*FxjbDU1?XJVtnbgW_}SQQY=A#XGr&^8O}DQH+)z z76PRI_(s{*)kI{eiOv(QTJYXMuXFY_&=}l{9d8;$63!IK%&NEV?HkN%I&t!!QpP$L zf}zl&wVp`jqu;rK^fs@N`cCwcfTxa9>X`HO2r;tPwbS3;+(;B9NM*U(^Y$|w5G9&a zq{=Cow}Ua?H@4C7Zlg!x#iKLE#nwwN+g|vCAJtQUIDv)%eO{ke^Yg`i&Y3u88bH|Q z2NnAfAe7wZSE+-HP8%R0UQ2~D-cK;54PZ{4bk|x+X>47*5D<`l?%Wqbh$J~D8U?$J zrING;0Oiaa0PBp~s5QSKtTIODBm|boT}LPw(wo3!^#Z415nw=6R;VBlBnDW@2h%9; zF9u4&(mqb&)A>w>fUB1Dx)5blqc}ag3-lHhaH>Lpg}duegm}sa8_9U@#@WMzA?>W4 zbd->&A3+fUap}O9eENn>fBx^E*I#+*g)0$Co~z;G!pP{@IVr0Ji7z9zjxvgn2==G42+Ru2Y6%%u}U;h)Iw z6yX3;Yes-o`w*)V05g#dO^L!FSyUs5H&Y>041l&Kyti5(CXB!2qJ-ABIonJf8KE35J}DplD(!!&sH%@5;Pp9$tB+1#== zq%l|lQHCmtmj=B&@r>tz5S+KV6A)4%&-iA=Gma>$8pG*%9bmLt5r7GZG)}y(0C5b} z0;RiVe^@3n|0Fh;lXgf;6K;+BZ;vLQ9g68=`ur7b0bA>dq~9|OXD4f)a_c*Au8Y+e&<&~@@JEc<#YxFUvq%_>UM)rFTvj)=! zBFjEuVx(Dg*sVa6YJ|{|q#SpY^a9Ju=${qhsQ+G<=@SY%2|GXRo6ZNKIKK-R}07(_|x`^|%o*tywL%kj$2? z@?m@?)C>%ZkSY@~X_<&BN~hwmO;XqB=7kufK^AkUy+8ajeJK+CN%SVr8pciAt~AG0 zYR&_~`|Znb%Tlob_?R+N{xmXs9*ON)!-{M;;*nPFi}CZkHP(Wp4J zN~2;~4glB)FGQq)Fhzt=QC4Iz7~(@NmnYOWFeDy1*22C0eqG+FuHMo_n+NpfR*do7 zRgoB*u{AeH8D<}K*v`8D0YyjxqqW(z^J)WNCMBXz#p~izy&nKn=+&w}1Q5DD)%T*a z69CmJJxl*KyD4HASvTLhM_*MH$JIU!$sK5Lesp{@&?&+VLglFEo+whvHk__@=eYmF z7tW}1vS<=c9aza0*Nh;BNf>4$l)90`w^7doRXh`wm?)9NjI%nL7|stmg9B>}KCIR9 zz-c8=sT9m`6k&Yk0GDMJNKsuad>^=<2s)s?PtJD;hHLBAy z6V@`Qtyvp|v7siU$n%n`GI~QqBjfu{#?9-->ppYvt2ca(KJbSp*_U2>XZ1o2MZ*vU zN!{MnQxg73?NcohteU431rbq(s4P0E5EQ5oP|=`29f`pLpd>qdl%4Hus7gJ^WG@*6 zjcyeHtPk{c?l)DX*64@0?J^H#hO z7-@Yz)p;*=y)*j!nim1!Q?vh&cHu<_vv0a(!(4pq!2x@nzp*n$GjP22_#}wINVS-D z<$3kmrL`%Q-x5U$c=r6Qc{!3QvW7^ao+`2qQKBZYjDgY+CCVg{L@yOWG7WPaM%~r0<@)7ZLGnR>wNiPX^qB!fz&rkGWS*ELWoQAOsd5RhrmYoI=7 z_3RppM8HKW_F;`KX8oyi3lFL@^O7#c^3?sUO_>;uIKV-xAJ6&v_fMJDF5N!8mxv&C z_5a}eszgpfO6ephp=$qF_e8SyLyHvv`n;My0*2dWU)LAS0-&h zy_4(a@)J;i5L3G9RBZkLxb&5)L`JP%P{W{C-b?YGfd^hXsQ?bPXXXZv#&E2%Xs;N4^xbB zT=fY>i2B!SiO_OHhWpPNASH zbvB#?pa223Z0#U0_@C0aAu_PU;-lm`H003)j4_-rk^IT0^}1+CGuW*q+wL z<3QwuJrA(XP_JLIe53n~Ctb5C!e4vtPkXj(STm=YHyebIal6Ag)ONBn(A;v0X*zMr z*xucnF)}iiDN53UjW;j`QnP$n&!;ZCXms}l+t|{Q!X>lm$=6@-sgb_^;h2ogu+|1? z*$0`7f;3>*nD0=LB{y=|PLP0Gg%rt*5U>b?AE^yq#+sjpwT9U8XF|G_@je&wITDtc zQv?!Iy!&~Qjvs|O8dd-6Ak_sPe^8Eers5xf2=Igd;A!XWFwMUbmvGdGlLhimF zet%Nz>x~P!6GSf!4!69u(EX;7k^s;Zth^Z`^l024I98~5r%f2Y5B81K`J1}u zbO`$PzII$18;CB#h3J^%V4x@1-Nqn96AB3u@YE4oM-n{yH~^;16WElYfLfbFr#cJ) z5IRPJ?lEE?sEXw6W5&gn)lLFVjv4lBK@nA`qO?=L+euxc`A7`SNp7mjFB~ZT*?j@5 z)s{KlZJbPn{U{87=ZSrU4#}E(M5MmngyRM%Wg@M zL$X3dg(%^F2r~>;acmem6~~27(K+e1C0QQGt=t@|8mqLuOkp*J1b!f8e6XIlM{DQn z^FTTE0bICz8N;$M<_wj@!_q)&876WZ$w^q?;QXcajKLCpW}G#*L7ShrG3zt}CmQw( zoy`${8n$G0rCo4s%v!VTT;0IUs2v~*vGv@3n^y68(`VhkH(>KFM`$1^hhoxcv%1{n zy8z&?iB6!BU;gT#D#IGz126o=_(KQ0``YIBuCl5QX{a;0ndJ#vp}M@L8M9oR4J&%w zCxX4F3IO6AHx$tTag84&qdnUf3h7XaWTt4@5p2%dj5$>?0HmKTy3hcI46N3c;Pf0o z2o9s5r;@nG5l+hvOXY>wU{eOA)JYZT6bX1ch;zh2#(GW2IZ}WEu41Ifb5`_aeD3oN z`FGs81an+osNcR%7#L5vy|n1C4^ml*XIx^~CQW{Ej^~5t^AS;EHr}4spKUi9^-1Sy zBmp(X*vxUPRjuN(G-`E@R;$U;sMXmV$7M1MleUnebl5-4mQ_XDvgUPgQ z`$^{;f6tv+PzL}O)~E_dKKtJJKHD^s5&){4>S~5z-T{bb?9IC2raEKonz@{IA?JI? zOh_T~+Rco?lE(KO=h12VC;SkF-Zs6uyrvTvHoDQJpuZ&sUNnK;P$nPvcVki$5I9Zu=>v-v}f7@or1r4@6SVn!z0lXnNp-M z*`?;$YgTdC_95?Nsbw>R&uZ+bJY{k$gQ)1M*MD?c@6Si9~-sI!wQUT+#8 zWFel3Zk(2rJ#$?FfF6t)Rz%4`#QWm~DDN*Keor@;tU-(+Lv&BtR2jJ$v)88L_Z;-R z_tzzGTF6qsl41SDdH{&!20#cx1LJz#(gHic6rTX2(d4n|*80G)#@U+iMwKq-aVan! zSm?PeykIP4X1z{-1OVdHU;Wy3%?027ZVe#BL{d^^d5i>tPRThL6vxIC!Qlxa(HNhW zmjICm@%B@(ymkPxe#6Ck)rA#nDM1Eb+mTM7G{yl&73HKd(n(Ormog1@42;$zOjfZD z7&f69BTo$ON3OAmibh%WAm~k_pwjV(Rnf3GhVcH5d=NQo@FFBj43z~nk|iA#`E(=; z*{MbyW`klZXJ2>y0n4sSqAkk88kY0DW9xy^MTK-dCk~Cl40fRiRNDQz>^$QA;JG-> zt?l63N3$~%YkU7JNRl*^TeT&I4jdWMkFt47V&+K{IiU0$69A!fP^$yw4?WVu>h%e= zCW@#y{?t>A?zi8J*z=H4u#ZX4i!+tNDnD_KHBiS;VhVADB+#4N5W|U_B|~J>bCj?Q zPy&8dv~?9yzdJ^FXlr-YY0GQoG+)_F^GVCLnyTppqwqZYesJ&LY<~cu^r4`w<&L?= znZ|+wK%Zapa-wiwggi)&{+4OL$LfaUTl`c>IkfMl79laom3A5ilHeQA=z zk*0Xu>9ae(Qclx{P3bnoX=FqQP$8tEK~D}y{Gv*X&k0oOP{vhPHCivfq6k>_^nw8? zI5587ceHc+Vo6J7zqdWudnzXBi6Z3Dl~%QT-+y-1v`2a-M%woes5tIis{dz=SKoZU z{-!&=AG2<7@rwOet&7P~QS3tv)IAzG{fmC~9}T)C74zEV6d|$i?(f>fp}|rLaEhx~ zpU@E;5dss#`>A_0rH)Lq!KCN5o6rAr_}R`Ic1+u`vR-91ML(|+47PUOe8cjYrU0c# zlx3k(81LwxZ>)J2jy|{M3&6m$seOzypn!$+Yrf8#sLVKKD$FK%<2IDKOIV)%k{+Uu|UI&SWMZ zy&(bxST;D_`6f~sMaVG1IBCzcI=g5Gd-;vWydOTFPeXx4XK3ZzbW`T+FPH)-ir3fX z-t&>i@#@Q~X)yRNv-!cGck8N__n9_s@aR_5m{?Ot+ z!9Il#Y??U?u&R3Dpl(2J{uJwen{nN5{xqL(TaGYV&If z5TzRyqP?LAsJ5!A-MDt$gm%Rm-u?bTo%e(1qo)cH_%TMSODQzPX!UE-F1^v~JNn@} z^2A6sm3T9fLV18t(qSjx(IW%86*XQeilSf}4hD`MReKNYts?%wq9pX$0@PX`XH9o< znOPn#zre39ERfXs1z44pWx#rU9%h)Zu!~!iVKr3t^T+%xeHv-h5y@>hTPi_95e@X7 z^a6r2y$>lmB@egdA+KveQ|HdR@7wX@Gym81*T??Lmzj~CQcuHkjr)K9=eUwBCOvx& z=Qd3b?+V6XNV?1m7$#Z*O%-uoreFZb=Re2xI4O+9&z2q7<3>vysR6_HI(DdpwC zlieT$lHwZ}{r!I@oura7I4;1L)0|wo#mA**gqZYnfwNd7)@r4!DGf1Z6UXXwDy-EQ zK&3JRX44R&VXY{sCLLk(gz#cZsBer9c8}B`NluyDe?IVo-E}~e8HpuyTpnoEff!0? z+ZPw+jSLKp#3a}>wVbS2ws9BQebLV5-`;mO$;!;A{YRJo%7lzQ{oLPTA`%oBH)7*M zf2Sda4|Q>9C~1aYDoGB6rm5`lf>ATzxV12gPa*=MXtB{!F(U)fjW~@l74vu|Mj!mm zdRr)vsjFGhnsLQd4qf%~3SCXjCdQgErZ^^}8XO>W3$m2`Y%Xf?c~m&KCgEj{6$ZurXX#Vagvc(BC;cQ!-MbbRj-a{jn}F700Wx zb4S$$1x{6den^#Ju647B<|3?(d@`tM)i>(rk46I-STfW z4wpM7FX65C_N;#Ey*+42S@GcAciq~z`=Xr%8nx=&@Ek6Hr~mqLmqJKPt_V)gX#qfq zoqK?72+^ugcUJ{bl=0JA*SrXIcG%Eqk{hf|KrNgA*qXmma@i*_k)oOa0041~_cH0Z z(*ht(_PU!?Y3WIsA~Qi`xs&pNrQz=|)Y`gYsI@h6UAufm8<&}hV*~(zQLi(6CE!B8 zOEgx(?WE6Ik&QSQi3?kC>R`pgk90A5eO&6Or?LB}FfbNbw5~Uv7@xM^u_e1b?Sjvy zR9-83T+RGIXEc>Jyh!EVW&(T`PIho9M{qp5Qx@FZ~P1zEb%giuhy*>vp&Q7K& z$=QG+M36%J99vpmGI-PVpXs~oqVx4R*%`}H6PC$=Qfe=* z*p+8#d;S2L^dea8R`v7mZ#izQn2;b1rEHCv?G>M+7+<1R35z2yF1@6{9*#l?mtU+94Ay~e21#dqZWBhtXBh-yZ|hSxRUn2gX$ zCjmkT^|v*4Axf5_sCTua1BJYcYof`pl72*#rvMW&XkJ#+9A$r^=LIdsP)1Gl6@rRZEla)Z_ICa=$GwTzESs{;8+(81 z`104jGxXN`^)dAx6hVk7t!Y8PYJg#KXRkR$2n~?_69oXkP)cdZFYe!`EiFx$rAiSJ z#~**J$^F55k!L`S);cb?Jo!zBs4Vxj4$owjLXmueqPfj zU9(~tDlIP1q^Ft98jU&=rov#e?P4O!WOQWQKK#nx-w_{p_=#G-KN#1kXf`OT9>VE) zz1W;lJuM7oY5`$bF8k#EjBBn*!&+S>&`5{nkT5aPGWyW(&3sp9B+N9WVg|(Prjf4;GCxfjq&zT}5vlZ*cr+ops z@@4I;-juY4Oer0AzVu+$%-omqhyQd;cDsVkzyDq9+rKwq;Sr(q`Jk=kbF;6<3xI;^ zbUkIp0U*XdQ2xlDj&kO-IJ?x}Hn1-^tICg3F1N`XVI)rG5`klv=vA%UcNy5!@`%Tv!_}RU8 ztxq+)M=1&%K6bqE`#YRb{Iy&T1`Ol2h1@EH2~dA@k#?$sdE%b@{qySPEe$IQ=uuMlQ?_H z05+$umU~Z(#_=~;au2YXg%ORJFMrAw36EQHa)x|yZfVNIs;+2@8ao??8G{Ql$p z_k1r$aXMnmWlAX@@OyKSXCQ_tI_{zB@@l?SXR0^Z1nqmpn)?rq+%P+q?(H@`pUYqt;S z%9pG|ko-wc5%N$`8j$_GTXy-W=`MkxZ)&8p?Wb3OFC&tT&ccPa}46Xr6 z=8Qrx3|7WF5AkPfue-gz6OaDof4vVs{_HBBKag^3^0NZVikc-AJ&ei33;lgrvfX|r zIfzoql++xU&JL6vBL}F%mJNs|thA&Qxsf65IFnPp0W*x2D6(JaZ5jbeqr|t2(d5)D zs~W(pitv3snbPQJ(Tw}aIH$ZBSS~QTGBwhEY{|MVV3?%{;V#q4^+{EkQiKA|*B=Xk z5Q=J-y0sU4lyUWyjIK2M3}=9%40e6}Yg*#<#F#)tK{p3SPdq;JTHNN+=hwUr0GH3a zmjA79e`H+0A+FBc+thuS?;D$o^)%#q`{{|9xz3I~1P_Qr1*-`+2 zII1Wo<({VE2r1?qU^1*Dh2OoWS6x)J8R$YqO%PH=} zVKtNQZ+|b;-k3>!js}D2vL;n|d4mP_{&1X2x2~IcC=x;J8u`=1KL5c3Yo_i;9k^^w zkn}cX#pST*)0^4+B@vTaU%7IKQEL|rm4&*R>qEyIq8pvb$ZIq0xMnE=#AIHzbpT_C z=5RI-MFhTp@_{jBv}*+rC84H3 z0Z7g*w>DUIp1)x(+de#JD$5cw!w4?aG`*LQs`i{9DLe&1;GY4F+zdltsX0+A; zFlywr$ITs=tUQEOnvI+_t4^C=I`5ohr+u&Y!!rqXh~a#eD{pZDW+DfyrabSEX;oQ_ z({|+HKOFJyt)JQWR0&Wi8;!I_o1Mf60B!z?uQGrinR!jWe#6niZ+$z?_LqG8LDzdn z7fetg0^jU@`>9eS2}wJN<^p5Q7%Tnd{S&Og5NCEoT8E2DD?c#q3Uu2lQAju^A;W)V zP(tjA&)p>qoC2Z0t$LbPZ6Rp2+Td+pxwh%q7hbQLa5!QF#S;(x=bkHeU-0oIQ7r;d!L({_-KlVBU1*bwIN|wPgeVqGpfm_n+v#^`@B2S3<~*GrJ7O*t!@&9G_U$ z=f03I{V~)(I^Q!mGp1Uz+H(0#L;fT0cT2;ai|!<;S-SL8@sEFI1%Q~%Pbswvqpp+w z*4`z=&o3@i`2PXIYCVC01&Pk$T;f4^IHeb$(0^J|x{Xj^FW zqe!!vymd&W4k1y{QZZOU5mF!pG92K2Q-rmZRUKHZTF_$;0EYA{uB@MU_UY}2kP5_V z68b`b5(BZd?;tjWTtn@HnzEWcE;EloDy<4Bk|^1SWxk`aiiZ3y_^)IFq6&?PDH17Vm#Q$I+@HkfIy{PTG<+EJ+UHkic%z?6v$nvLh{1<4^(D5k>p&=u?uRm z+NG6KUE%>SQbeGv$_BDHClM+2yNg068j3>m%>yCDwEa_ixvYYO>pj>pSgtFMb>Ucc zUDj)V^0Aqm0~a7vt(xvGe5M$LLswuFoB5ab^>1_{MDrrCQJbyF_0}7#dvE_W;8t9rnR$%_@EE4y0SX-LC|_&Hm&215}Y&U{ydSYqB zo1Z93r?u@*>6W;S`@+NBkbM67XaL ziBN#W(SW7;IajS%*8R6<9uAp|5p!wI`u&`&jI}2kUT>`3c~v#f^HHD5BTxKw(?<;l z2j6`0v3`@$m{QWoIf8QM-9KwRaJXsCkemP1fT*wa_o&hg^R7WcsDw4TNT*~P>8($- zDLql3nyJ^b;QX-ply%p-jSGdDf#YVBSpowZqYe2TNih*80+(HULce)8i&<_?Ujgxl z3W!4bF@vMYN5*yQboM77n=uun*W}>M{1~c3GX(%pdg<&kGj(B6n)mRb`q4-Kw3-G2 zu{Mk%8T|*}?lEq?Jmn7)R*_h}c_SFCqZrYMbB0Pn1GUc*01P9Q{PO+~02EP##{F#r z-NBCGE&mEJcuJ8MDYBi2f`9hsTdwWkig)wbb+Pv^|^3AA_h7M0gtK!0}cHygfs!{=+@Y+Ix! z5BiJWK78=G|NHyA1RJv5u5*3ZoU1L(sW+@D--0p3g>OZNx5Klqm55`LQ)Z6Z&;tOe z@hDJmBS2coIc6XMzSi;XvHj_vShW&!Ou~E$nU|dY){_xBE=rLEEN2C^u7mm$G#b{L zPau{RkSyq_B$<#bS^-gQBy5?M*{v>LF|01C%Em00R28e@3)*m+Au0gaw6r*lkzvq9 z2*nBjc1ksfMb~XNubNNNNuzzMi7d5amP@&nmdpojfM`@A66aXi>>K6Y{*C=utvOGy zyXrg#+SejkPTU0ZV?ub7Q!&)9_&pxkGf8CEm{xH)EX3!^L5gGwYoQB`@o~;KGt)Y` zam~u1ZJXEos+X3bPk!O6+oo?h>2PGUclGY8swhi}rN(G9EN?mR#_$zixb4*8qsOBv zH>DK*{pbI+?Y9p-?)}q$-&cRZ_ATp93$u?4^ua$pwfBLCpG=sikBe69Q^tF{G{wuQqgmLt3o?`N z>ss2SN%uKnpdpg%kpu2tCE)Wa!61(GzE@;3rPPQhQc)uJ(U8v#A-|ULfkGPch9BBL zg+E{>G#X84&C2S&ZJXCQH*Kg@mMtkaS*>Y>Fhye?O$zz!RlASA@XA}!h|TT)_T!un zUw@h;NUco>mSGCseEu=vv8P|Czvm~v-ZoQyxjkOfwKv?gJ=2=*{PoZ7IeOWyPt*X! zn<{4s<=4M|^uYbU`}3SdI-u6BVTzX9mChqF$buWJZ;PC&ri_$JQz0#jAjdEQMA^X6_lFD{F3JK#t+L&{L$1*mVdOv(|Bhab1I#uZ=)33P6oD99vOw6RCQYMD>&2H;u z-hItGjJfbUBrA({LJ>s?iXKO+?4009D40))QZ_RwhEi&$LZE}V#x>OMtOiQ|;SMj1 zG0DuZIP$WyBP*$?^IyN^OO4;S>5FEg(QtNztW)2G_xA7nsPn~F-k!4zI=q3o z%8illPjT3yHTP_8S+il|E*)ms*!J{<34Z zs*N+4&&uDzrW-rccdm+g4$k-9t+PM*c+9>5eG0ue#!@$iF3q2{mxaV+f{!os(M&Ozi7wSzT%>M zzA!(BU-52g` ze(U``QE&gXH{Mls^5EMZG)LmfT*%0FpPso8RweRSL&jqRtJp6l}mTT55%SUyvxefO?g_uhTyEsJh_tq@{+;Grja zfAi3zTNEX}JMByq<>!uk{xhHKy?odCP+nS;uj06(xwIEil6rsp(BmV&edzHy=LXjN z21v`Xkea2Y{naX2m^ml-T<+VV(#HWmU&; z;j(guQ%BZ63Hmye;Zs^bD83KC?DuMobzI@H=yxv{w7K(l z5LyUWukNw#T3+O{bfvP!`|wqF<cdpuQN2%?WBUZ0TLu85JhPv_Wsa(jUDF!T~$-v{pl-qjqcdGo+&LV%+zW% zVR4!;t$1*FA_20PVODQGPeDj{4AAaV=SE5?W@x6aPoS&OLSG`3- z{NjN}_C4^2Ct`%^(T`p^SW=W3@ar4^09~3%L_t)4R@7p$tPDT)^b5VeeBhC_fnX>p zVXx6>Ldz=4hL=?=ap&b^3M|VapWlxg_BUpqIN3gD`0jM)ru|rJj66SSptUQ!(Q)Cj z`sr)vG+zYqyZ4i^zS>0G2FR3on8_|F27ogSW@eQHhc!-hVR0Xqo*oeGW9HbR9yG=j z*HEm{Mxs@OD7<1HJcPUxrHKSV`1Lrmpl4f(R<@(Pa{EvVCb`gVd(oPv!mPc|b zH%DK;`@-$bPygw+DQPlqyt}9F%U}C$bP?S5gKyP;?Zz*pBt$5s&Se{SlSzjo>L4^3 z4c?9;?>RUwan5{d<0IeErW42h<2Mgm_Z@0HYx_TLe9SnR(v^dl;iAuu1ibac*1kPa z!w+;iX$VPTD+&37h$;xw8bE485ym)1|CPpi8o=w=cKUPB>@VNYVA-{+YT6V;Q{)2! zlP|t7?0o&z$R?%;4KU?vvN6NP^hr?oKo1JI$B4+w7-NKVW-~UV)d0kD2NFeYAw9<; zS*lR-vfS=|)suU2O4kEdaz8pHbwJyAB%=r7Tmi zeh6c{pc+0KL{)P-@x%NHzK{rl6W7 z-A7`CaI`>)RUI)^*QR`65n1kZzxhN~qFNCB#*Iw{cYHIu`_6Ar#TlE3Bq6y11E9Ei zXv>8cx6ZD#lYM+#9FsL?Y}KyXULW(ik)GAgmmkRpojkIa@S)?Bq98iWg+~;5AlP>3 zC~=N#NwoPiEO^KJf}TQ|16_qYFP z80$FTfAXRKTyydH=k2)Rx=+WO81V9GkkmMqVkz4_5pIL`v+tj35V&^a=& zn7Pu);qK|pZ`{1KaiPvY#@aPI^cyxbMScmo*AiJw`ME_X?Ix$_yO`|KNx-NuYzD-< zhWAJI8dcbRmIjY?IlR;vWzw6}X`c+_jI zPMyzU=eCXR_v`jWe?M@f$$rt!tqF74=YZ}jU%ckPqks9|XgJ_IKm4Ws#s7II<+^~Y46=*ncc=>0)a!1<>e|fMdS(@?`pfVbCD>klOg~C765JjmkZ`qc4O%KYd zY)o2~LKV3WVKzHah>c0l;}nk_5r=Inw&b1!hN(_vax~v^0dlUpUg|j< zq4QT0_e2xUNX?Hzh2SAfq-~Vb_JdltAyS0Iur5TD=v=7JT}g8gkLDx>$2&+(bje{q_7v|Hg!ypO7wj;J9;k9>FxsCZp%chwk4q)LJjz zaqD&4G-~x+jwfc-ie=H0<<+-7SdrF?8T8cuzLIho^xb#e8nZD!+Iwil_=F>6 zEg<|=KJ~|6m#$h--8J*#hYbgpUwrjfMhPKfix@K%D0TY|*OjliV8POCki~p5*t(VU zG_6p&j$}$*M*`A7YnMFUcPOkvlc28wMY3`kPguK?hflE-p?Mp1%myVSdP=fZYf^dn ztTiL*pr%2OlG6Ps4JjjCk@dzFES-#2Achab82~DqiGA>B*gPhLEXW~0H8!!i-}x({ zA%ArLQUr>5*L4TY+pelHUwCcXf`b4QVFWa8vtv#b$C^t5!n|p(oF!|U<*M5R-P#N4 zs7g0)l@T-;hZ$u7^hokxUXJvjwkH*PekF6+_GG3B}Meo$XSDRreXMw-dEV)upHn`d78 z(kgq`7)&d5mdw?0DPK)!VPYUZ1}u)iINS%k62t`0B6pTyy=` zcZ6n9cP9g&R38cNc_uc7?PoH4YMav6ycVbu5vU*pmy$`_;S`6BnpS|Br0C;fGGrAg zB|i-DOdbYV%tJ6-nJyYwS=$@&nJR~}F50a%zx*A!t0cG?qq zz)8qguk;h$T4FRBqMDFG z2-DWyy|6T%h2Q_@k7G26V=ZkJ2M#svOLZJHm2H?+_vUkk^=nqP&%8b|I-XUr=7Q?i z-u$3$F~TkpL{aMf*1bPJQnB{J)nOrXF2SK`WX>fIzh&Jw^7V^n4Gd${}NT^O!o;kez3!T?876J(uvuB3kwFx zWp8l4_GsSBlRpgomP_k7Q+m{F1O#g9?%jpO_x-%wv~7FB!xkZw%_0Ob>L#nzduMMN zickQ_@`8mgGj5K7RaN86e8gqOYFcvZk&{43Wc-LacUq#OND_l-9AGN8%8w84740ME zB?{e7vjxu=*1zhq3sUxUuUcLeBlv#)n?JxGe)H2*hryR#wBzLSFTWA>!2jxZe>Obz z$6u#3h{a-B`Nhv%aroI6UyJ4deD0=it?g<4aCkAi`eB>;trs8H-gVD?hn{`$wdmZ! z0_w&)?%A>OPgUK|KmL1PW`-r@PBrLs2=u=m`AhGwfB)zfLP$cQ&6&U^6Edu*8PYCW z?p77#Q`T%&fl9@Mbz2hRW!X8Y@csR4prtjN?`*eHDY40}*xGlXqUxC3fVlvsbc&o) zeKPrh0X5}GC1ROYO<|@}m6u^;R-e)UMu^F|<*Rv~y$uv*F)N1;Y|!V|ga9DwA!Mx9v5E&DjxhnKLb@kj z-JeuXzJ=gzKYjp-d_+=7y(fleMgzF);uFSgSFGg6hW82Mw#^9>d#2;T3!tz0qmxFX zAvGrvrJDvro(o0iTfw@w!Vtd-KeM4m#FxXoO`GheqduB*}xt zHQRH<8Dt%wIB#R?i~sXb)me1rrSI?AzvsH!zH^=|OL30fUFV;7;{Ko9%@^e7tW73- zDN547@BZ|c;otuL@r|;g%sVH>Y?0GHc}0hL+crIGG{)zr&NX2~1##Hc>v-dJy?1Xz z5mMqUA;?=&#~9M*O`;)+%q!iELRiBEtzpc3=~p!Kwd1CQdfe|k<)Z;_0i{%CN>@){ zl{(I67V{oTC*}TDA4s86oV%nR8`ERfDpizDrKd4gGtyg{C?CiHMVxo+IH)upo0=JpjW~dZe*K@v15Hh%CvOD>H$?Uj(w73vu| zCoHoTby2`ZAIk!zO8ueZ*+j5b6N!(JL zPAZ`$giVEz010giETNYlvVlN=p8z3&6c+v@B&0X1T9%8fV)foNYSY`j_k8~|BWpBw ziZqht&BE^cJbB_7%{}+ra=!D`_X|vp#*N`qzo%9nJhLyEjJ_MLxx6DI&9bamF~*o{ zXgt%}(?2+G$78S0-*)kVy~`RWD>Fm$@MFJK0l@q<;Jm!7hVl*Tl78vY996e*!}`+d z7ykBU__+STk@VEml&Wpz8> zVhf{D4-uX>vj#(Z=IuFg~x8BKG}6au9+SYt>FKF7EhP6!LKd>Z-P zxkw7=#Ho>9A%C5SF^<`hsVw(7EB<1qbF^y+sq{6hC2P?_9RL`X`dhrg#g8pHHH;}E zCd>$;uLJB>h3(E1ZYIQ>Wk49$BClh~=L!gcx@>oye*d*8j7pO*4O+V{cN#NfE|XV= zcGrn|WtSp^#GnQsMm7Oq)6z%R{Pbr^N8He4h4XlF4LX)WNVT6B31vb_BOQ&%ZC{W| z#ez5 z?qp;22R?k){)O=o47OBtsa2|F*|Xn$_!m`=MAWqFG-`Km;}Pcy+LFmPfAapeBh|+g z8Fz30^{J|@o6629Z#?*(pK0%^zU@Iiw5H0S1Dmi_I$oOqlr3 z3jj&I4Gl<^=E`+3@vBO=p5$|iWx}gi>U9SKL!ByLU+YecLY;yTVBEtLZ_6MIo6vTr zve+Q^HRoe0S#U~D6|rRRkGE0Dv*VR8Ik=F@%&LaD;_~q3N(0^+I^Mem`P?y)6Rgos zsMhW5QWcfu&$9t9hSVGO^%8 zS+{mg;e|e5peuLdzOXc8Sxy{ntG2Cx=ia;Z&~=XC;j!=oxazV)C;#xPA1{m0)a&(i z6>d3H99cYLH0bR&Tywbn`l~PZ?cBOCU87O2OQy_MlBAK1d#}_?&)UO_h7dxJzxRB7 zLGHQb_oF!>8ffe6IsUKT{IRL(=%&FDL*Ukt$HM00_)XpXj~+GdmH(IW@a4J1JzB97?9(C*ck7`^P*Tr$ZMajrZ3@ z@}&bW4045AVkBFl{(x`1@I=*)@=f7g@ahlU-Clp{beNpvKfd?XirYSN!?HD(vGIu$ zoA-S{k#Ao6`hQlhEsftuG79wQv6IywzvqkFmgSd3&e(0A^ zWyvnLLVKZ<_JPMf2#y&oct%O%46v%*(UFxXUMb^rY>~$4HXAHliL~E$f zm6V;cwhaXPGj7-yzAozbcXr->o02a9=1hQ5j2DuDBAtMe({Z}{v-ht>frb2T^_Byb z>e8(%ri=za;AlP;2tE*LatS;hrbg9-rwI;G0CmKYyN+qe75SV-};ZRa7i&4XWEayDG@>v(r~-np0U z+hXh^rp%MXl9PoA2}c+JA=z^2fxo64*nO3|y}fJbTMxyp-Jpb>V$$-?V7(=u5TeLP zq@r(9o*rvQZo7a$QX$Oxutx78mfTXp3o%Yx0GI}kqeY(RFUFF8&TP_}8<^~p^hEV9 z;dA-DN9jQG1(Dy)mjcg!Ru`>50HHu$zr*RzjT9s_u3kNudfATfW=bV#p!2p{3ZkyX zk99el&mfG$DhZdBHCXcSPx5_3gSGwt_OCmUEXAn(hO0htjMbW!ZC}sB(Ny1u5ER8@ z0%F$gwnwUf(olPIbZhWyue#*a6F>jqmS`_E0NC<29TXzjZ9jU`HOGJcqi?LFA~0~{ zt@m|R9jy;PjH@rdxZ$Z^{-5$V-f_n-pLpi<*T4Id1LxAj5}g$UkNxJMFi^}cwX z{^JeJRaf17$NuPYg~J!`YkKOJKhp6Ww`2$oV=OzI&W?)e<5Q16^;}wQeM4DPdishW z6c>*de(Sqr?1k4(dYap>n*SbTxkn!D^dPVEoccBZ0Fbo&YGTgXhVcS*hI&omyVYduQTv1*T(&LqJ2>#F zK{k8s80J+;tp&;2UyE#3g|&OAilOfB(CLyF6BDt)>s$p~frBC3m!*I)Nl{r#wAU4bvmaUIZmOIxxe8 zNE@l&S4#()6gMn0BhA`e^9o6%Ea-#wR9|ZP1YvF28tR zZ7c}C^Hh?$J8!#1;e!Ey39QZD^k+=S&C5N3dfTB2bxHY(ChO`xQ%OCpgYg6aDJrig zoN6}!_@utZnNTWa1kTjGeljD?vY128fAFf0j<$C8gjGGAM&s%__4X9Uamx;!1=afX zyDuy9`4+0FzVR=gtGwrvAIpuQ!}#(6P_4` z$V)N!ECx!rDWWs>Vx8H8)jAVq*iwwJmU^63bhu3sfhhdpj~m>5BZIT=)?6Px-tDNM zBkc*_5x%gf$8_kT5lz_!!kSE4%yJyak|a57Zf{q&Bh9i@=u3Fz&%g7OY`a*dXB}oPk4JaK&3BBy^Wq=e z7%#8WjZUj6t9j!;>vkNvVb8qU{f!?ydhi=Rcr@z z32EubxNkqAvNSaN)|-9SKmPGTOqDzOz|d$;@wQ8{Z@A|2Qy;qi3TJv+Dsehpw6mul zSJ$1;2gJp5)0eaO0g#MT+Ad;9v0}A}LA;R1&ehz%hFu%4+A0`dEfrwE93w9 z$~N%(veA4(O!rB1^bX6WrH>YV?*~(?R=aGX6)K9|{oi~D0=+#72Ptr>KDKakW_a!_ zH(mUtPgy(fxnn(&q_FfMr8@@*Cgq`i$ae@0d`u(HAe9D*ilCz;*O*GR%y{Rh>cf z3*d3gV9~Eo{eS<9A000)%u9N4tXor*-`O+J(B9P(wz1B%cjbtZboAhbyYiOH`B74s zpZdhpe{p)ezPQCld0+c_rD4zBL(w*7vLFm&HeGV5);}@U7#JKnCvNy?0ALYobRF27 z)k@OxMoD@BW-ZxSL}$(=ys8irCNoj?T+CPj? zyA|dZUtH2v{KFsX7=d3AB^hRz6w{@LRlb3NR&jJB9HtJnQe*jAqSDTtk5Ucdxl=-# z#qI0rT*zl(Ri}wcv(oIzbAmiI+=+aym_#d*l5NEd6MELFf;EV++=@Ccfh2X?-+spH z?`+s2PmksMyBl+LdvrOF#89?RmCU~S1V{b8#$+bzYde07&RP?uFJf8DKwd`-&)n9{>z7+s zPagm2cZ%1Q6e-wa9{I%+`yT%JZ)<|DO0ok50)d`OuK&dN8i8u{oE#1>ALoF83b5<92rvUNOoxjDcRo4uG=NEc^ft`=8Qu`&|DagaVJ?&+ho`w z1_;aQ^8E(yXx~Dy3zgfEb5ap?AqLY_;kUk{$7Fe$Vl*&-Huu3VtPu)}dK7cA4(yGr zo|}2E_Mq|L#R~QwIz3bdD=4QvS5Tk>Ff$f6H-NflLvsX7+SwHfC+hc{o_+Z@8Pqwe z&~9d4eo^6+_74rl91B4Dh{H1k}`>tSs7kac)yfdtAp4lVX{$TzyT#W){nzGg9S$?cX^dWYN-7001_- zxB)S2*p3Xb>a5u{$`xl{QwEG9-boOsW2QCy89?ps7@R@85GLLTZ7gD-VKwHk z9MGxZkwo-#bfW*DJUdn)q@)h5WNg{vIzIc#@5|+Yn=%VY>WQz-rxC_&bD%=hS;)} zC*st2rTxvPN4zbyE6!d2`u88rLTGWKgm_?CHvhztzdACaguw6q_~*UXeB|zaNs{8; z{D3I-ee{k8ny|eSG%0 zrz=N-5rnmt>#jc<`5m1eR^&g}8=CX@ymf7n-^sS|z-rE8DS#|@z|7bMiT)lG3@|x# zh~cb=IpMoG_`~mM072-QJ?-r=?}dy}dq^??LZ@RUFG=AYm#Day zgE7P8nXdjP!sV`2l4b<_MsLHBL-xP@cH8XpkLAw3_{%lUBY!?f9W$}EpU!fe6mM&! zgk7@Fdt-BJ>B*+n82a*Lpc43!)0HoKqgio|ojAR|Wb4H#?^V`SAcW?#B}0Fojcr9j8r&9ya-cb?sk5h(mk!rJY%T;AF+ z`A49qXN~olXHHVTzb%>(7!Y^^qs`sq?59&PlegPu=KqZ`&Osa(qk;!D0sY=9b{Ma? z^(fYwmZipEQ)Ug5U9z39+!iJ^Hz~%Pn{N5^_CP>P$QryDIF8M)eC;{Q-M4+XF7kV~ z*Qfo^?VmkVyzP>}y6L8dFZKT9EKa>%pRuqT^M&71}?&>5-TLcnj0K2E*9Y_%JRhQMev z8;Q}R+&BWhk@(*cP@mg*9z6&*54l0Dg?B8Cxp!4?I zVo7NswCT^qoiU~-q9bG8o<1*^x=={RrRw{|v6;9#BBM87V7l@XqU@Ne@wC)w#j(E4 z(HT-9o7IxpDU_6t^Eqqsyrcb+Y?}`KhUin$Wov(;YY)Ti@oF!ttPbOaD#WK38IalDLG8iEPS6FCsh{=&Ln^HU`P^YaK>cnPvwV$vS)c$mX{dcojNz|KIl*KW?7|2$D6^=R z4zz@|M{wEl?XUbcpXkj4Lg9u%VlY#XWF*_HY+$&j0Nl2q@LeCRP7qUir{Ut8Il?fZ zj9DQ&Lv6U^(t5|c?=0F)3J?mJP$2=}_DLtSst^}oc+t8V8`Oq93u6cwDK>FzCgFe> zBaStu?lbKBK&Z}`N`V0g%<-sWPS6sL&&L>rlPT2M1xBZB+K&+uFvFGs!;i&dMES@~ z*BonTYR&HK?hA!c+PZp*uKULp+O$;QereeL^sky(Rr zZhswdt-&fs2mmA{#Rh&(ppyfw zgi4AOcFJ&8HmhV!Xz>Sc3$H2Xi#FB<`pzhS=bSC@HrFhfuK<8Rv+YoYs$}y8k=2ZR zX3C`L(A^uH$Lj3hb|;LS0fmq`J$J*VJA$fZQ{)faO$~~$1ean+I(Y_TtYbJ~BdgW# z3I50M{3eW*i9089D~ z7J+K_jx7hDcfnlmU&9Ic*>xIVvki8xpB2!(k-!M`**yNenbSf`+$QhmLoMO1~AmkK=^Vo^O5_hMtJ6saR z*z(>>PfG@!HoPMqJ8^pb-pfDa35a6vIZOfWe(-B?i+F$+2ASOT#h78|ssV!!Po-0^ z$Ogv8m(BUV9EjFQ#rPol6%Sjo&zfZCbW#aCs}0FzOQU|jLSw^(#N<}UUhlaG>4b2$ z^i?_PTNUTSQFPkk^mrNgeM%usR!XI~G$@5Wd9XFM;*nSP;7FbTPB^B{E(qm~5J9!o zc;LEn(+&5wYj$3Ggh|V31;U0Mf`Eyi7*o51^}DJKm)%m6a?`!CcGCq12+PJ~7BU8H zA5jY-(hvRCE!z;!C31KkMfT>9nz$eU098i+Dlj|}&K|%lDS~KedXC_9hB6>z&MCuk z8{;*4IMd!0QgH==FRXa&*|tcZYESP#{`y^)rw$B{#)Qn0LGQruv8I;z9cqa=yDvI5 zmKZD(k)H#@LwN|{^P*D%f`ETySeZQrvSP7IZbykYH5NBH>3jqGy{R$VDZ|HXul|94 z+iIWNJ1}wF+c|PTa@dq62*)YSAz99ny>3PP8}q93qO}2llMc231{xESCmDq!^U(mn zXjEIeDJG*exS&;q8!j>)x@m*urh9ef58m%G-~3s(<%9PLrprE7s@=4AKWj|cw^#+P zVek5J9Oh=k2JO~uZSm)dUJjpk{vTct--}jp9d)(ge`ADrJ~O2yx!aLEg!F7Xnll^J zSZj{)(7sz)8`cc+va@!+@YF9A-1rWs%dq3n^?M$B{Ew9pp}b@OpL+1Ci5CwOyHjAT zS^2_A5I$1XgJk`aY&qE6+^4FX^ozL^V9Yyeh^A zF#X0`Dk{pWW~azLamsPBD|F4$#DFrnh*iaAcT!oI0K+h=t}y`uNgnU*L;-IM7elBW zxN44_8W57z`D>>cmR&OCH)uv;Ts}#GVt}K7mno7=tPtDo|2!$39F{Q`6C~v=4QySS3Xa9ve_f^0CT%Ex4ii>;b z2R}Wy=kQHa6V|Dunt2Qijnp?ZwkAwTMv@p~&tgs}jc)AdX=&C>y>d)&ob0NgGCijl zGX+GQt^L(guhvQKhK87jPYkIF_jQQS0WH7qXw?8wS)aTr#wqJQS-)lS<=R<$eaDfY zU^|!7$=<-!%$p}CF+yonrUSBVO7VVTUZuDfq10blRL&{jU)4N-;QK%)N9vGsI(~_| zO4~DEEXNvCs(GXNoJdjP&H-c5w6{#7%(uXz^Z4-??G^y4{nqBN*-WDT`c~ved3rjN z1_I%{YtE$GjyTr7nv*AlaID@OO5?<8eGH|N-RVl1wb>O;lJ%t}dpl3Q-Cr8v7(^jT z>!G}j7hJXD{x5&?xX%|@v@7qYAN==4r7$>WVzh%0dyGoc9r>N7xpp%`!1x9x4o$vv z)L}o-St0qw>BYv529>Gb)izi?_1D^dZ~O3m1R!`@>y!kJm^cxqQu4Y}sb@LoS#`*= zA~ysxOmQ@`5T>#a7#ryG4^Lh+^-3K#Q_)lzu({$od=mjEK=A%dzgAB`l0q?Ue{WN4 zhhR>n)CedYh)%?jr$?6TQ<(I;f{WiaUKNk8avoTvo6`;%=CnF*&1%$(#K5q3T`gvm z90k6?zGJe(7VEbI`dH*gg+0XRGW~?0+ateItzFkFWM<8)nH=WXV}bFRRaH9~jZPhy z9#bKNEz~wKivbk2w^pO+xNy&QWpx?HS?;{`L#$4%uCJ&$rXUh+Y3t1Sk6-*QrGI$z z#MaI0M-6&idgQeZkBrxU{rf*zoDo*?rz2g;QOt5mJ6x7q6x{8yq$V0;Eo(|G461Ch z-CgME8sWYD^Fa&%(@wj1^97#?Dx>GQzet0=H}(=tq8!vMrY!NW}(AJ!aTtHED^tb_6n=9;t!pkwq6Z zHl*}nm0qt~yG6rriD|`G3NFpuqT5)wLvb$<@(n!n?*etZ71|E~dfh$R@~?{dMUBU5 zb2eKs#^IMry{WvV;NQL)PWPm;+%^8p>oXWLglKf@S4s;O4Oe^PQ5x`FAoVx*pnzAQ zx(EXA9zIj!!x+W2*&R-I+wJ##1y$74$8E!{(`j6nU%bC#%ce57%i|?~c=q{q&M2Q^b_!LV{M07`6zBzB9DHS(%B25H(+WS0fSBaocJE1hq6Y3k16{01FhQ&iAR0 z2K9LxX8-n^EqH|lpYI0%VJ~9&NzCz18t`TiS&G?txcqe|bh|I#9`CvUAwMQ$7;}sR zVMIX&9wa#f1H?pV0D;r?_Mfa^n^e3bfQbX^Oq~b-BUw~~*Hf6NZzOQS09&*rbEW1} z(DVw~{E5{52w+Q2mA3qVinpY#gqL!0u&gWf1AAr(!HP}Feypxy?5B?$ikUN&H~Gd| z6=i`jcCI#i)2}fhcZUx|2-g4c*)dE=DC14CJ5F`qdv~ao9~jP~-Fb1nkhkuFbG1bS zc3YFDw}r^vr#8mRpZ?V&-+t%j>#n?Xu`z~6$4`9zU%#!bsE#iP&J`j1pHmHCDNZ+H zmU9}fzGHyl`Iu7XWI;uLv+b293Xv3B-Sv+E^~Swbs8VAfIj;;1}1e9o-7 zIJ4vkW3VinfCFT?LmE7z!lExJauGJO=!9Ya2Ue|GG!iL?x7V-+jbbyRlGN9E$0u`; zq$Kl|Dct8BZT>~{7%?FcR+BnGm<0iVgJ#T{(o^$hz|323bbDHwLl=*Ma^gr&VPLGk zk1s59VoX*@G3a&KAGz_GwEOP5wIeMx zrR_{dx7O{Br5#%-LX97&96$C?so?8uFmY+QHAJJ&Urz6YpoSn#k5${>db%9T@_Eag zd%qz~4j0HS#|b{WScR9FAxQnc)AqNX;izqLReDr{%a%h-nLU`0q{65G5TLHvN_p^1 zIi^y~F@TUnEIBnKy>OJ;CUV23NWd?s*KJ?*XR0dBs#6zaTo^S6I`o6@j!NU>Sy6MZ z^o%y&A8(u(0U)_2M>u`@wHPmymHPYovFY+Fj0j^o^2mSaz^`P*0iWCA>uMrg-rB}^ zTXwYDpOd zKwg*I&HDq1%X`lS7y%O)Xe$sWhB~;koNmmk(idZm1wgXwu^)HUyzKI|)$a(J2j^p6 z|D8bXQ#rowMv>^v#~8iIuow+lP%uKk+vIAf81&S?u>*>Cd?Ug6o;B{r5{VX9t+Y6#Ug;1qEdHmhyYl{laD`Vo*T+qVE{8PlgMpgt9%=+Ks!D=_(a}jsO$V%y=hOX$4km z*ljvgUd3As3T_SR^S5{2eR~O#mC0h1!dE<_%?W$cCs0$@2-+O=a3t9$V=_&0=^4|$ z&W=zDV%Fy#c-h<8skgoUYK$F4^eO$?3$7Yv^(lKI$s^-m90Jd&Bgg!G%?n8k=B#=) zEpIm!eYLbt*%!c|*V#IbziC?u#di>W{X@rhU3$}hjR5#x{9PcZiD8|&5eq6e1biy+ zI`gnBEy`d7DqTC9SEh?cuO>UED`2eWP{jCPy7rD!j7pnye@Q?nKwb7zp4K`xFxVM( zo|xdqTf{&)!|StviQ-$@r`D=&P(eJ$}`ED2F;{3Y~90U5It2A>HumfWpHyjvR z^gefUps&^A?P%D7B>&#%`A}QGHhSGo{PC%#!0@1A4FMn#N%)S%7C%ZX01%So!2e}2 z|CM<0S#9~&;{@aVNMJQYt=nn5w$a~FGAHwyD25D_&TOMBkFFmle|mkky;^KBq3* zeN$k@`kHU3_d1N{8cWJ!y*1#Rin-~9`ME=tuRi0}sMSd~(5JA=6~^z z52kcy55^>J0U6@BdR@t;Lc;T*O-Xjy>l{@t<$&83LkYf8V4Z$?&11iEG8|hdIh_q% z_uf+;*)RporIfdLM!4+=om{d|ZBhH2H>6{X@r}{nXWn_Ijc{DZ+s_&_{l32OP{aTu z#4sw&M%B6<9AirRe}R!+4MwnJm<0&iWxV|3M}*upAH@i9tXf+}#lUma=h-}W(I%eG zPWUkbh1b(&cNl;6>!*x``8ic(>q=8FUfS{Ka5_i-@9&>GZ^W7Zi~l|VOdJSff3kfE zfm4lQeM%cn$?9X$@+Wa>ZU8VoiM-BGkq>3qNj4=r#^wh=Gpek-25sTmy_jLbH5`Oj z<*G`zX!z`sI>|Zf0*~|DBLRVc#&q~_7iM{}=kxbvA%8#_kpqxHrq@ZX#-u3v7FUO} z)~4zhRZOMZ+CMzgMHuF6Aq^HMU$6IqCvJ#lEHE*2#C`Iebn15Ok0vhw!ZvEQU(~EB zD7y$_q`0wwknHYid7gURA3}4z)hg*=OTx5Ey-w@=^c^2Rece@;lANpz1Isc31)u?c z!0+|>13sTmmSvzPPB+od{qwh1?2-L@1^`SW1eZX7T0L^WgJ#44LaAqM#HqQ>Acfy$LscLX^n=N#Qy^C>o`RN2smSJmlC%GMB$ zU(z21vee@r?H~5{wpgUe;q~*Ro`4V~I@2(ll5S;FbKI;kl`wjf9x$uWSsu}+Aj|!zpYi#>+3Qg|O=A^anCGBBtPWddxNRi-^dH?6%HeGmOxC5+z zVz%C1b0#4N8H8xk;57BR`l-+3#|%dpt=@tec5fnw2%39gJKtnYb69dPP!>Xhu*O^=sfpRz!Io;=Z8rM2Mk;;PiMMrGQ|+<;qqfJTA zDT$QKSW2hy$A7hlQW~bx;xo)!utrE&C5RCs1mo?jMt>8ZlJPOlnDPFi`@M4U+%et3y;$%Sxlc_D+rUit0Lod6|RDV9s^Y?pX zKVr#E$l3~kM6bvH_G-?>HQw5a5WWcj{g*b}m`BjYXVz?MVf3bp=E*kaL1npxj&>;< z(U>w$GnVX~(f2Lk_yD8IZqk7H?4o+Z`tl1Ijyrz|TrA^Ay=r%7aPgy1j{{F)-pVLo zK+CE)Tsrj>K!f7+Hkkd}O9w1hUsKPTOd*w=OVMrTGRy6|FT1_kQgDwny{pUFd@BHYE>R1t^8r98z!YyC zAPgJx41@N)Jlw`BdKU1i2{tW%T{4Y2!k|>icOd$5RH^AJYHpe=h*}s=xqOF{FeFUD zZ>z3Mnn(o@T2?*IQV$F-M!ntl^{-{aSymW~5H{^C;}Of87mhqut=+HAFRdlKda-

A~RFa0Kp84aRM90LM z#CTbehQ$SdVS_pPCPYG>n#`Sg@r7_#A$IY z^o%q;B~$q#066a~MSvMQ(U}u23OYTk)NEDSA;Jkuws{MhNkEupMsKW8<&`w(H}5V) zI+KD~%KpxuQz?a{Rq|xP7i1haU$iBc5FAcH28{eeKlrXQkKL^q) zwbd_rfv>$?S?KczY7gz(of@=G5)I|{cuyUzZ%8gl1K~}7iM|0qXcRFlKkq=r3~SeyZ5<#iTaKtS;%h$vN}V% zkeNHH&MQe#XXfVc=F~z)t;xcK@O<9dK3``;TDS^O^clQ8Ej})@;51Qbvd@*c1rP@P z_BEBd4FypJbXXkw&96_p>+9p1soI^pPiKAV)7uHlX4or^jMK;_APoDY?6M|B$G)fl zP#LqzF-D&Z|5$IBG49{rBG+)R6Qm5_4vwQh`Y$YH{zn+*Pu=~?iLNwbEx(M3ncPB!Mf_IAbK%{N><&U0K`wJ0w;bL7`g{%L7%FhH$a!iU81 z=1^Y%*)usSI;|C=)A}f@Pdk3rOe#5FHZZ2L0l963;F>K2yETtak7z-bjuC^!t1H{) zAPgH~ot2!njzDj7o^tL;l;priWcT!{({sm#!y$$4jn4EqhZ#R0T&8rStpa$#N(`y- zi**3TV0u(RNruz%ZJ1%w=NCMuH?{GZc{A$VVoP|zt3nGtX0sqHmn{^Q9rN|Hm@v@L z0>!eZ3EpRaU+V;7*i%HUox&`yOC}L55{yyXYq@OuFmEyJi6ry{gxsT#{rrrx@<_s> zwW9BS-$fWElzTAy#%uOS0RR;KBDt)woQd(~u-G4YL)t!!@lEreh2o&n< zJSj-%gnqx)H#FF8xZuJ}9MVD%tIE%q@^p_FLKID_2-z{y?(c00of`m2Y{AANth2mH zZ4-GIW5olsPEM!%^jE)6WeBd>y>lxYO(qf$CF^6q`QxI^JpqA-B6LU^YmR4MgXpw& ziB9XYEN}h|Vwp)mI1^)ZUbLTigdACZcP^5}DRs%_u-fnIX&jKJMwM(u7(L+~YyHFd zoKIZ1rahw5`kv^tJj&~FGX!^Jj0!Nq=S<5Dav*ih55C)E+`D~PySAi@%Sau>f-nuD z1k~rJFf*#q%J|&1O+s#wZGORXCQBisQpa8J@jy5sPqlXYsAQY!p)T7(7O51_OXK~8 z{;t#6-qw0PFx)*LJ7?N~(jLt5Gnfr=LB^`@EbB5}PzDD`7PRLM+gF}NElZPYn4 zC;d5Llu5}nW0qS>3~5K`>_mDTDU7S@PUb)IizhVW6V~I|nHl{&$IbQ+4)@%6>%I97 zr)zQgEx^xuMw*{mYF?6e+9^7%HKKFkmq?fX905Fl0HmO)HqU+o0Dgqfc)X0WxTi+M zgH`%Yq|x^yUOf)7#9*M|Fc0|LseI9yl z$^oafRdibaBpUcfSedUx2oD27LG3^ntsR{0O)-NI8bS!SA%q$MAu&>@Vn9Tko*p*t z-@lzV8;jI=*#-KI>#|IHx9d|bx`0go^#y_e!tb`aWYZ*{mc1+41s@NX5VMfKHk&Wn zbX<1KOj5U9p@CqGFa*3NdDfaM4tEv$yBe~*ZO0keG26hVWR4Jy&k6POTc1~J-oMGn z>eL&e*Pm}_sHXP|4;0B$ld;;3pzUXhwoEamj8fH_QmbLhhOp)uEdUV0wW8Bnm&|o7 z6$VtQ$7d5EFu|$1ojV)c_4Nu8%+a4be39lCzpCd<=1^!*u;{meZ~TPqZ+{LI-(a%i=Gdm05NCRV{?Wcv+O2} z@WzO_01EhyN`o!s7$CH8Ty?K$42N#Ax=y@1h-4|M$O#^eCV_lkF+A+`py4+WLT`q@ zOWU9mFq4iMS@OA&3prZt(Iu?fjPi8gpbaA&R_&cNRXZnBEH&vL`}iId(I+2ctT$bB z$uZkIZ-t$No{rn4&hIFg5Y}-}c(xek*R3A>~z8E`NgspVeTOwls0lwJwW}gb`Nc z8qQT?<%)!-Vm3o9%FRw zao_0<7v`Bm3@aSN7$+t&(OZtQrMsq>wA}rK<+em8w}dze1@#3??uLf>@8s#R^|Q}E zmM#vqDHlJ$q}${9?m5r>d}FfJ<+nEXh6I(PbtYc@x_hF@3KV@I`umxeU)(qR=p#)4 zNYSAv?Gv9c0mk7jL3GBNBm2Co8l566yI#F!%SGl7+?j5=?w%&i&P$FEQ)oy{s8!4c-Gw z4n}ig5i?97F{V|~*|D7Ya^gf_8Af{g*FT;uDJt0W$&cQs?d}_BZ0qcamY(Z~Ci6Mj zJ+Tre8l%hEd^2arcoGxx*+rdUT5|)FRkA%P^MKpD8T2$$&RGdz`qBG zPHQ7)NH50-D;S>x!^2s&iX%4Tfdkz{5JK-6Vc1kQHMP=x@4Vr2IaM>k@zzs*Py1hP~+w$Fqdxw=>0CdjXUU zGR5)H!v2T8HU$6^vfG_FZ+ZSS0UwelhxPvM)5V@s?`?3^{%w=1=B3T<`ZqTFI~z*C z>r#wC43;|cHUAV8F!`#iy#1=If_jM9IWWf`#wj^RmO8H_%WUb+%eG#A(?4}KG`Ai9 z#gD(8Z8Yc=_Hyu{u`Il;$u5h_`5za?0+Tj-(-*Wkn>!K4|A{ePk_rm^?sN!vkH%|| z8jVo6i02c=q5+`RGurY*()0a0f!i;Cl2V8MN+n^~tt4-K zb@YM$p6>OXcio|KpJ@q`C-6DB{n{P7>&`mEmJgM>L4WYZ|7X1X<6AIc!rpHoE$ady zGp`N+h1EXBoRM^{_Nm4*YcIRuwgc;S9geG7VT_A0W{+#KH~&GEx#>_`%_$>8tIpnh zUGO(ryQ68~0Bbw-)ci80AYYSCjp%*TeQalbRMfr{r=s^?msZ6Jx*p`5`3BNijeZjlmsz ztr$~Z;B37=OiNRf(PhC&3xGu?Q4p8AJuo`bugc3;oRHDzFP8L{I^>*IR7Zi=qj1KV zue@r&bolTE({GPE z#odWj>Dl`*BDKD;fnA|&D2Cer0926=4p_uE{=x~u?rcJQ@7^7PW!MAF2HV1c^ zW`yuG0$_mPJPc5_l*y3{2o+Q5aN7Z}HNb&bdNOOtI6J6hcWs^^gAlgOlS0c@qrBO_MMG6pZmNv|6AWq?YjF;2Y5UN z@VbnSiWhqfhi(v-szi+jaIB3pX-D}q(=?xE1je9EAv{xxLHRxcMs)14pP{HZnH+UT zD@Gpu=|PN^85S2bao<;i6Z0lr50LPM)u!v?E{2(xU*@wvb6-rA zAd^{KD7kEQ3<(-%1Yd4J01SvvIkYur%H`Y3=M*zR)d>L_M+kZlrQVPW12e$~l!6c{ z#TX?`_a&rfUP#oc7H@y21|uj&3_F3S94?SyN_ULV@$PBlvL(&V8V_fM)rDt2%&o&E zYD_3$Cz;gTR-!ej5yJ+rjYkN4AW75Uo)PKP=$6QP2h&3b!Cu`L6Fde3O{_s`|p{$Ai2ZGMu^+H{N~_-TyM_UOO6Po3D(arn~7!boqMBpT%7Gy@lW1<##M&J#YC0S?R zN9Dqbqhrm!{zkRC@w7s4%Qjou=%YV5w337v%<^#;vp8cHRT*j0*l&Ivhn@^49d1ud z3W`Zw(POW!+?jdT-93oJc&Ia(?9%t>Nc+W!#`N^`a>o4!5MfE7kbTX&bbLo zLTzl~a@Q7NjAO_fG0z~xXCZ9Lw9(P-Q^-9Vewk(K5X;0Yn2zfJx5 zPcbd70l+B*ukUb*2u?3HcH8}rX9SFti@V-%>yv@ zA(o%Of_fOKbpuFi>_-eY2?1YvB4PiQ)7%`t`QKjV~%g)OsPFWP9c$PlT+ZC zFQfvRb?04$7@?4`rII+<```mPAO;lUP=H4~qb(1g)6M!jhv=TLicag3oHo4^VYFw_ zaH=E;vv0rS&zeCoieqZQ1 zdAtZoi9>lS1u>*`60I?Al98wBM2~D8OB6+Li%x4&reH#CTbogyjybewk`7a}^#ta4 zMFL7t`bdMV?bz>5*rAs(nWl;#{>dbxR)>BQTm;S&oin~c>#C701cl)2%fE4?^hq@9JXg?k9a3imCAq$TchMCNgKFkXmL>M!|I5p8r1^~nIXxB7y+rkeB zICUSBUuMUIU5h!cjnkW4tT{EE(dgF)HJ16`*(+ack*!0U&W__Md-l&l@Mw_bv7RqJ zz)BIJ*txmF+n$l;E0<~j@jnW=xoQoVK93RpN@T11L;!?pQ@1jcuVlKS7^QGxPc@2K~<6$613RFSx@A*%mXGZf^38 zPNgAw?gd2D=Q-nVKdwk+R&TtZo6+irsLvZfi_n#0j7`{>s--SRFO~yyAr&gCne+k{ zGwk}{OSM?3Yfvy?y~P54PZJV-q4+PRk{M>lG7Sf>9aLxJZsd&SLWUR8BJVE%l0By@ zHHgZgvDIZ88%>7}HYE-S;;z~EHVOUb-Nr_BEQ{xPB-fx4RaNV)gr6=70a?- zgwYm^5|e=hwOH``G_uoamTk5a*=|drE~g26ejTEee?JyKHdxLu#`GA%6DkI3X@B#c z=)yO}u^2T59M&h3InQEw05ZaOY|%-I-XdwZqX%Fv`6B#1uiirmYhCor0a57t$`>z~ zdF|CIKyxG;yhUH0`hmT(s>00PWG0^o0LbC2epy)p%n5c?Udax1PSGL5=3Q$wB^x`4 z)=&X}8Mu?NG{fpiY#qk(%O!k`WkeLuUH@_!goi_D*82*bMeSU{kbW&fi{!3{5?K6M)Me`MPrhW!T}m?5FcIUy^>Eaa!R2dp-3u{6nO0Dw=Lv9>#o{J#`% z%_>wsX8%%)KLkL(7oFCm$a5D}Z8W)?A0-HXHkp9{VK>QI zbCl>!7X)`t>i5*rftKXkc=Mbu@1OszUQ@Q=z=C5Di~h0cHh0HRjx^)6g!;vc{$bf= zvs0G?sLxHn=VGbHrInnvRO*~oAxdM0)*rmFl1tA`Dm+6e$RPSgX~1s>nTpgoHR(C_ z#^vE3Xid$0QMn5XLV(fe7^2eY8MQita6-m>NF}Z~N_)H$tw{-hMKjkhLdOt7NeKXqKnr7Chox~cO-$)0!Rt;* zcETY9MAgu@zj;u*ZChi`gI_WdmMshgLb6iI8I$(p`0xHQ>OT3-dhoe}J7iKcUO+gH z;nEME=8P#x-#kJvg9LRRBd9~?pc+kcWMc$y**0m{mAjM9B;$Bx9=GVjK(Jr<|LmQA zY!l}h$Dijt$BBJ$k`h9QohfBxph28&0u>mfz`71Z1zXo{|DeHO8CzRcb<%!lYPUZs zty@*?hqhH6+lmlXwUz-zXl*I#B*@l58I%$`A%upIG`=Jbj*0C%+h^bVOmR9&NaEc2 z2Tt1i{C)4`IdOh@?|HxP_xq`Ntzx}1ZYdKR5aIL6#f}yoKmOhXCGJxPIy&_}k4buD zzjqJ-{$_KzyBR@?kv;T$p$!jSW~F5vBnOlHnzRgobicnu6p9YEzpMB9v=aaac0w>H zDM#X!6r3nd(2IQj{9TI4GZuzMnanDnRT(speLsFfqz zr20DYrdRkR6ZLsc+iTrVBH%NHt_1?UtN4c}77TYE5#BN)21|PwDB5?y#|d;<3*{6| zD;nJM{Mz>){!Tpf-!muU{gQ60aaJ+!a`}dDo^mv8X=H+s^|oR%5}XjWVIWxITGacW zai)%Ud~v-KVSISQ@Uhp=OqTO@(|ul(qw1Q`e-C5Tn;1dq^E^A*X~S8Ch6jyYKJDQZ z1bzg7P3ljUZ?sUTQu^($C+yo3068Do-6A$LmZ7!CN}86?!eKcXO@!5fh*R|kSLEa@ z96?Mu`_FMdOtjhpAXuhtZW2H;eCmx}P~$hGy^b|aX9b(>hHiogg4YfoYzrLv`=l$T z)D8sUFJ;N|v($pjB;ODx?ORBKN3U($OoE6aGRLDv#!Nc_Bsjce72wIND$6lKsW?); zy6J8}xUsiuaX3<%NpWl;9Z-kEZE}0>oP@O`akNFA`jxc zH|7g;%WXXixf0V62#HDUy8oT<+yAJiXk=Vhm^tQjmv7kWyjh=3ql>O0A;O9*3M(lN zS<3C#W$%USBX1noaPwH}!iAUTZ{ImEuy5~t^-5RTSZxA23<>#RdGJblErm(m5Nq9s z5I%Cmp6QIP7E#k0A_jMdbWH%Z+NEC;Ksc0fb$JABwn*jXE$>x)>C0t8X{j5hc&5?M z21+D7v0ndmrAzuWkBpxTi1?r?UF}dVv@9kiJVAe5vUqXF{GH$ZplDw8Tvo&?fw8hw z5F?IXMG=6~xnmorc-+u=^RXJJABY~8CGP>FV@+~P>HPXCF4(;+&lc~wf{2dDKFTpmk2=ay>PK&cAz7diI%VKM`)JB@PI`lcVY{AaE&cWUNX4usPjZ z1eU){#aux|T0+wrGtq|V-es`WxW6I@c*JmZ1p>v=P{rEy?PY7%QgN+o5kodXTA8>W zJlFO3_ZvV>Oseaj|Gh^}RNS*}HNey^LI9BDs(LjpNdqG-Ekf|vYb&Q(h1^OisRn4j z#H_U=sMEDi9e_Kyt^|QYiOHMbh(cq;)YEAP+>tRUT|l(W5r5I zT)Gql(P0UlIh`DO<>gg3hnw6?<%3`Uch!Rrr5bdU`Yfj*EgJPF1A&pauP+h3)LE=t z?yOXMx)*?^84`~WflNqywS@Yc_~50hxq3>IJWyRNik9kgh_JZOrA!6^_+&Y<6oOYW z3V$&)0Tfp+zSCl{dW`z1%|?hZt+>Yd0asOPayU?B^hZ7!oTv=}{wha8`^-Z;vj@9# z$>$J-Q;7Plwr&SB3g;V@{WY2Z_@wO26k^q3bcp46)X|6$oA|#L?iXAP!H>ca_$nf7&gPG23)i$}rN8Y39hWk@^Rss` zu&g4)6#%dTfIgFLoHr3kL~sd^+JUq8P@s3@K2Mve+WYuouWf8bqCNSFg4Y8P{7dFT z8==2n$@X4ZENALUL|6IfB(O2tA~JzE|BEbnGw;q;Eh)8Gg$BlP0V3VOk;)Lz4g^;C z|K|g84ur7}n|31t;4mQhRgMEH3-&@^pXtQ&3B*>@@Ku(wBQJ!P0G)`$waqZp9?8Ys zvizP|Vzt)_dl}%?Toj`hBHAbWJk6#Lo=Kq?YYbWrAwq6qhjqY-4k*6Rc7UDK6e=MV~OlA#<@X$blmF%}zt#ybv!MiV) z5J7^7eyT{G9{`NxH1{EA9Zh?Udl@o#0THJSJTwXsB?;kqB^>&>xqW6b)37<+TNuLc z5wR}U_a>rqL|U^VwdW<+G}l`ig5Bl*HUd0>h*P=(>t#$Li!UbQ+OOh6ZC$x3(ZnPl z)Rl-%$8JFUVJ@u2h^U8%o>C<5zI-;7^R=g0B-S)OL=2h{Xr0lHDG{9qqL+}z_D2TZ zAIejWCMJ2HuEg#t{RTs}g9w_8=uwDBBfyhL{LixFJ#;%_-tsSMs97{uMC-i-uo95D z5ovT~FelY*2T%cFm Date: Tue, 28 Apr 2026 22:10:14 +0200 Subject: [PATCH 156/298] Update README --- README.md | 47 ++++++++++++++++++++++++++++++++--------------- docs/index.md | 47 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3bb2ed2f..41a1ab54 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,31 @@ **Virtual** **I**nput over **IP** **E**mulato**R** -VIIPER lets developers create virtual USB input devices (like game controllers, keyboards, and mice) that can be controlled programmatically (even over a network!) (using USBIP under the hood). -These virtual devices are indistinguishable from real hardware to the operating system and applications, enabling seamless integration for testing, automation, and remote control scenarios. +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. +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. + +- 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. -- Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. - (USBIP still requires a kernel driver, but this is generic and device _emulation_ code still lives in Userspace) -- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. +- 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: -- a self-contained, (no dependencies) portable, standalone executable. - providing a lightweight TCP based API for feeder application development. - -- libVIIPER, a single shared library that allows you to emulate devices using USBIP directly from within your application. +- **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-standalone-executable-and-interfacing-via-tcp-over-the-shared-object-libviiper-library) @@ -155,15 +165,22 @@ VIIPER uses it because it's already built into Linux and available for Windows, ### Can I use VIIPER for gaming? -Yes! VIIPER can create virtual controllers that appear as real hardware to games and applications. -This works with Steam, native Windows games, and any other application supporting controllers. +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? -Most controller emulators require custom kernel drivers for each device type. -VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without needing to develop specialized kernel drivers. -(On Windows, a USBIP-Kernel driver is still required, but that driver is generic and doesn't care about the type of USB devices; Device _emulation_ code still lives in Userspace) -This makes VIIPER portable, easier to extend, and simpler to bundle with applications. +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? diff --git a/docs/index.md b/docs/index.md index 1b116dab..9d65cde5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,9 @@ **Virtual** **I**nput over **IP** **E**mulato**R** +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 (VIIPER Server)](getting-started/installation.md) @@ -15,21 +18,30 @@ ## What is VIIPER? -VIIPER lets developers create virtual USB input devices (like game controllers, keyboards, and mice) that can be controlled programmatically (even over a network!) (using USBIP under the hood). -These virtual devices are indistinguishable from real hardware to the operating system and applications, enabling seamless integration for testing, automation, and remote control scenarios. +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. -- Device emulation happens in userspace code instead of kernel drivers, so no kernel programming is required to add new device types. -- Users need USBIP installed once (built into Linux, usbip-win2 for Windows), after that VIIPER can run without additional dependencies or system-wide installation. +- 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) portable, standalone executable. - providing a lightweight TCP based API for feeder application development. + 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 that allows you to emulate devices using USBIP directly from within your application. - See [libVIIPER documentation](libviiper/overview.md) for details and examples. + 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) @@ -95,14 +107,23 @@ VIIPER uses it because it's already built into Linux and available for Windows, ### Can I use VIIPER for gaming? -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. +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? -Most controller emulators require custom kernel drivers for each device type. -VIIPER uses USBIP to handle the USB protocol layer, allowing device emulation in userspace without kernel drivers. -This makes VIIPER portable, easier to extend, and simpler to bundle with applications. +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? From e22d6fe33a8c1a9824e9cf7fc7c63b8f4d76c63e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 11 May 2026 06:09:24 +0200 Subject: [PATCH 157/298] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 41a1ab54..91deefaf 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Useful for reverse engineering USB protocols and understanding how devices commu ### What about TCP overhead or input latency performance? End-to-end input latency for virtual devices created with VIIPER could be 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). +Detailed methodology and sample runs can be found in [E2E Latency Benchmarks](/docs/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. From c31ab2b3fa8b018f8e8253e107a6285fdb6488e8 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:32:18 +0200 Subject: [PATCH 158/298] Fix LangIDs Changelog(fix) --- device/dualshock4/device.go | 2 +- device/keyboard/device.go | 2 +- device/mouse/device.go | 2 +- device/xbox360/device.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 9135b863..6e041d7e 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -397,7 +397,7 @@ var defaultDescriptor = usb.Descriptor{ }, }, Strings: map[uint8]string{ - 0: "\x04\x09", + 0: "\u0409", // LangID: en-US (0x0409) 1: "Sony Interactive Entertainment", 2: "Wireless Controller", }, diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 5ba40822..54218307 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -206,7 +206,7 @@ var defaultDescriptor = usb.Descriptor{ }, }, Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) + 0: "\u0409", // LangID: en-US (0x0409) 1: "VIIPER", 2: "HID Keyboard", 3: "1337", diff --git a/device/mouse/device.go b/device/mouse/device.go index 8b375dcd..0f567fbe 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -169,7 +169,7 @@ var defaultDescriptor = usb.Descriptor{ }, }, Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) + 0: "\u0409", // LangID: en-US (0x0409) 1: "VIIPER", 2: "HID Mouse", 3: "1337", diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 7a0fbf5d..ddd810c9 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -210,7 +210,7 @@ func MakeDescriptor() usb.Descriptor { }, }, Strings: map[uint8]string{ - 0: "\x04\x09", // LangID: en-US (0x0409) + 0: "\u0409", // LangID: en-US (0x0409) 1: "©Microsoft Corporation", 2: "VIIPER Controller", //"Controller", 3: "296013F", From e7456304372d8985840c55759fc3ba76942067b5 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:45:56 +0200 Subject: [PATCH 159/298] Handle usbGetStatus Changelog(fix) --- internal/server/usb/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 98f18342..24da95d5 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -730,6 +730,9 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by wIndex := binary.LittleEndian.Uint16(setup[4:6]) wLength := binary.LittleEndian.Uint16(setup[6:8]) + if breq == usbReqGetStatus { + return []byte{0x00, 0x00} + } if breq == usbReqSetAddress && bm == usbReqTypeStandardToDevice { return nil } From 0297dda410c8b22cd013bbf56ddeb744f8148813 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:46:43 +0200 Subject: [PATCH 160/298] Fix xbox360 capability request Changelog(fix) --- device/xbox360/device.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index ddd810c9..9cdf9bc0 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -225,3 +225,29 @@ func (x *Xbox360) GetDescriptor() *usb.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 +} From 27532fb69668692f2c93b261e9fbac44dffb8823 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:49:15 +0200 Subject: [PATCH 161/298] Add dualshock4 example --- examples/csharp/virtual_ds4_pad/Program.cs | 119 ++++++++++++++++++ .../virtual_ds4_pad/VirtualDs4Pad.csproj | 11 ++ 2 files changed, 130 insertions(+) create mode 100644 examples/csharp/virtual_ds4_pad/Program.cs create mode 100644 examples/csharp/virtual_ds4_pad/VirtualDs4Pad.csproj diff --git a/examples/csharp/virtual_ds4_pad/Program.cs b/examples/csharp/virtual_ds4_pad/Program.cs new file mode 100644 index 00000000..cb146191 --- /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 + + + + + From 97da27a4065439646eb666f6dad2ae3dcb48803d Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:55:43 +0200 Subject: [PATCH 162/298] WinInstall: Fix USBIP-Win2 driver detection Changelog(fix) --- scripts/install.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1d871de0..71e5ba1d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -113,11 +113,11 @@ try { $usbipInstalledVersion = $null $usbipEntry = Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -eq 'USBip' } | + 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 -eq 'USBip' } | + Where-Object { $_.DisplayName -like 'USBip version*' } | Select-Object -First 1 } if ($usbipEntry) { From 904bef3a308c13e3c20e57597635d6fd19f4bb58 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Tue, 12 May 2026 20:56:29 +0200 Subject: [PATCH 163/298] gofmt --- device/xbox360/device.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 9cdf9bc0..8801a37f 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -226,7 +226,6 @@ 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] @@ -240,11 +239,11 @@ func (x *Xbox360) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, w return []byte{ 0x00, 0x14, // report ID, size 0xFF, 0xFF, // all buttons supported - 0xFF, // LT + 0xFF, // LT 0xFF, // RT 0xFF, 0x7F, // LX 0xFF, 0x7F, // LY - 0xFF, 0x7F, // RX + 0xFF, 0x7F, // RX 0xFF, 0x7F, // RY extra[0], extra[1], extra[2], extra[3], extra[4], extra[5], }, true From 3c5df8fc8f45346166d0c2868eb2aea95d1367c2 Mon Sep 17 00:00:00 2001 From: Travis Nickles Date: Mon, 25 May 2026 07:20:13 -0500 Subject: [PATCH 164/298] Added quotes around paths for libVIIPER dir check Fixes command syntax error --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8e861d20..3ef523af 100644 --- a/Makefile +++ b/Makefile @@ -165,7 +165,7 @@ LIBVIIPER_DIST_DIR := dist/libVIIPER .PHONY: libVIIPER libVIIPER: ifeq ($(OS),Windows_NT) - @if not exist $(LIBVIIPER_DIST_DIR) mkdir $(LIBVIIPER_DIST_DIR) + @if not exist "$(LIBVIIPER_DIST_DIR)" mkdir "$(LIBVIIPER_DIST_DIR)" @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(LIBVIIPER_VERSIONINFO_JSON)" "libviiper.versioninfo.tmp.json" @cd $(SRC_DIR) && goversioninfo -64 -o $(LIBVIIPER_RESOURCE_SYSO) libviiper.versioninfo.tmp.json From 5b3f7fdee9c0e2978a7264d6c4ad79627a5fcc88 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 28 May 2026 21:33:42 +0200 Subject: [PATCH 165/298] Replace makefile with justfile --- .github/workflows/build_base.yml | 161 +++--------- Makefile | 359 --------------------------- README.md | 17 +- docs/getting-started/installation.md | 2 +- justfile | 119 +++++++++ 5 files changed, 167 insertions(+), 491 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index b075f089..fbd2a289 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -25,29 +25,27 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + 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: Show Go version run: go version - - name: Verify gofmt - shell: bash - run: | - set -euo pipefail - files="$(gofmt -l .)" - if [ -n "$files" ]; then - echo "format check failed for:" - echo "$files" - exit 1 - fi + - name: Lint + uses: golangci/golangci-lint-action@v9.2.0 + with: + version: latest + install-mode: goinstall - name: Run tests - run: go test -count=1 -v -coverpkg="./..." -coverprofile="coverage.txt" ./... + run: just test-coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v6 @@ -75,72 +73,25 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v6.4.0 with: go-version: 1.26 cache: true cache-dependency-path: | go.sum - - name: Generate Windows version info - if: matrix.target.goos == 'windows' - run: | - VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - git fetch --tags --force || true - if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then - VERSION="v0.0.0-dev" - fi - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest - - VER_STRIPPED=${VERSION#v} - IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" - MAJOR=${PARTS[0]:-0} - MINOR=${PARTS[1]:-0} - PATCH=${PARTS[2]:-0} - BUILD=0 - if [ ${#PARTS[@]} -gt 3 ]; then - BUILD_STR="${PARTS[3]}" - if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then - BUILD=$BUILD_STR - fi - fi - if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then - MAJOR=0; MINOR=0; PATCH=0; BUILD=0; - fi - - jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ - --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ - '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | - .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.FileVersion.Build = ($build | tonumber) | - .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | - .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | - .StringFileInfo.FileVersion = $verStr | - .StringFileInfo.ProductVersion = $prodVer' \ - versioninfo.json > versioninfo.tmp.json - - echo "Generating resource.syso for ${{ matrix.target.goarch }}"; - if [ "${{ matrix.target.goarch }}" = "amd64" ]; then - goversioninfo -64 -o cmd/viiper/resource.syso versioninfo.tmp.json - elif [ "${{ matrix.target.goarch }}" = "arm64" ]; then - # arm64 requires both -arm and -64 flags per goversioninfo flag semantics - goversioninfo -arm -64 -o cmd/viiper/resource.syso versioninfo.tmp.json - else - goversioninfo -o cmd/viiper/resource.syso versioninfo.tmp.json - fi + - name: Setup just + uses: extractions/setup-just@v3 - 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: | - VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - mkdir -p dist - go build -trimpath -ldflags "-s -w -X github.com/Alia5/VIIPER/internal/codegen/common.Version=${VERSION}" -o dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} ./cmd/viiper + just build Release ls -la dist - name: Upload artifact @@ -162,20 +113,19 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + 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 - env: - CGO_ENABLED: 1 run: | - mkdir -p dist/libVIIPER - go build -buildmode=c-shared -trimpath -o dist/libVIIPER/libVIIPER.so ./lib/viiper - go run ./lib/viiper/postbuild + just build-libVIIPER Release - name: Package run: cd dist/libVIIPER && zip libVIIPER-linux-amd64.zip libVIIPER.so libVIIPER.h @@ -190,7 +140,7 @@ jobs: libviiper-windows: name: Build libVIIPER (windows/amd64) - runs-on: ubuntu-latest + runs-on: windows-latest needs: test steps: - name: Checkout code @@ -199,71 +149,34 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v6.4.0 with: go-version: 1.26 cache: true cache-dependency-path: | go.sum - - name: Install mingw cross-compiler - run: sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools + - name: Setup just + uses: extractions/setup-just@v3 - - name: Generate Windows version info + - name: Install build tools + shell: pwsh run: | - VERSION=$(git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always || echo "v0.0.0-dev") - git fetch --tags --force || true - if ! echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then - VERSION="v0.0.0-dev" - fi - go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest - - VER_STRIPPED=${VERSION#v} - IFS='.-' read -ra PARTS <<< "$VER_STRIPPED" - MAJOR=${PARTS[0]:-0} - MINOR=${PARTS[1]:-0} - PATCH=${PARTS[2]:-0} - BUILD=0 - if [ ${#PARTS[@]} -gt 3 ]; then - BUILD_STR="${PARTS[3]}" - if [[ "$BUILD_STR" =~ ^[0-9]+$ ]]; then - BUILD=$BUILD_STR - fi - fi - if ! [[ "$MAJOR" =~ ^[0-9]+$ && "$MINOR" =~ ^[0-9]+$ && "$PATCH" =~ ^[0-9]+$ && "$BUILD" =~ ^[0-9]+$ ]]; then - MAJOR=0; MINOR=0; PATCH=0; BUILD=0; - fi - - jq --arg major "$MAJOR" --arg minor "$MINOR" --arg patch "$PATCH" --arg build "$BUILD" \ - --arg verStr "$MAJOR.$MINOR.$PATCH.$BUILD" --arg prodVer "$VER_STRIPPED" \ - '.FixedFileInfo.FileVersion.Major = ($major | tonumber) | - .FixedFileInfo.FileVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.FileVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.FileVersion.Build = ($build | tonumber) | - .FixedFileInfo.ProductVersion.Major = ($major | tonumber) | - .FixedFileInfo.ProductVersion.Minor = ($minor | tonumber) | - .FixedFileInfo.ProductVersion.Patch = ($patch | tonumber) | - .FixedFileInfo.ProductVersion.Build = ($build | tonumber) | - .StringFileInfo.FileVersion = $verStr | - .StringFileInfo.ProductVersion = $prodVer' \ - lib/viiper/versioninfo.json > libviiper.versioninfo.tmp.json - - goversioninfo -64 -o lib/viiper/resource.syso libviiper.versioninfo.tmp.json + 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 + } - name: Build - env: - CGO_ENABLED: 1 - GOOS: windows - GOARCH: amd64 - CC: x86_64-w64-mingw32-gcc + shell: bash run: | - mkdir -p dist/libVIIPER - go build -buildmode=c-shared -trimpath -o dist/libVIIPER/libVIIPER.dll ./lib/viiper - cd dist/libVIIPER && gendef libVIIPER.dll && cd ../.. - GOOS= GOARCH= CGO_ENABLED=0 CC= go run ./lib/viiper/postbuild + just build-libVIIPER Release - name: Package - run: cd dist/libVIIPER && zip libVIIPER-windows-amd64.zip libVIIPER.dll libVIIPER.def libVIIPER.h + shell: pwsh + run: | + Compress-Archive -Path dist/libVIIPER/libVIIPER.dll, dist/libVIIPER/libVIIPER.def, dist/libVIIPER/libVIIPER.h -DestinationPath dist/libVIIPER/libVIIPER-windows-amd64.zip -Force - name: Upload artifact if: ${{ inputs.upload_artifacts }} diff --git a/Makefile b/Makefile deleted file mode 100644 index 3ef523af..00000000 --- a/Makefile +++ /dev/null @@ -1,359 +0,0 @@ -# VIIPER Makefile -# Cross-platform build automation for VIIPER - -############################################################ -# Variables -# These are defined in a cross-platform way. We branch early -# so that later variable definitions do not need per-OS logic. -############################################################ - -BINARY_NAME := viiper -MAIN_PKG := ./cmd/viiper -SRC_DIR := . -DIST_DIR := dist - -# OS-specific helpers -ifeq ($(OS),Windows_NT) - NULL_DEVICE := nul - DATE_CMD := powershell -NoProfile -NonInteractive -Command "Get-Date -Format 'yyyy-MM-dd_HH:mm:ss'" - EXE_EXT := .exe - RM_DIR := rmdir /S /Q - RM_FILE := del /Q - COVERAGE_OUT := $(SRC_DIR)\coverage.out - COVERAGE_HTML := $(SRC_DIR)\coverage.html - export CGO_ENABLED=0 -else - NULL_DEVICE := /dev/null - DATE_CMD := date -u +"%Y-%m-%d_%H:%M:%S" - EXE_EXT := - RM_DIR := rm -rf - RM_FILE := rm -f - COVERAGE_OUT := $(SRC_DIR)/coverage.out - COVERAGE_HTML := $(SRC_DIR)/coverage.html - export CGO_ENABLED=0 -endif - -# Git-derived metadata (robust to missing git by redirecting errors) -VERSION ?= $(shell git describe --tags --match "v[0-9]*.[0-9]*.[0-9]*" --always 2>$(NULL_DEVICE) || echo v0.0.0-dev) -COMMIT := $(shell git rev-parse --short HEAD 2>$(NULL_DEVICE) || echo unknown) -BUILD_TIME := $(shell $(DATE_CMD)) - -# Go build flags -LDFLAGS := -s -w -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.Date=$(BUILD_TIME) -X github.com/Alia5/VIIPER/internal/codegen/common.Version=$(VERSION) -BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)" - -# Windows resource embedding -VERSIONINFO_JSON := versioninfo.json -RESOURCE_SYSO := cmd/viiper/resource.syso -LIBVIIPER_VERSIONINFO_JSON := lib/viiper/versioninfo.json -LIBVIIPER_RESOURCE_SYSO := lib/viiper/resource.syso - -.PHONY: all -all: test build - -.PHONY: help -help: ## Show this help message - @echo VIIPER Makefile - @echo. - @echo Usage: make [target] - @echo. - @echo Build Targets: - @echo build Build VIIPER for current platform - @echo libVIIPER Build libVIIPER shared library (DLL on Windows, SO on Linux) - @echo clean Remove build artifacts - @echo test Run tests - @echo test-coverage Run tests with coverage - @echo. - @echo SDK Code Generation: - @echo codegen-all Generate all SDK client libraries - @echo codegen-c Generate C SDK - @echo codegen-cpp Generate C++ SDK - @echo codegen-csharp Generate C# SDK - @echo codegen-rust Generate Rust SDK - @echo codegen-typescript Generate TypeScript SDK - @echo. - @echo SDK Building: - @echo build-sdks Build all SDK client libraries - @echo build-sdk-c Build C SDK - @echo build-sdk-cpp Build C++ SDK - @echo build-sdk-csharp Build C# SDK - @echo build-sdk-rust Build Rust SDK - @echo build-sdk-typescript Build TypeScript SDK - @echo. - @echo Example Building: - @echo build-examples Build all examples for all SDKs - @echo build-examples-c Build C examples - @echo build-examples-cpp Build C++ examples - @echo build-examples-csharp Build C# examples - @echo build-examples-rust Build Rust examples - @echo build-examples-typescript Build TypeScript examples - @echo. - @echo Cleaning: - @echo clean-sdks Clean SDK build artifacts - @echo clean-examples Clean example build artifacts - @echo. - @echo Complete Rebuild: - @echo rebuild-all Clean, regenerate, and build all SDKs and examples - @echo. - @echo Other Targets: - @echo help Show this help message - @echo deps Download Go dependencies - @echo tidy Tidy Go dependencies - @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: generate-versioninfo -generate-versioninfo: ## Generate Windows version info resource -ifeq ($(OS),Windows_NT) - @echo Generating Windows version info... - @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest - @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(VERSIONINFO_JSON)" "versioninfo.tmp.json" - @cd $(SRC_DIR) && goversioninfo -64 -o $(RESOURCE_SYSO) versioninfo.tmp.json - @del versioninfo.tmp.json -else - @echo Skipping versioninfo generation on non-Windows platform -endif - -.PHONY: clean-versioninfo -clean-versioninfo: ## Remove generated Windows version info resource - -@$(RM_FILE) $(RESOURCE_SYSO) 2>$(NULL_DEVICE) - -@$(RM_FILE) $(LIBVIIPER_RESOURCE_SYSO) 2>$(NULL_DEVICE) - -@$(RM_FILE) versioninfo.tmp.json 2>$(NULL_DEVICE) - -@$(RM_FILE) libviiper.versioninfo.tmp.json 2>$(NULL_DEVICE) - -.PHONY: build -build: ## Build for current platform -ifeq ($(OS),Windows_NT) - @$(MAKE) generate-versioninfo -endif - cd $(SRC_DIR) && go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)$(EXE_EXT) $(MAIN_PKG) - -############################################################ -# libVIIPER shared library -############################################################ - -LIBVIIPER_DIST_DIR := dist/libVIIPER - -.PHONY: libVIIPER -libVIIPER: -ifeq ($(OS),Windows_NT) - @if not exist "$(LIBVIIPER_DIST_DIR)" mkdir "$(LIBVIIPER_DIST_DIR)" - @go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest - @powershell -NoProfile -NonInteractive -File scripts/inject-version.ps1 "$(VERSION)" "$(LIBVIIPER_VERSIONINFO_JSON)" "libviiper.versioninfo.tmp.json" - @cd $(SRC_DIR) && goversioninfo -64 -o $(LIBVIIPER_RESOURCE_SYSO) libviiper.versioninfo.tmp.json - @del libviiper.versioninfo.tmp.json - set CGO_ENABLED=1 && go build -buildmode=c-shared -o $(LIBVIIPER_DIST_DIR)/libVIIPER.dll ./lib/viiper - cd $(LIBVIIPER_DIST_DIR) && gendef libVIIPER.dll - go run ./lib/viiper/postbuild -else - @mkdir -p $(LIBVIIPER_DIST_DIR) - CGO_ENABLED=1 go build -buildmode=c-shared -o $(LIBVIIPER_DIST_DIR)/libVIIPER.so ./lib/viiper - go run ./lib/viiper/postbuild -endif - -.PHONY: clean -clean: clean-versioninfo ## Remove build artifacts - -@$(RM_DIR) $(DIST_DIR) 2>$(NULL_DEVICE) - -@$(RM_FILE) $(COVERAGE_OUT) 2>$(NULL_DEVICE) - -@$(RM_FILE) $(COVERAGE_HTML) 2>$(NULL_DEVICE) - -.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) - -############################################################ -# SDK Code Generation -############################################################ - -CLIENTS_DIR := clients - -.PHONY: codegen-all -codegen-all: ## Generate all SDK client libraries (C, C++, C#, Rust, TypeScript) - @echo Generating all SDK clients... - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang all --output $(CLIENTS_DIR) - -.PHONY: codegen-c -codegen-c: ## Generate C SDK client library - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang c --output $(CLIENTS_DIR) - -.PHONY: codegen-cpp -codegen-cpp: ## Generate C++ SDK client library - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang cpp --output $(CLIENTS_DIR) - -.PHONY: codegen-csharp -codegen-csharp: ## Generate C# SDK client library - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang csharp --output $(CLIENTS_DIR) - -.PHONY: codegen-rust -codegen-rust: ## Generate Rust SDK client library - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang rust --output $(CLIENTS_DIR) - -.PHONY: codegen-typescript -codegen-typescript: ## Generate TypeScript SDK client library - cd $(SRC_DIR) && go run $(MAIN_PKG) codegen --lang typescript --output $(CLIENTS_DIR) - -############################################################ -# SDK Building -############################################################ - -.PHONY: build-sdks -build-sdks: build-sdk-c build-sdk-cpp build-sdk-csharp build-sdk-rust build-sdk-typescript ## Build all SDK client libraries - -.PHONY: build-sdk-c -build-sdk-c: ## Build C SDK - @echo Building C SDK... - @if exist $(CLIENTS_DIR)\c (cd $(CLIENTS_DIR)\c && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release) else (echo C SDK not generated yet. Run 'make codegen-c' first.) - -.PHONY: build-sdk-cpp -build-sdk-cpp: ## Build C++ SDK (header-only, no build needed) - @echo C++ SDK is header-only - no build needed. - -.PHONY: build-sdk-csharp -build-sdk-csharp: ## Build C# SDK - @echo Building C# SDK... - @if exist $(CLIENTS_DIR)\csharp (cd $(CLIENTS_DIR)\csharp\Viiper.Client && dotnet build) else (echo C# SDK not generated yet. Run 'make codegen-csharp' first.) - -.PHONY: build-sdk-rust -build-sdk-rust: ## Build Rust SDK - @echo Building Rust SDK... - @if exist $(CLIENTS_DIR)\rust (cd $(CLIENTS_DIR)\rust && cargo build) else (echo Rust SDK not generated yet. Run 'make codegen-rust' first.) - -.PHONY: build-sdk-typescript -build-sdk-typescript: ## Build TypeScript SDK - @echo Building TypeScript SDK... - cd $(CLIENTS_DIR)\typescript && npm install && npm run build - -############################################################ -# Example Building -############################################################ - -.PHONY: build-examples -build-examples: build-examples-c build-examples-cpp build-examples-csharp build-examples-rust build-examples-typescript ## Build all examples for all SDKs - -.PHONY: build-examples-c -build-examples-c: ## Build C examples - @echo Building C examples... - cd examples\c && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release - -.PHONY: build-examples-cpp -build-examples-cpp: ## Build C++ examples - @echo Building C++ examples... - cd examples\cpp && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release - -.PHONY: build-examples-csharp -build-examples-csharp: ## Build C# examples - @echo Building C# examples... - cd examples\csharp\virtual_keyboard && dotnet build - cd examples\csharp\virtual_mouse && dotnet build - cd examples\csharp\virtual_x360_pad && dotnet build - -.PHONY: build-examples-rust -build-examples-rust: ## Build Rust examples - @echo Building Rust examples... - cd examples\rust && cargo build --workspace - -.PHONY: build-examples-typescript -build-examples-typescript: build-sdk-typescript ## Build TypeScript examples - @echo Building TypeScript examples... - cd examples\typescript && npm install && npm run build - -############################################################ -# Cleaning -############################################################ - -.PHONY: clean-sdks -clean-sdks: ## Clean all SDK build artifacts - @echo Cleaning SDK build artifacts... - -@$(RM_DIR) $(CLIENTS_DIR)\c\build 2>$(NULL_DEVICE) - -@$(RM_DIR) $(CLIENTS_DIR)\csharp\Viiper.Client\bin 2>$(NULL_DEVICE) - -@$(RM_DIR) $(CLIENTS_DIR)\csharp\Viiper.Client\obj 2>$(NULL_DEVICE) - -@$(RM_DIR) $(CLIENTS_DIR)\rust\target 2>$(NULL_DEVICE) - -@$(RM_DIR) $(CLIENTS_DIR)\typescript\node_modules 2>$(NULL_DEVICE) - -@$(RM_DIR) $(CLIENTS_DIR)\typescript\dist 2>$(NULL_DEVICE) - -.PHONY: clean-examples -clean-examples: ## Clean all example build artifacts - @echo Cleaning example build artifacts... - -@$(RM_DIR) examples\c\build 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\cpp\build 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_keyboard\bin 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_keyboard\obj 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_mouse\bin 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_mouse\obj 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_x360_pad\bin 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\csharp\virtual_x360_pad\obj 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\rust\target 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\typescript\node_modules 2>$(NULL_DEVICE) - -@$(RM_DIR) examples\typescript\dist 2>$(NULL_DEVICE) - -############################################################ -# Complete Rebuild -############################################################ - -.PHONY: rebuild-all -rebuild-all: clean-sdks clean-examples codegen-all build-sdks build-examples ## Complete rebuild: clean, regenerate all SDKs, build all SDKs and examples - @echo. - @echo ============================================================ - @echo REBUILD COMPLETE - @echo ============================================================ - @echo All SDKs have been regenerated and built. - @echo All examples have been built. - @echo ============================================================ diff --git a/README.md b/README.md index 91deefaf..952bb6e7 100644 --- a/README.md +++ b/README.md @@ -118,25 +118,28 @@ See the [API documentation](./docs/api) for details - [Go](https://go.dev/) 1.26 or newer - USBIP installed -- (Optional) [Make](https://www.gnu.org/software/make/) - - Linux/macOS: Usually pre-installed - - Windows: `winget install ezwinports.make` +- (Optional) [just](https://github.com/casey/just) + - Windows: `winget install --id Casey.Just --exact` + - Linux/macOS: `cargo install just` or use your package manager +- Windows compiler (required for `build-libVIIPER`): + - `winget install -e --id MartinStorsjo.LLVM-MinGW.UCRT` + `--accept-package-agreements --accept-source-agreements` ### 🔄 Building from Source ```bash git clone https://github.com/Alia5/VIIPER.git cd VIIPER -make build +just build Release ``` -The binary will be in `dist/viiper` (or `dist/viiper.exe` on Windows). +The binary will be in `dist/viiper--` (for example `dist/viiper-windows-amd64.exe`). For more build options: ```bash -make help # Show all available targets -make test # Run tests +just --list # Show all available targets +just test # Run tests ``` --- diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 1a26120e..ce0cf0f9 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -186,7 +186,7 @@ You must have a USBIP-Client implementation available on your system to use VIIP ```bash git clone https://github.com/Alia5/VIIPER.git cd VIIPER - make libVIIPER + just build-libVIIPER ``` The output will be in `dist/libVIIPER/`. diff --git a/justfile b/justfile new file mode 100644 index 00000000..ef64fb76 --- /dev/null +++ b/justfile @@ -0,0 +1,119 @@ +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) + +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 -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + +[arg("type", pattern="Debug|Release")] +[unix] +build type=build_type: + {{ mkdir_p }} {{ dist_dir }} + CGO_ENABLED=0 go build -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + +[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 + +[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 + +clean: clean-versioninfo + -{{ rm_rf }} {{ dist_dir }} + -{{ rm_f }} coverage.out + -{{ rm_f }} coverage.html + go clean + +fmt: + go fmt ./... + +lint: + golangci-lint run ./... + +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 }} From 97105365441cd6250de6bc3d2249849e1669c895 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 28 May 2026 21:53:16 +0200 Subject: [PATCH 166/298] Introduce golangci-lint and make linter happy - Breaking: Go users will experience breaking changes due to renamings Changelog(misc) --- .golangci.yml | 63 +++++++++++++ _testing/e2e/bench_test.go | 22 ++--- _testing/test_server.go | 10 +-- _testing/usbip_client.go | 2 +- cmd/viiper/viiper.go | 16 ++-- device/device_attach_test.go | 23 ++--- device/dualshock4/device.go | 10 +-- device/dualshock4/dualshock4_test.go | 32 +++---- device/dualshock4/inputstate.go | 2 + device/keyboard/device.go | 10 +-- device/keyboard/keyboard_test.go | 30 +++---- device/mouse/const.go | 10 +-- device/mouse/device.go | 10 +-- device/mouse/handler.go | 2 +- device/mouse/mouse_test.go | 24 ++--- device/options.go | 4 +- device/xbox360/device.go | 8 +- device/xbox360/handler.go | 2 +- device/xbox360/inputstate.go | 1 + device/xbox360/xbox360_test.go | 30 +++---- docs/cli/codegen.md | 2 +- docs/clients/generator.md | 4 +- docs/clients/go.md | 6 +- examples/cpp/virtual_mouse.cpp | 2 +- examples/csharp/virtual_ds4_pad/Program.cs | 6 +- examples/csharp/virtual_keyboard/Program.cs | 6 +- examples/csharp/virtual_mouse/Program.cs | 8 +- examples/csharp/virtual_x360_pad/Program.cs | 6 +- examples/go/virtual_ds4/main.go | 10 +-- examples/go/virtual_ds4_cli/main.go | 21 +++-- examples/go/virtual_keyboard/main.go | 10 +-- examples/go/virtual_mouse/main.go | 12 +-- examples/go/virtual_x360_pad/main.go | 10 +-- examples/rust/async/virtual_mouse/src/main.rs | 2 +- examples/rust/sync/virtual_mouse/src/main.rs | 2 +- examples/typescript/virtual_mouse.ts | 4 +- internal/_testing/api_test_helpers.go | 2 +- internal/cmd/config.go | 5 +- internal/cmd/install_windows.go | 6 +- internal/cmd/proxy.go | 4 +- internal/cmd/server.go | 32 +++---- internal/codegen/cmd/scan-dtos/main.go | 4 +- internal/codegen/common/wiresize.go | 5 +- internal/codegen/generator/cpp/client.go | 2 +- internal/codegen/generator/cpp/device.go | 2 +- .../codegen/generator/cpp/device_header.go | 2 +- internal/codegen/generator/cpp/gen.go | 2 +- internal/codegen/generator/cpp/json.go | 2 +- internal/codegen/generator/cpp/main_header.go | 2 +- internal/codegen/generator/cpp/types.go | 2 +- internal/codegen/generator/csharp/client.go | 2 +- .../codegen/generator/csharp/constants.go | 13 ++- internal/codegen/generator/csharp/device.go | 2 +- .../codegen/generator/csharp/device_types.go | 2 +- internal/codegen/generator/csharp/project.go | 4 +- internal/codegen/generator/csharp/types.go | 2 +- internal/codegen/generator/generator.go | 4 +- .../codegen/generator/rust/async_client.go | 2 +- internal/codegen/generator/rust/client.go | 2 +- internal/codegen/generator/rust/constants.go | 2 +- .../codegen/generator/rust/device_types.go | 2 +- internal/codegen/generator/rust/gen.go | 2 +- internal/codegen/generator/rust/helpers.go | 12 --- internal/codegen/generator/rust/project.go | 2 +- internal/codegen/generator/rust/types.go | 2 +- internal/codegen/generator/rust/wire.go | 2 +- .../generator/typescript/binary_utils.go | 2 +- .../codegen/generator/typescript/client.go | 2 +- .../codegen/generator/typescript/constants.go | 2 +- .../generator/typescript/device_types.go | 2 +- .../codegen/generator/typescript/index.go | 4 +- .../codegen/generator/typescript/types.go | 2 +- internal/codegen/scanner/constants.go | 5 +- internal/codegen/scanner/dtos_test.go | 6 +- internal/codegen/scanner/payload.go | 2 +- internal/codegen/scanner/returns.go | 14 +-- internal/log/logging.go | 2 +- internal/server/api/auth/auth.go | 2 +- internal/server/api/auth/auth_test.go | 2 +- internal/server/api/auth/conn_test.go | 6 +- internal/server/api/auth/handshake.go | 4 +- internal/server/api/auth/handshake_test.go | 4 +- internal/server/api/autoattach_linux.go | 4 +- internal/server/api/autoattach_windows.go | 70 ++++++++------- internal/server/api/device_stream_handler.go | 4 +- .../server/api/device_stream_handler_test.go | 22 ++--- internal/server/api/error/apierror.go | 30 +++---- internal/server/api/handler/bus_create.go | 20 ++--- .../server/api/handler/bus_create_test.go | 8 +- internal/server/api/handler/bus_device_add.go | 14 +-- .../server/api/handler/bus_device_add_test.go | 24 ++--- .../server/api/handler/bus_device_remove.go | 4 +- .../api/handler/bus_device_remove_test.go | 8 +- .../server/api/handler/bus_devices_list.go | 14 +-- .../api/handler/bus_devices_list_test.go | 10 +-- internal/server/api/handler/bus_list.go | 4 +- internal/server/api/handler/bus_list_test.go | 6 +- internal/server/api/handler/bus_remove.go | 4 +- .../server/api/handler/bus_remove_test.go | 12 +-- internal/server/api/handler/ping.go | 4 +- internal/server/api/handler/ping_test.go | 8 +- internal/server/api/server.go | 30 ++++--- internal/server/api/server_test.go | 28 +++--- internal/server/proxy/server.go | 4 +- internal/server/usb/server.go | 24 ++--- internal/tray/tray_windows.go | 6 +- internal/updater/updater.go | 4 +- internal/util/util_windows.go | 2 +- lib/viiper/bus.go | 2 +- lib/viiper/dualshock4.go | 8 +- lib/viiper/keyboard.go | 8 +- lib/viiper/mouse.go | 8 +- lib/viiper/postbuild/main.go | 4 +- lib/viiper/xbox360.go | 8 +- usbip/usbip.go | 27 ++---- {apiclient => viiperclient}/client.go | 60 ++++++------- {apiclient => viiperclient}/client_test.go | 32 +++---- {apiclient => viiperclient}/stream.go | 12 +-- {apiclient => viiperclient}/stream_test.go | 36 ++++---- {apiclient => viiperclient}/transport.go | 6 +- {apiclient => viiperclient}/transport_test.go | 42 +++++---- {apitypes => viipertypes}/structs.go | 32 +++---- virtualbus/virtualbus.go | 88 +++++++++---------- 123 files changed, 709 insertions(+), 644 deletions(-) create mode 100644 .golangci.yml rename {apiclient => viiperclient}/client.go (73%) rename {apiclient => viiperclient}/client_test.go (69%) rename {apiclient => viiperclient}/stream.go (96%) rename {apiclient => viiperclient}/stream_test.go (89%) rename {apiclient => viiperclient}/transport.go (98%) rename {apiclient => viiperclient}/transport_test.go (85%) rename {apitypes => viipertypes}/structs.go (86%) 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/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 321a37d0..6d374092 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -9,12 +9,12 @@ import ( "testing" "time" - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/apitypes" "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 @@ -163,12 +163,12 @@ func Benchmark_Xbox360_Delay(b *testing.B) { } s := cmd.Server{ - UsbServerConfig: usb.ServerConfig{ + USBServerConfig: usb.ServerConfig{ Addr: ":3244", BusCleanupTimeout: 1 * time.Second, WriteBatchFlushInterval: 0, }, - ApiServerConfig: api.ServerConfig{ + APIServerConfig: api.ServerConfig{ Addr: ":3245", AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, @@ -185,10 +185,10 @@ func Benchmark_Xbox360_Delay(b *testing.B) { } }() - var c *apiclient.Client + var c *viiperclient.Client - c = apiclient.New("localhost:3245") - var busResp *apitypes.BusCreateResponse + c = viiperclient.New("localhost:3245") + var busResp *viipertypes.BusCreateResponse var err error for range 10 { busResp, err = c.BusCreate(1) @@ -208,11 +208,11 @@ func Benchmark_Xbox360_Delay(b *testing.B) { b.Fatalf("DeviceAdd failed: %v", err) } - devStream, err := c.OpenStream(ctx, busID, devInfo.DevId) + devStream, err := c.OpenStream(ctx, busID, devInfo.DevID) if err != nil { b.Fatalf("OpenStream failed: %v", err) } - defer devStream.Close() + defer devStream.Close() //nolint:errcheck var gamepad *sdl.Gamepad for range 10 { @@ -224,7 +224,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { if err != nil { b.Fatalf("OpenGamepad failed: %v", err) } - defer gamepad.Close() + defer gamepad.Close() //nolint:errcheck break } } @@ -257,7 +257,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { for _, bench := range benches { if bench.useEncryption { - c = apiclient.NewWithPassword("localhost:3245", "testpassword1234") + c = viiperclient.NewWithPassword("localhost:3245", "testpassword1234") } b.Run(bench.name, func(b *testing.B) { for b.Loop() { diff --git a/_testing/test_server.go b/_testing/test_server.go index 8228c835..85829923 100644 --- a/_testing/test_server.go +++ b/_testing/test_server.go @@ -22,7 +22,7 @@ func NewTestServerWithConfig(t *testing.T, cfg *config.CLI) *MockServer { logger := slog.Default() - usbServer := usb.New(cfg.Server.UsbServerConfig, logger, nil) + usbServer := usb.New(cfg.Server.USBServerConfig, logger, nil) usbErrCh := make(chan error, 1) go func() { @@ -44,8 +44,8 @@ func NewTestServerWithConfig(t *testing.T, cfg *config.CLI) *MockServer { UsbServer: usbServer, ApiServer: api.New( usbServer, - cfg.Server.ApiServerConfig.Addr, - cfg.Server.ApiServerConfig, + cfg.Server.APIServerConfig.Addr, + cfg.Server.APIServerConfig, logger, ), } @@ -63,12 +63,12 @@ func TestServerConfig(t *testing.T) *config.CLI { return &config.CLI{ Server: cmd.Server{ - UsbServerConfig: usb.ServerConfig{ + USBServerConfig: usb.ServerConfig{ Addr: "localhost:0", ConnectionTimeout: 1 * time.Second, BusCleanupTimeout: 1 * time.Second, }, - ApiServerConfig: api.ServerConfig{ + APIServerConfig: api.ServerConfig{ Addr: "localhost:0", DeviceHandlerConnectTimeout: 1 * time.Second, ConnectionTimeout: 1 * time.Second, diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go index a3247645..17a73b61 100644 --- a/_testing/usbip_client.go +++ b/_testing/usbip_client.go @@ -61,7 +61,7 @@ func (c *TestUsbIpClient) ListDevices() ([]Device, error) { if err != nil { return nil, err } - defer conn.Close() + defer conn.Close() //nolint:errcheck if err := (&usbip.MgmtHeader{Version: usbip.Version, Command: usbip.OpReqDevlist}).Write(conn); err != nil { return nil, err diff --git a/cmd/viiper/viiper.go b/cmd/viiper/viiper.go index f580411e..7107912e 100644 --- a/cmd/viiper/viiper.go +++ b/cmd/viiper/viiper.go @@ -33,14 +33,14 @@ func main() { kong.Name("VIIPER"), kong.Description(Description()), kong.UsageOnError(), - kong.Help(helpWithAsciiArt), + 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) + 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) @@ -73,7 +73,7 @@ func main() { func handlePlainHelpFlag() { for i, arg := range os.Args[1:] { if arg == "-p" { - os.Setenv("VIIPER_HELP_STYLE", "plain") + os.Setenv("VIIPER_HELP_STYLE", "plain") // nolint os.Args[i+1] = "-h" return } @@ -93,22 +93,22 @@ func findUserConfig(args []string) string { } func setupRawLogger(cli *config.CLI, logger *slog.Logger, closeFiles *[]io.Closer) log.RawLogger { - if cli.Log.RawFile != "" { - f, err := os.OpenFile(cli.Log.RawFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + 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) + 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" { + if cli.Log.Level == "trace" { // nolint return log.NewRaw(os.Stdout) } return log.NewRaw(nil) } -func helpWithAsciiArt(options kong.HelpOptions, ctx *kong.Context) error { +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 == "" { diff --git a/device/device_attach_test.go b/device/device_attach_test.go index ee29a226..25f350a7 100644 --- a/device/device_attach_test.go +++ b/device/device_attach_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" - "github.com/Alia5/VIIPER/apiclient" "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" @@ -34,8 +34,8 @@ func TestDeviceAttach(t *testing.T) { t.Run(tc.deviceType, func(t *testing.T) { s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -44,14 +44,17 @@ func TestDeviceAttach(t *testing.T) { if err := s.ApiServer.Start(); err != nil { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() - s.UsbServer.AddBus(b) + defer b.Close() // nolint + err = s.UsbServer.AddBus(b) + if err != nil { + t.Fatalf("Failed to add bus to USB server: %v", err) + } - c := apiclient.New(s.ApiServer.Addr()) + c := viiperclient.New(s.ApiServer.Addr()) stream, addResp, err := c.AddDeviceAndConnect(context.Background(), b.BusID(), tc.deviceType, nil) if !assert.NoError(t, err) { @@ -61,10 +64,10 @@ func TestDeviceAttach(t *testing.T) { assert.NotNil(t, addResp) assert.Equal(t, tc.deviceType, addResp.Type) assert.Equal(t, b.BusID(), addResp.BusID) - assert.Equal(t, "1", addResp.DevId) + assert.Equal(t, "1", addResp.DevID) if stream != nil { - defer stream.Close() + defer stream.Close() //nolint:errcheck } usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) @@ -90,7 +93,7 @@ func TestDeviceAttach(t *testing.T) { return } if imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() // nolint } if !assert.NotNil(t, imp.Conn) { return diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 6e041d7e..a98a2ec6 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -27,11 +27,11 @@ func New(o *device.CreateOptions) (*DualShock4, error) { descriptor: defaultDescriptor, } if o != nil { - if o.IdVendor != nil { - d.descriptor.Device.IDVendor = *o.IdVendor + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor } - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct } } @@ -174,7 +174,7 @@ func (d *DualShock4) GetDescriptor() *usb.Descriptor { return &d.descriptor } -func (x *DualShock4) GetDeviceSpecificArgs() map[string]any { +func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return map[string]any{} } diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 366c0223..31978c04 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -8,11 +8,11 @@ import ( "time" viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/dualshock4" "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" @@ -102,7 +102,7 @@ func TestInputReports(t *testing.T) { LY: 0, RX: 0, RY: 0, - Buttons: uint16(dualshock4.ButtonSquare), + Buttons: dualshock4.ButtonSquare, DPad: 0, Touch1Active: false, Touch2Active: false, @@ -235,8 +235,8 @@ func TestInputReports(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -246,19 +246,19 @@ func TestInputReports(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + 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() + defer stream.Close() //nolint:errcheck usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -273,7 +273,7 @@ func TestInputReports(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } var seq uint32 @@ -412,8 +412,8 @@ func TestFeedback(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -423,19 +423,19 @@ func TestFeedback(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + 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() + defer stream.Close() //nolint:errcheck usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -450,7 +450,7 @@ func TestFeedback(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } for _, tc := range cases { diff --git a/device/dualshock4/inputstate.go b/device/dualshock4/inputstate.go index b6ab381b..5c6e4d86 100644 --- a/device/dualshock4/inputstate.go +++ b/device/dualshock4/inputstate.go @@ -5,6 +5,7 @@ import ( "io" ) +// 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 @@ -82,6 +83,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { 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) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 54218307..c4d9d00f 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -27,11 +27,11 @@ func New(o *device.CreateOptions) (*Keyboard, error) { descriptor: defaultDescriptor, } if o != nil { - if o.IdVendor != nil { - d.descriptor.Device.IDVendor = *o.IdVendor + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor } - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct } } return d, nil @@ -217,6 +217,6 @@ func (k *Keyboard) GetDescriptor() *usb.Descriptor { return &k.descriptor } -func (x *Keyboard) GetDeviceSpecificArgs() map[string]any { +func (k *Keyboard) GetDeviceSpecificArgs() map[string]any { return map[string]any{} } diff --git a/device/keyboard/keyboard_test.go b/device/keyboard/keyboard_test.go index c466f6f7..d11ba3b2 100644 --- a/device/keyboard/keyboard_test.go +++ b/device/keyboard/keyboard_test.go @@ -7,11 +7,11 @@ import ( "time" viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/keyboard" "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" @@ -62,8 +62,8 @@ func TestInputReports(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -73,19 +73,19 @@ func TestInputReports(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() // nolint _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + client := viiperclient.New(s.ApiServer.Addr()) stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "keyboard", nil) if !assert.NoError(t, err) { return } - defer stream.Close() + defer stream.Close() // nolint usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -100,7 +100,7 @@ func TestInputReports(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() // nolint } for _, tc := range cases { @@ -156,8 +156,8 @@ func TestLEDs(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -167,19 +167,19 @@ func TestLEDs(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() // nolint _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + client := viiperclient.New(s.ApiServer.Addr()) stream, _, err := client.AddDeviceAndConnect(context.Background(), b.BusID(), "keyboard", nil) if !assert.NoError(t, err) { return } - defer stream.Close() + defer stream.Close() // nolint usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -194,7 +194,7 @@ func TestLEDs(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() // nolint } for _, tc := range cases { diff --git a/device/mouse/const.go b/device/mouse/const.go index b7c277d5..fbabc6a1 100644 --- a/device/mouse/const.go +++ b/device/mouse/const.go @@ -3,9 +3,9 @@ package mouse // Button bit masks for virtual mouse input reports. // These align with the Buttons bitfield in InputState. const ( - Btn_Left = 0x01 - Btn_Right = 0x02 - Btn_Middle = 0x04 - Btn_Back = 0x08 - Btn_Forward = 0x10 + BtnLeft = 0x01 + BtnRight = 0x02 + BtnMiddle = 0x04 + BtnBack = 0x08 + BtnForward = 0x10 ) diff --git a/device/mouse/device.go b/device/mouse/device.go index 0f567fbe..0a4ee50c 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -26,11 +26,11 @@ func New(o *device.CreateOptions) (*Mouse, error) { descriptor: defaultDescriptor, } if o != nil { - if o.IdVendor != nil { - d.descriptor.Device.IDVendor = *o.IdVendor + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor } - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct } } return d, nil @@ -180,6 +180,6 @@ func (m *Mouse) GetDescriptor() *usb.Descriptor { return &m.descriptor } -func (x *Mouse) GetDeviceSpecificArgs() map[string]any { +func (m *Mouse) GetDeviceSpecificArgs() map[string]any { return map[string]any{} } diff --git a/device/mouse/handler.go b/device/mouse/handler.go index 46dbc385..fa7d4e8c 100644 --- a/device/mouse/handler.go +++ b/device/mouse/handler.go @@ -19,7 +19,7 @@ type handler struct{} func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } -func (r *handler) StreamHandler() api.StreamHandlerFunc { +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") diff --git a/device/mouse/mouse_test.go b/device/mouse/mouse_test.go index f80a95f2..0864ae84 100644 --- a/device/mouse/mouse_test.go +++ b/device/mouse/mouse_test.go @@ -6,10 +6,10 @@ import ( "time" viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" "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" @@ -38,7 +38,7 @@ func TestInputReports(t *testing.T) { { name: "Left down", inputState: mouse.InputState{ - Buttons: mouse.Btn_Left, + Buttons: mouse.BtnLeft, DX: 0, DY: 0, Pan: 0, @@ -49,7 +49,7 @@ func TestInputReports(t *testing.T) { { name: "right down", inputState: mouse.InputState{ - Buttons: mouse.Btn_Right, + Buttons: mouse.BtnRight, DX: 0, DY: 0, Pan: 0, @@ -60,7 +60,7 @@ func TestInputReports(t *testing.T) { { name: "all down", inputState: mouse.InputState{ - Buttons: mouse.Btn_Right | mouse.Btn_Left | mouse.Btn_Middle | mouse.Btn_Back | mouse.Btn_Forward, + Buttons: mouse.BtnRight | mouse.BtnLeft | mouse.BtnMiddle | mouse.BtnBack | mouse.BtnForward, DX: 0, DY: 0, Pan: 0, @@ -126,7 +126,7 @@ func TestInputReports(t *testing.T) { { name: "Move right 100, down 50, left button", inputState: mouse.InputState{ - Buttons: mouse.Btn_Left, + Buttons: mouse.BtnLeft, DX: 100, DY: 50, Pan: 0, @@ -137,8 +137,8 @@ func TestInputReports(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -148,19 +148,19 @@ func TestInputReports(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + 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() + defer stream.Close() //nolint:errcheck usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -175,7 +175,7 @@ func TestInputReports(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } for _, tc := range cases { diff --git a/device/options.go b/device/options.go index 4dd372a0..285937a5 100644 --- a/device/options.go +++ b/device/options.go @@ -1,7 +1,7 @@ package device type CreateOptions struct { - IdVendor *uint16 - IdProduct *uint16 + IDVendor *uint16 + IDProduct *uint16 DeviceSpecific map[string]any } diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 8801a37f..4324423d 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -30,11 +30,11 @@ func New(o *device.CreateOptions) (*Xbox360, error) { descriptor: MakeDescriptor(), } if o != nil { - if o.IdVendor != nil { - d.descriptor.Device.IDVendor = *o.IdVendor + if o.IDVendor != nil { + d.descriptor.Device.IDVendor = *o.IDVendor } - if o.IdProduct != nil { - d.descriptor.Device.IDProduct = *o.IdProduct + if o.IDProduct != nil { + d.descriptor.Device.IDProduct = *o.IDProduct } if o.DeviceSpecific != nil { data, err := json.Marshal(o.DeviceSpecific) diff --git a/device/xbox360/handler.go b/device/xbox360/handler.go index 59d045b3..88450234 100644 --- a/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -19,7 +19,7 @@ type handler struct{} func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } -func (r *handler) StreamHandler() api.StreamHandlerFunc { +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") diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 36062baf..1865f1af 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -19,6 +19,7 @@ type InputState struct { Reserved [6]byte } +// 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 diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go index 783e35eb..7e8a0a01 100644 --- a/device/xbox360/xbox360_test.go +++ b/device/xbox360/xbox360_test.go @@ -7,11 +7,11 @@ import ( "time" viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" "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" @@ -327,8 +327,8 @@ func TestInputReports(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -338,19 +338,19 @@ func TestInputReports(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + 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() + defer stream.Close() //nolint:errcheck usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -365,7 +365,7 @@ func TestInputReports(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } for _, tc := range cases { @@ -419,8 +419,8 @@ func TestRumble(t *testing.T) { } s := viiperTesting.NewTestServer(t) - defer s.UsbServer.Close() - defer s.ApiServer.Close() + 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)) @@ -430,19 +430,19 @@ func TestRumble(t *testing.T) { t.Fatalf("Failed to start API server: %v", err) } - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) - client := apiclient.New(s.ApiServer.Addr()) + 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() + defer stream.Close() //nolint:errcheck usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) devs, err := usbipClient.ListDevices() @@ -457,7 +457,7 @@ func TestRumble(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } for _, tc := range cases { diff --git a/docs/cli/codegen.md b/docs/cli/codegen.md index 3f0444e1..01b271ab 100644 --- a/docs/cli/codegen.md +++ b/docs/cli/codegen.md @@ -62,7 +62,7 @@ go run ./cmd/viiper codegen --lang=csharp Run codegen when any of these change: -- `/apitypes/*.go`: API response structures +- `/viipertypes/*.go`: API response structures - `/device/*/inputstate.go`: Wire format annotations - `/device/*/const.go`: Exported constants - `internal/server/api/*.go`: Route registrations diff --git a/docs/clients/generator.md b/docs/clients/generator.md index dfa9100c..6e2ba295 100644 --- a/docs/clients/generator.md +++ b/docs/clients/generator.md @@ -67,7 +67,7 @@ No special tags are required. Exported Go constants and maps are emitted with la **Scan Phase:** 1. Parse API routes from `internal/server/api/*.go` -2. Reflect response DTOs from `/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 and map literals from `/device/*/const.go` (automatic) @@ -178,7 +178,7 @@ public static class CharToKey Run codegen when any of these change: -- `/apitypes/*.go`: API response structures +- `/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 diff --git a/docs/clients/go.md b/docs/clients/go.md index 80d9e87c..9d479698 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -27,7 +27,7 @@ import ( func main() { // Create new Viiper client - client := apiclient.New("127.0.0.1:3242") + client := viiperclient.New("127.0.0.1:3242") ctx := context.Background() // Create or find a bus @@ -165,12 +165,12 @@ The Go client provides device packages under `/device/` with type-safe structs a ### 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. diff --git a/examples/cpp/virtual_mouse.cpp b/examples/cpp/virtual_mouse.cpp index bec5181a..671b3077 100644 --- a/examples/cpp/virtual_mouse.cpp +++ b/examples/cpp/virtual_mouse.cpp @@ -118,7 +118,7 @@ int main(int argc, char** argv) { // 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::Btn_Left, + .buttons = viiper::mouse::BtnLeft, .dx = 0, .dy = 0, .wheel = 0, .pan = 0, }); std::this_thread::sleep_for(std::chrono::milliseconds(60)); diff --git a/examples/csharp/virtual_ds4_pad/Program.cs b/examples/csharp/virtual_ds4_pad/Program.cs index cb146191..56cd75f5 100644 --- a/examples/csharp/virtual_ds4_pad/Program.cs +++ b/examples/csharp/virtual_ds4_pad/Program.cs @@ -40,8 +40,8 @@ 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}"); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); } catch (Exception ex) { @@ -55,7 +55,7 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevId); Console.WriteLine($"Removed device {resp.DevId}"); } catch { } + 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 { } } } diff --git a/examples/csharp/virtual_keyboard/Program.cs b/examples/csharp/virtual_keyboard/Program.cs index fcdd75c5..c2ec9ab9 100644 --- a/examples/csharp/virtual_keyboard/Program.cs +++ b/examples/csharp/virtual_keyboard/Program.cs @@ -49,8 +49,8 @@ { // 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}"); + device = await client.ConnectDeviceAsync(deviceInfo.BusID, deviceInfo.DevID); + Console.WriteLine($"Created and connected to device {deviceInfo.DevID} on bus {deviceInfo.BusID}"); } catch (Exception ex) { @@ -68,7 +68,7 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(deviceInfo.BusID, deviceInfo.DevId); Console.WriteLine($"Removed device {deviceInfo.DevId}"); } catch { } + 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 { } diff --git a/examples/csharp/virtual_mouse/Program.cs b/examples/csharp/virtual_mouse/Program.cs index ecd88352..2ac075f3 100644 --- a/examples/csharp/virtual_mouse/Program.cs +++ b/examples/csharp/virtual_mouse/Program.cs @@ -40,8 +40,8 @@ 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}"); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); } catch (Exception ex) { @@ -55,7 +55,7 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevId); Console.WriteLine($"Removed device {resp.DevId}"); } catch { } + 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 { } } } @@ -79,7 +79,7 @@ async Task Cleanup() 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 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)"); diff --git a/examples/csharp/virtual_x360_pad/Program.cs b/examples/csharp/virtual_x360_pad/Program.cs index b27ebdff..2887b7ef 100644 --- a/examples/csharp/virtual_x360_pad/Program.cs +++ b/examples/csharp/virtual_x360_pad/Program.cs @@ -40,8 +40,8 @@ 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}"); + device = await client.ConnectDeviceAsync(resp.BusID, resp.DevID); + Console.WriteLine($"Created and connected to device {resp.DevID} on bus {resp.BusID}"); } catch (Exception ex) { @@ -55,7 +55,7 @@ async Task Cleanup() { - try { await client.BusDeviceRemoveAsync(resp.BusID, resp.DevId); Console.WriteLine($"Removed device {resp.DevId}"); } catch { } + 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 { } } } diff --git a/examples/go/virtual_ds4/main.go b/examples/go/virtual_ds4/main.go index a861fbe0..9e4b8c97 100644 --- a/examples/go/virtual_ds4/main.go +++ b/examples/go/virtual_ds4/main.go @@ -12,8 +12,8 @@ import ( "syscall" "time" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/viiperclient" ) func main() { @@ -25,7 +25,7 @@ func main() { addr := os.Args[1] ctx := context.Background() - api := apiclient.New(addr) + api := viiperclient.New(addr) // Find or create a bus busesResp, err := api.BusListCtx(ctx) @@ -63,15 +63,15 @@ func main() { } os.Exit(1) } - defer stream.Close() + defer stream.Close() //nolint:errcheck - fmt.Printf("Created and connected to DualShock 4 device %s on bus %d\n", addResp.DevId, addResp.BusID) + 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) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/go/virtual_ds4_cli/main.go b/examples/go/virtual_ds4_cli/main.go index d4ded9c9..42ad99c9 100644 --- a/examples/go/virtual_ds4_cli/main.go +++ b/examples/go/virtual_ds4_cli/main.go @@ -14,8 +14,8 @@ import ( "syscall" "time" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/dualshock4" + "github.com/Alia5/VIIPER/viiperclient" ) // Usage: @@ -51,7 +51,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - api := apiclient.New(addr) + api := viiperclient.New(addr) busesResp, err := api.BusListCtx(ctx) if err != nil { @@ -88,9 +88,9 @@ func main() { } os.Exit(1) } - defer stream.Close() + defer stream.Close() //nolint:errcheck - fmt.Printf("Connected to DualShock 4 device %s on bus %d\n", addResp.DevId, addResp.BusID) + 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 { @@ -248,7 +248,7 @@ func main() { after := box.state box.timers[id] = time.AfterFunc(dur, func() { box.mu.Lock() - _ = revertKey(&box.state, id, before, after) + revertKey(&box.state, id, before, after) box.mu.Unlock() }) } @@ -529,25 +529,25 @@ func applyKeyValue(st *dualshock4.InputState, key string, val string) error { if err != nil { return err } - setButton(uint16(dualshock4.ButtonSquare), on) + setButton((dualshock4.ButtonSquare), on) case "cross", "x": on, err := parseBool() if err != nil { return err } - setButton(uint16(dualshock4.ButtonCross), on) + setButton((dualshock4.ButtonCross), on) case "circle": on, err := parseBool() if err != nil { return err } - setButton(uint16(dualshock4.ButtonCircle), on) + setButton((dualshock4.ButtonCircle), on) case "triangle": on, err := parseBool() if err != nil { return err } - setButton(uint16(dualshock4.ButtonTriangle), on) + setButton((dualshock4.ButtonTriangle), on) case "l1": on, err := parseBool() @@ -629,7 +629,7 @@ func applyKeyValue(st *dualshock4.InputState, key string, val string) error { return nil } -func revertKey(st *dualshock4.InputState, key string, before dualshock4.InputState, after dualshock4.InputState) error { +func revertKey(st *dualshock4.InputState, key string, before dualshock4.InputState, after dualshock4.InputState) { switch key { case "lx": st.LX = before.LX @@ -678,5 +678,4 @@ func revertKey(st *dualshock4.InputState, key string, before dualshock4.InputSta default: _ = after } - return nil } diff --git a/examples/go/virtual_keyboard/main.go b/examples/go/virtual_keyboard/main.go index 8f2ab5a7..1399e3b1 100644 --- a/examples/go/virtual_keyboard/main.go +++ b/examples/go/virtual_keyboard/main.go @@ -11,8 +11,8 @@ import ( "syscall" "time" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/keyboard" + "github.com/Alia5/VIIPER/viiperclient" ) func main() { @@ -24,7 +24,7 @@ func main() { addr := os.Args[1] ctx := context.Background() - api := apiclient.New(addr) + api := viiperclient.New(addr) // Find or create a bus busesResp, err := api.BusListCtx(ctx) @@ -62,16 +62,16 @@ func main() { } os.Exit(1) } - defer stream.Close() + defer stream.Close() //nolint:errcheck - fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevId, addResp.BusID) + 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) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/go/virtual_mouse/main.go b/examples/go/virtual_mouse/main.go index dda557d0..05d308ee 100644 --- a/examples/go/virtual_mouse/main.go +++ b/examples/go/virtual_mouse/main.go @@ -8,8 +8,8 @@ import ( "syscall" "time" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/mouse" + "github.com/Alia5/VIIPER/viiperclient" ) func main() { @@ -21,7 +21,7 @@ func main() { addr := os.Args[1] ctx := context.Background() - api := apiclient.New(addr) + api := viiperclient.New(addr) // Find or create a bus busesResp, err := api.BusListCtx(ctx) @@ -59,16 +59,16 @@ func main() { } os.Exit(1) } - defer stream.Close() + defer stream.Close() //nolint:errcheck - fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevId, addResp.BusID) + 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) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { @@ -117,7 +117,7 @@ func main() { // Simulate a short left click: press then release time.Sleep(50 * time.Millisecond) - press := &mouse.InputState{Buttons: mouse.Btn_Left} + press := &mouse.InputState{Buttons: mouse.BtnLeft} if err := stream.WriteBinary(press); err != nil { fmt.Printf("Write error (press): %v\n", err) return diff --git a/examples/go/virtual_x360_pad/main.go b/examples/go/virtual_x360_pad/main.go index 2a7a14b0..026d43e6 100644 --- a/examples/go/virtual_x360_pad/main.go +++ b/examples/go/virtual_x360_pad/main.go @@ -11,8 +11,8 @@ import ( "syscall" "time" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device/xbox360" + "github.com/Alia5/VIIPER/viiperclient" ) func main() { @@ -24,7 +24,7 @@ func main() { addr := os.Args[1] ctx := context.Background() - api := apiclient.New(addr) + api := viiperclient.New(addr) // Find or create a bus busesResp, err := api.BusListCtx(ctx) @@ -62,16 +62,16 @@ func main() { } os.Exit(1) } - defer stream.Close() + defer stream.Close() //nolint:errcheck - fmt.Printf("Created and connected to device %s on bus %d\n", addResp.DevId, addResp.BusID) + 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) + fmt.Printf("Removed device %d-%s\n", addResp.BusID, addResp.DevID) } if createdBus { if _, err := api.BusRemoveCtx(ctx, busID); err != nil { diff --git a/examples/rust/async/virtual_mouse/src/main.rs b/examples/rust/async/virtual_mouse/src/main.rs index 3a6217fc..e192c53d 100644 --- a/examples/rust/async/virtual_mouse/src/main.rs +++ b/examples/rust/async/virtual_mouse/src/main.rs @@ -127,7 +127,7 @@ async fn main() { // Simulate a short left click: press then release sleep(Duration::from_millis(50)).await; let _ = stream.send(&MouseInput { - buttons: BTN__LEFT, + buttons: BTN_LEFT, dx: 0, dy: 0, wheel: 0, diff --git a/examples/rust/sync/virtual_mouse/src/main.rs b/examples/rust/sync/virtual_mouse/src/main.rs index c5d441f3..9edbebaf 100644 --- a/examples/rust/sync/virtual_mouse/src/main.rs +++ b/examples/rust/sync/virtual_mouse/src/main.rs @@ -130,7 +130,7 @@ fn main() { // Simulate a short left click: press then release thread::sleep(Duration::from_millis(50)); let _ = stream.send(&MouseInput { - buttons: BTN__LEFT, + buttons: BTN_LEFT, dx: 0, dy: 0, wheel: 0, diff --git a/examples/typescript/virtual_mouse.ts b/examples/typescript/virtual_mouse.ts index 6a6aedfb..8ae6e7ad 100644 --- a/examples/typescript/virtual_mouse.ts +++ b/examples/typescript/virtual_mouse.ts @@ -1,6 +1,6 @@ import { ViiperClient, ViiperDevice, Mouse, Types } from "viiperclient"; -const { MouseInput, Btn_ } = Mouse; +const { MouseInput, Btn } = Mouse; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -101,7 +101,7 @@ async function main() { // 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 }); + 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 }); diff --git a/internal/_testing/api_test_helpers.go b/internal/_testing/api_test_helpers.go index 3fdbc641..00c21bf5 100644 --- a/internal/_testing/api_test_helpers.go +++ b/internal/_testing/api_test_helpers.go @@ -58,7 +58,7 @@ func ExecCmd(t *testing.T, addr string, cmd string) string { 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) diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 2ec3d586..43f83729 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -48,9 +48,10 @@ func (c *ConfigInit) Run() error { dest := c.Output if dest == "" { ext := "json" - if format == "yaml" { + switch format { + case "yaml": ext = "yaml" - } else if format == "toml" { + case "toml": ext = "toml" } dest = c.Command + "." + ext diff --git a/internal/cmd/install_windows.go b/internal/cmd/install_windows.go index 0e94585a..69de36c7 100644 --- a/internal/cmd/install_windows.go +++ b/internal/cmd/install_windows.go @@ -48,7 +48,7 @@ func install(logger *slog.Logger) error { if err != nil { return err } - defer key.Close() + defer key.Close() //nolint:errcheck if err := key.SetStringValue(runValueKey, value); err != nil { return err @@ -80,7 +80,7 @@ func uninstall(logger *slog.Logger) error { return err } } else { - defer key.Close() + defer key.Close() //nolint:errcheck if err := key.DeleteValue(runValueKey); err != nil { if !errors.Is(err, registry.ErrNotExist) { @@ -107,7 +107,7 @@ func currentAutorunExe() (string, error) { } return "", err } - defer key.Close() + defer key.Close() //nolint:errcheck val, _, err := key.GetStringValue(runValueKey) if err != nil { diff --git a/internal/cmd/proxy.go b/internal/cmd/proxy.go index 13ba56b8..2a64bbb5 100644 --- a/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -25,7 +25,7 @@ func (p *Proxy) Run(logger *slog.Logger, rawLogger log.RawLogger) error { defer stop() if p.UpstreamAddr == "" { - return errors.New("Upstream address is empty") + return errors.New("upstream address is empty") } logger.Info("Starting VIIPER USB-IP proxy", "listen", p.ListenAddr, "upstream", p.UpstreamAddr) @@ -40,7 +40,7 @@ func (p *Proxy) Run(logger *slog.Logger, rawLogger log.RawLogger) error { case <-ctx.Done(): logger.Info("Shutting down proxy server") _ = proxySrv.Close() - _ = <-proxyErrCh + _ = <-proxyErrCh // nolint return nil case err := <-proxyErrCh: return err diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 3375e660..4661f09a 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -24,8 +24,8 @@ import ( const keyFileName = "viiper.key.txt" type Server struct { - UsbServerConfig usb.ServerConfig `embed:"" prefix:"usb."` - ApiServerConfig api.ServerConfig `embed:"" prefix:"api."` + 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"` } @@ -41,11 +41,11 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger defer cancel() go tray.Run(cancel) - s.UsbServerConfig.ConnectionTimeout = s.ConnectionTimeout - s.ApiServerConfig.ConnectionTimeout = s.ConnectionTimeout - s.UsbServerConfig.BusCleanupTimeout = s.ApiServerConfig.DeviceHandlerConnectTimeout + 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) + logger.Info("Starting VIIPER USB-IP server", "addr", s.USBServerConfig.Addr) keyFileDir, err := configpaths.KeyFileDir() if err != nil { @@ -53,7 +53,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger } keyFilePath := filepath.Join(keyFileDir, keyFileName) if pwd, err := os.ReadFile(keyFilePath); err == nil { - s.ApiServerConfig.Password = strings.TrimSpace(string(pwd)) + s.APIServerConfig.Password = strings.TrimSpace(string(pwd)) } else { newPwd, err := auth.GenerateKey() if err != nil { @@ -65,7 +65,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger 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 + s.APIServerConfig.Password = newPwd logger.Info("Generated API server password", "path", keyFilePath) logger.Info("-------------------------------------") logger.Info("Your VIIPER API server password is:") @@ -75,7 +75,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Info("You can change this password at any time by editing the file") } - usbSrv := usb.New(s.UsbServerConfig, logger, rawLogger) + usbSrv := usb.New(s.USBServerConfig, logger, rawLogger) usbErrCh := make(chan error, 1) go func() { @@ -88,12 +88,12 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger case <-usbSrv.Ready(): } - if s.ApiServerConfig.Addr == "" { + 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).") + return fmt.Errorf("API server address must be set (default :3242).") // nolint } - apiSrv := api.New(usbSrv, s.ApiServerConfig.Addr, s.ApiServerConfig, logger) + 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)) @@ -104,9 +104,9 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger r.Register("bus/{id}/remove", handler.BusDeviceRemove(usbSrv)) r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) - if s.ApiServerConfig.AutoAttachLocalClient { + if s.APIServerConfig.AutoAttachLocalClient { logger.Info("Auto-attach is enabled, checking prerequisites...") - if !api.CheckAutoAttachPrerequisites(s.ApiServerConfig.AutoAttachWindowsNative, logger) { + 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") @@ -119,7 +119,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger logger.Error("failed to start API server", "error", err) if util.IsRunFromGUI() { fmt.Println("Press any key to exit...") - var b []byte = make([]byte, 1) + b := make([]byte, 1) _, _ = os.Stdin.Read(b) } return err @@ -138,7 +138,7 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger apiSrv.Close() } _ = usbSrv.Close() - _ = <-usbErrCh + _ = <-usbErrCh // nolint return nil case err := <-usbErrCh: if apiSrv != nil { diff --git a/internal/codegen/cmd/scan-dtos/main.go b/internal/codegen/cmd/scan-dtos/main.go index a27de101..5d0326e9 100644 --- a/internal/codegen/cmd/scan-dtos/main.go +++ b/internal/codegen/cmd/scan-dtos/main.go @@ -16,8 +16,8 @@ func main() { os.Exit(1) } - apitypesPkg := filepath.Join(projectRoot, "pkg", "apitypes") - schemas, err := scanner.ScanDTOsInPackage(apitypesPkg) + 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) diff --git a/internal/codegen/common/wiresize.go b/internal/codegen/common/wiresize.go index b18aa2c2..d0fb6879 100644 --- a/internal/codegen/common/wiresize.go +++ b/internal/codegen/common/wiresize.go @@ -58,9 +58,10 @@ func GetWireTag(md *meta.Metadata, deviceName, direction string) *scanner.WireTa return nil } dir := direction - if direction == "input" { + switch direction { + case "input": dir = "c2s" - } else if direction == "output" { + case "output": dir = "s2c" } return md.WireTags.GetTag(deviceName, dir) diff --git a/internal/codegen/generator/cpp/client.go b/internal/codegen/generator/cpp/client.go index 743a3e54..298c23fa 100644 --- a/internal/codegen/generator/cpp/client.go +++ b/internal/codegen/generator/cpp/client.go @@ -178,7 +178,7 @@ func generateClient(logger *slog.Logger, includeDir string, md *meta.Metadata) e if err != nil { return fmt.Errorf("create client.hpp: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Header string diff --git a/internal/codegen/generator/cpp/device.go b/internal/codegen/generator/cpp/device.go index 5479b350..09715c09 100644 --- a/internal/codegen/generator/cpp/device.go +++ b/internal/codegen/generator/cpp/device.go @@ -192,7 +192,7 @@ private: } // namespace viiper ` -func generateDevice(logger *slog.Logger, includeDir string, md *meta.Metadata) error { +func generateDevice(logger *slog.Logger, includeDir string, _ *meta.Metadata) error { logger.Debug("Generating device.hpp") outputFile := filepath.Join(includeDir, "device.hpp") diff --git a/internal/codegen/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go index 95863135..c25d4922 100644 --- a/internal/codegen/generator/cpp/device_header.go +++ b/internal/codegen/generator/cpp/device_header.go @@ -239,7 +239,7 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md if err != nil { return fmt.Errorf("create device header: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck hasMaps := false for _, m := range devicePkg.Maps { diff --git a/internal/codegen/generator/cpp/gen.go b/internal/codegen/generator/cpp/gen.go index bfb2bb3d..b7305141 100644 --- a/internal/codegen/generator/cpp/gen.go +++ b/internal/codegen/generator/cpp/gen.go @@ -44,7 +44,7 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } - if err := generateJson(logger, detailDir); err != nil { + if err := generateJSON(logger, detailDir); err != nil { return err } diff --git a/internal/codegen/generator/cpp/json.go b/internal/codegen/generator/cpp/json.go index 1352579b..11a7c04f 100644 --- a/internal/codegen/generator/cpp/json.go +++ b/internal/codegen/generator/cpp/json.go @@ -103,7 +103,7 @@ inline std::vector get_array(const json_type& j, const std::string& key) { } // namespace viiper ` -func generateJson(logger *slog.Logger, detailDir string) error { +func generateJSON(logger *slog.Logger, detailDir string) error { logger.Debug("Generating detail/json.hpp") outputFile := filepath.Join(detailDir, "json.hpp") diff --git a/internal/codegen/generator/cpp/main_header.go b/internal/codegen/generator/cpp/main_header.go index 74b33f28..4ff8c6f9 100644 --- a/internal/codegen/generator/cpp/main_header.go +++ b/internal/codegen/generator/cpp/main_header.go @@ -71,7 +71,7 @@ func generateMainHeader(logger *slog.Logger, includeDir string, md *meta.Metadat if err != nil { return fmt.Errorf("create viiper.hpp: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck devices := make([]string, 0, len(md.DevicePackages)) for deviceName := range md.DevicePackages { diff --git a/internal/codegen/generator/cpp/types.go b/internal/codegen/generator/cpp/types.go index 650027cb..a8039033 100644 --- a/internal/codegen/generator/cpp/types.go +++ b/internal/codegen/generator/cpp/types.go @@ -110,7 +110,7 @@ func generateTypes(logger *slog.Logger, includeDir string, md *meta.Metadata) er if err != nil { return fmt.Errorf("create types.hpp: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Header string diff --git a/internal/codegen/generator/csharp/client.go b/internal/codegen/generator/csharp/client.go index 421f4d5e..63eca613 100644 --- a/internal/codegen/generator/csharp/client.go +++ b/internal/codegen/generator/csharp/client.go @@ -157,7 +157,7 @@ func generateClient(logger *slog.Logger, projectDir string, md *meta.Metadata) e if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Routes []scanner.RouteInfo diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index b619073a..fab03ca2 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -73,7 +73,7 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, if err != nil { return fmt.Errorf("creating file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck tmpl := template.Must(template.New("constants").Parse(constantsTemplate)) if err := tmpl.Execute(f, data); err != nil { @@ -243,7 +243,11 @@ func isFlags(constants []constantInfo) bool { for _, c := range constants { if strings.HasPrefix(c.Value, "0x") { var val uint64 - fmt.Sscanf(c.Value, "0x%x", &val) + _, 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 } @@ -422,9 +426,10 @@ func formatMapKey(key string, goType string) string { } if len(key) == 1 { if key[0] >= 32 && key[0] <= 126 { - if key[0] == '\'' { + switch key[0] { + case '\'': return "(byte)'\\''" - } else if key[0] == '\\' { + case '\\': return "(byte)'\\\\'" } return fmt.Sprintf("(byte)'%s'", key) diff --git a/internal/codegen/generator/csharp/device.go b/internal/codegen/generator/csharp/device.go index f4135a9d..97efb383 100644 --- a/internal/codegen/generator/csharp/device.go +++ b/internal/codegen/generator/csharp/device.go @@ -159,7 +159,7 @@ func generateDevice(logger *slog.Logger, projectDir string, md *meta.Metadata) e if err != nil { return fmt.Errorf("create ViiperDevice.cs: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck if err := tmpl.Execute(f, md); err != nil { return fmt.Errorf("execute device template: %w", err) } diff --git a/internal/codegen/generator/csharp/device_types.go b/internal/codegen/generator/csharp/device_types.go index c72c15fe..7d3aba98 100644 --- a/internal/codegen/generator/csharp/device_types.go +++ b/internal/codegen/generator/csharp/device_types.go @@ -56,7 +56,7 @@ func generateWireClass(outputPath, device, className string, tag *scanner.WireTa if err != nil { return fmt.Errorf("creating file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Device string diff --git a/internal/codegen/generator/csharp/project.go b/internal/codegen/generator/csharp/project.go index 8d9f60c6..a55e52b2 100644 --- a/internal/codegen/generator/csharp/project.go +++ b/internal/codegen/generator/csharp/project.go @@ -38,7 +38,7 @@ const projectTemplate = ` ` -func generateProject(logger *slog.Logger, projectDir string, md *meta.Metadata, version string) error { +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) @@ -50,7 +50,7 @@ func generateProject(logger *slog.Logger, projectDir string, md *meta.Metadata, if err != nil { return fmt.Errorf("create csproj file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Version string diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go index 2a02d415..6344feb5 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -52,7 +52,7 @@ func generateTypes(logger *slog.Logger, typesDir string, md *meta.Metadata) erro if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { DTOs interface{} diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index 1ccff548..e0e90b66 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -75,7 +75,7 @@ func (g *Generator) GenerateLang(lang string) error { } func (g *Generator) ScanAll() (*meta.Metadata, error) { - requiredPaths := []string{"internal/cmd", "apitypes", "device"} + 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) @@ -98,7 +98,7 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { g.logger.Info("Found API routes", "count", len(routes)) g.logger.Debug("Scanning DTOs") - dtos, err := scanner.ScanDTOsInPackage("apitypes") + dtos, err := scanner.ScanDTOsInPackage("viipertypes") if err != nil { return nil, fmt.Errorf("failed to scan DTOs: %w", err) } diff --git a/internal/codegen/generator/rust/async_client.go b/internal/codegen/generator/rust/async_client.go index b7047ee7..426bb7a7 100644 --- a/internal/codegen/generator/rust/async_client.go +++ b/internal/codegen/generator/rust/async_client.go @@ -376,7 +376,7 @@ func generateAsyncClient(logger *slog.Logger, srcDir string, md *meta.Metadata) if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Header string diff --git a/internal/codegen/generator/rust/client.go b/internal/codegen/generator/rust/client.go index fd1910cb..12699949 100644 --- a/internal/codegen/generator/rust/client.go +++ b/internal/codegen/generator/rust/client.go @@ -249,7 +249,7 @@ func generateClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Header string diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go index 71a07e05..03b4db78 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -152,7 +152,7 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck if err := tmpl.Execute(f, data); err != nil { return fmt.Errorf("execute template: %w", err) diff --git a/internal/codegen/generator/rust/device_types.go b/internal/codegen/generator/rust/device_types.go index 9301c15e..0d70a7a2 100644 --- a/internal/codegen/generator/rust/device_types.go +++ b/internal/codegen/generator/rust/device_types.go @@ -192,7 +192,7 @@ func generateDeviceWireStruct(outputPath, deviceName, className string, tag *sca if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck if err := tmpl.Execute(f, data); err != nil { return fmt.Errorf("execute template: %w", err) diff --git a/internal/codegen/generator/rust/gen.go b/internal/codegen/generator/rust/gen.go index 35983774..caa69d6e 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -97,7 +97,7 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return nil } -func generateLibFile(logger *slog.Logger, srcDir string, md *meta.Metadata) error { +func generateLibFile(logger *slog.Logger, srcDir string, _ *meta.Metadata) error { logger.Debug("Generating lib.rs") outputFile := filepath.Join(srcDir, "lib.rs") diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go index 9c3f59d3..bbb739c1 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -99,18 +99,6 @@ func wireTypeToRust(wireType string) string { } } -func isWireArray(spec string) bool { - return strings.Contains(spec, "*") -} - -func extractArrayCount(spec string) string { - parts := strings.Split(spec, "*") - if len(parts) == 2 { - return parts[1] - } - return "" -} - func writeFileHeaderRust() string { return "// This file is auto-generated by VIIPER codegen. DO NOT EDIT.\n" } diff --git a/internal/codegen/generator/rust/project.go b/internal/codegen/generator/rust/project.go index d01f0fd2..c7221cce 100644 --- a/internal/codegen/generator/rust/project.go +++ b/internal/codegen/generator/rust/project.go @@ -67,7 +67,7 @@ func generateProject(logger *slog.Logger, projectDir string, version string) err if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Version string diff --git a/internal/codegen/generator/rust/types.go b/internal/codegen/generator/rust/types.go index 6da7d736..1db45330 100644 --- a/internal/codegen/generator/rust/types.go +++ b/internal/codegen/generator/rust/types.go @@ -83,7 +83,7 @@ func generateTypes(logger *slog.Logger, srcDir string, md *meta.Metadata) error if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Header string diff --git a/internal/codegen/generator/rust/wire.go b/internal/codegen/generator/rust/wire.go index c38f891d..1edb5c53 100644 --- a/internal/codegen/generator/rust/wire.go +++ b/internal/codegen/generator/rust/wire.go @@ -34,7 +34,7 @@ func generateWireModule(logger *slog.Logger, srcDir string) error { if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck if _, err := f.WriteString(wireModuleTemplate); err != nil { return fmt.Errorf("write template: %w", err) diff --git a/internal/codegen/generator/typescript/binary_utils.go b/internal/codegen/generator/typescript/binary_utils.go index 07b5425a..e94d7c09 100644 --- a/internal/codegen/generator/typescript/binary_utils.go +++ b/internal/codegen/generator/typescript/binary_utils.go @@ -45,7 +45,7 @@ func generateBinaryUtils(logger *slog.Logger, utilsDir string) error { if err != nil { return fmt.Errorf("write binary.ts: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck tmpl := template.Must(template.New("binaryts").Funcs(template.FuncMap{ "writeFileHeaderTS": writeFileHeaderTS, }).Parse(binaryUtilsTemplate)) diff --git a/internal/codegen/generator/typescript/client.go b/internal/codegen/generator/typescript/client.go index 24db4043..dbf06f95 100644 --- a/internal/codegen/generator/typescript/client.go +++ b/internal/codegen/generator/typescript/client.go @@ -164,7 +164,7 @@ func generateClient(logger *slog.Logger, srcDir string, md *meta.Metadata) error if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + 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) diff --git a/internal/codegen/generator/typescript/constants.go b/internal/codegen/generator/typescript/constants.go index b587b73c..41f9112e 100644 --- a/internal/codegen/generator/typescript/constants.go +++ b/internal/codegen/generator/typescript/constants.go @@ -58,7 +58,7 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck tmpl := template.Must(template.New("constsTS").Funcs(template.FuncMap{ "writeFileHeaderTS": writeFileHeaderTS, }).Parse(constantsTemplateTS)) diff --git a/internal/codegen/generator/typescript/device_types.go b/internal/codegen/generator/typescript/device_types.go index 5072b1bd..e83f40a7 100644 --- a/internal/codegen/generator/typescript/device_types.go +++ b/internal/codegen/generator/typescript/device_types.go @@ -109,7 +109,7 @@ func generateWireClassTS(outputPath, device, className string, tag *scanner.Wire if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck data := struct { Device string ClassName string diff --git a/internal/codegen/generator/typescript/index.go b/internal/codegen/generator/typescript/index.go index 5ea62566..d2400870 100644 --- a/internal/codegen/generator/typescript/index.go +++ b/internal/codegen/generator/typescript/index.go @@ -31,7 +31,7 @@ func generateIndex(logger *slog.Logger, srcDir string) error { if err != nil { return fmt.Errorf("write index.ts: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck tmpl := template.Must(template.New("index").Funcs(template.FuncMap{ "writeFileHeaderTS": writeFileHeaderTS, }).Parse(indexTemplate)) @@ -56,7 +56,7 @@ func generateDeviceIndex(logger *slog.Logger, deviceDir, deviceName string) erro if err != nil { return fmt.Errorf("write device index.ts: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck tmpl := template.Must(template.New("deviceIndex").Funcs(template.FuncMap{ "writeFileHeaderTS": writeFileHeaderTS, diff --git a/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go index f926a37c..9f443a10 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -43,7 +43,7 @@ func generateTypes(logger *slog.Logger, typesDir string, md *meta.Metadata) erro if err != nil { return fmt.Errorf("create file: %w", err) } - defer f.Close() + 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) diff --git a/internal/codegen/scanner/constants.go b/internal/codegen/scanner/constants.go index 84f3abc8..cf5de100 100644 --- a/internal/codegen/scanner/constants.go +++ b/internal/codegen/scanner/constants.go @@ -61,9 +61,10 @@ func ScanDeviceConstants(devicePkgPath string) (*DeviceConstants, error) { for _, decl := range file.Decls { if genDecl, ok := decl.(*ast.GenDecl); ok { - if genDecl.Tok == token.CONST { + switch genDecl.Tok { + case token.CONST: result.Constants = append(result.Constants, extractConstants(genDecl)...) - } else if genDecl.Tok == token.VAR { + case token.VAR: maps := extractMaps(genDecl) result.Maps = append(result.Maps, maps...) } diff --git a/internal/codegen/scanner/dtos_test.go b/internal/codegen/scanner/dtos_test.go index 797ebe64..b6588790 100644 --- a/internal/codegen/scanner/dtos_test.go +++ b/internal/codegen/scanner/dtos_test.go @@ -6,8 +6,8 @@ import ( ) func TestScanDTOs(t *testing.T) { - // Scan the apitypes package - schemas, err := ScanDTOsInPackage("../../..//apitypes") + // Scan the viipertypes package + schemas, err := ScanDTOsInPackage("../../..//viipertypes") if err != nil { t.Fatalf("ScanDTOsInPackage failed: %v", err) } @@ -20,7 +20,7 @@ func TestScanDTOs(t *testing.T) { // Expected DTOs expectedDTOs := map[string]bool{ - "ApiError": true, + "APIError": true, "BusListResponse": true, "BusCreateResponse": true, "BusRemoveResponse": true, diff --git a/internal/codegen/scanner/payload.go b/internal/codegen/scanner/payload.go index d13f3096..fade2387 100644 --- a/internal/codegen/scanner/payload.go +++ b/internal/codegen/scanner/payload.go @@ -399,7 +399,7 @@ func usesPayloadDirect(expr ast.Expr) bool { func extractTypeName(expr ast.Expr) string { switch v := expr.(type) { case *ast.SelectorExpr: - // e.g., apitypes.DeviceCreateRequest + // e.g., viipertypes.DeviceCreateRequest left := extractTypeName(v.X) if left == "" { return v.Sel.Name diff --git a/internal/codegen/scanner/returns.go b/internal/codegen/scanner/returns.go index c7563221..20cdb356 100644 --- a/internal/codegen/scanner/returns.go +++ b/internal/codegen/scanner/returns.go @@ -9,7 +9,7 @@ import ( "strings" ) -// ScanHandlerReturnDTOs scans handler implementations to find the apitypes.* struct +// 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) { @@ -82,7 +82,7 @@ func detectDTOType(expr ast.Expr) string { case *ast.CompositeLit: switch tt := v.Type.(type) { case *ast.SelectorExpr: - if pkg, ok := tt.X.(*ast.Ident); ok && pkg.Name == "apitypes" { + if pkg, ok := tt.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { return tt.Sel.Name } } @@ -93,7 +93,7 @@ func detectDTOType(expr ast.Expr) string { 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" { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { return sel.Sel.Name } } @@ -104,7 +104,7 @@ func detectDTOType(expr ast.Expr) string { 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" { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { return sel.Sel.Name } } @@ -112,7 +112,7 @@ func detectDTOType(expr ast.Expr) string { } if vs.Type != nil { if sel, ok := vs.Type.(*ast.SelectorExpr); ok { - if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "apitypes" { + if pkg, ok := sel.X.(*ast.Ident); ok && isDTOPackageName(pkg.Name) { return sel.Sel.Name } } @@ -122,3 +122,7 @@ func detectDTOType(expr ast.Expr) string { } return "" } + +func isDTOPackageName(name string) bool { + return name == "viipertypes" || name == "apitypes" +} diff --git a/internal/log/logging.go b/internal/log/logging.go index 841f3b74..54cd47ae 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -154,7 +154,7 @@ func (h *colorHandler) Handle(_ context.Context, r slog.Record) error { color = "\033[0m" } buf.WriteString(color) - buf.WriteString(fmt.Sprintf("%5s", r.Level.String())) + fmt.Fprintf(&buf, "%5s", r.Level.String()) buf.WriteString("\033[0m") buf.WriteString(" ") diff --git a/internal/server/api/auth/auth.go b/internal/server/api/auth/auth.go index 5a7ced6b..4fa4c034 100644 --- a/internal/server/api/auth/auth.go +++ b/internal/server/api/auth/auth.go @@ -32,7 +32,7 @@ func GenerateKey() (string, error) { // 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 nil, errors.New("password cannot be empty") } return pbkdf2.Key( sha256.New, diff --git a/internal/server/api/auth/auth_test.go b/internal/server/api/auth/auth_test.go index 2763b385..4146ec66 100644 --- a/internal/server/api/auth/auth_test.go +++ b/internal/server/api/auth/auth_test.go @@ -51,7 +51,7 @@ func TestDeriveKey(t *testing.T) { name: "empty password", password: "", expectedKey: []byte{}, - expectedErr: errors.New("Password cannot be empty"), + expectedErr: errors.New("password cannot be empty"), }, { name: "long password", diff --git a/internal/server/api/auth/conn_test.go b/internal/server/api/auth/conn_test.go index 9b18041f..7c2153c6 100644 --- a/internal/server/api/auth/conn_test.go +++ b/internal/server/api/auth/conn_test.go @@ -129,9 +129,9 @@ func TestConn(t *testing.T) { if err != nil { t.Fatalf("failed to accept connection: %v", err) } - defer ln.Close() - defer clientConn.Close() - defer serverConn.Close() + defer ln.Close() //nolint:errcheck + defer clientConn.Close() //nolint:errcheck + defer serverConn.Close() //nolint:errcheck var clientKey, serverKey []byte if tc.setupFn != nil { diff --git a/internal/server/api/auth/handshake.go b/internal/server/api/auth/handshake.go index c0f91cf2..0bad0b36 100644 --- a/internal/server/api/auth/handshake.go +++ b/internal/server/api/auth/handshake.go @@ -10,8 +10,8 @@ import ( "io" "strings" - apitypes "github.com/Alia5/VIIPER/apitypes" apierror "github.com/Alia5/VIIPER/internal/server/api/error" + "github.com/Alia5/VIIPER/viipertypes" ) const ( @@ -96,7 +96,7 @@ func HandleAuthHandshake(r *bufio.Reader, w io.Writer, key []byte, isClient bool raw := append(respPrefix, rest...) line := strings.TrimSuffix(string(raw), "\n") - var apiErr apitypes.ApiError + var apiErr viipertypes.APIError if err := json.Unmarshal([]byte(line), &apiErr); err == nil && (apiErr.Status != 0 || apiErr.Title != "") { return nil, nil, &apiErr } diff --git a/internal/server/api/auth/handshake_test.go b/internal/server/api/auth/handshake_test.go index 88f3e019..ff530734 100644 --- a/internal/server/api/auth/handshake_test.go +++ b/internal/server/api/auth/handshake_test.go @@ -87,7 +87,7 @@ func TestWriteServerHandshake(t *testing.T) { name: "Err closed writer", writer: func() io.Writer { _, w := io.Pipe() - w.Close() + w.Close() // nolint return w }(), expectedErr: fmt.Errorf("write response: io: read/write on closed pipe"), @@ -224,7 +224,7 @@ func TestFullHandshake(t *testing.T) { reader: bufio.NewReader(bytes.NewBuffer(validHandshake)), writer: func() io.Writer { _, w := io.Pipe() - w.Close() + w.Close() // nolint return w }(), key: validKey, diff --git a/internal/server/api/autoattach_linux.go b/internal/server/api/autoattach_linux.go index 07ac884d..311433e0 100644 --- a/internal/server/api/autoattach_linux.go +++ b/internal/server/api/autoattach_linux.go @@ -15,7 +15,7 @@ import ( ) 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) + logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID) cmd := exec.CommandContext( ctx, @@ -24,7 +24,7 @@ func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.Expo strconv.FormatUint(uint64(usbipServerPort), 10), "attach", "-r", "localhost", - "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusId, deviceExportMeta.DevId), + "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID), ) output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index 4c433c20..eed6ee96 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -24,18 +24,18 @@ var ( ) const ( - DIGCF_PRESENT = 0x00000002 - DIGCF_DEVICEINTERFACE = 0x00000010 + DigcfPresent = 0x00000002 + DigcfDeviceInterface = 0x00000010 ) -type SP_DEVICE_INTERFACE_DATA struct { +type SpDeviceInterfaceData struct { CbSize uint32 - InterfaceClassGuid windows.GUID + InterfaceClassGUID windows.GUID Flags uint32 Reserved uintptr } -type SP_DEVICE_INTERFACE_DETAIL_DATA struct { +type SpDeviceInterfaceDetailData struct { CbSize uint32 DevicePath [1]uint16 } @@ -77,18 +77,18 @@ func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.Expo return attachViaCommand(ctx, deviceExportMeta, usbipServerPort, logger) } -func attachViaIOCTL(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, logger *slog.Logger) error { +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) + "busID", deviceExportMeta.BusID, + "deviceID", deviceExportMeta.DevID) if usbipServerPort == 0 { - return fmt.Errorf("ArgumentValidation: invalid TCP port number (0)") + return fmt.Errorf("argumentValidation: invalid TCP port number (0)") } devicePath, err := getDeviceInterfacePath(&deviceGUID) if err != nil { - return fmt.Errorf("Discovery: %w", err) + return fmt.Errorf("discovery: %w", err) } logger.Debug("Found usbip-win2 device", "path", devicePath) @@ -96,22 +96,22 @@ func attachViaIOCTL(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usb var ioctlData attachIOCTL ioctlData.Size = uint32(unsafe.Sizeof(ioctlData)) - busID := fmt.Sprintf("%d-%d", deviceExportMeta.BusId, deviceExportMeta.DevId) + 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) + 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) + 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) + return fmt.Errorf("open: failed to convert device path: %w", err) } handle, err := windows.CreateFile( @@ -124,9 +124,9 @@ func attachViaIOCTL(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usb 0, ) if err != nil { - return fmt.Errorf("Open: failed to open usbip-win2 device: %w", err) + return fmt.Errorf("open: failed to open usbip-win2 device: %w", err) } - defer windows.CloseHandle(handle) + defer windows.CloseHandle(handle) // nolint logger.Debug("Opened device handle") @@ -152,15 +152,15 @@ func attachViaIOCTL(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usb } logger.Info("Successfully attached device via IOCTL", - "busID", deviceExportMeta.BusId, - "deviceID", deviceExportMeta.DevId, + "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) + logger.Info("Auto-attaching localhost client", "busID", deviceExportMeta.BusID, "deviceID", deviceExportMeta.DevID) cmd := exec.CommandContext( ctx, @@ -169,7 +169,7 @@ func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, u strconv.FormatUint(uint64(usbipServerPort), 10), "attach", "-r", "localhost", - "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusId, deviceExportMeta.DevId), + "-b", fmt.Sprintf("%d-%d", deviceExportMeta.BusID, deviceExportMeta.DevID), ) output, err := cmd.CombinedOutput() if err != nil { @@ -189,20 +189,23 @@ func getDeviceInterfacePath(guid *windows.GUID) (string, error) { uintptr(unsafe.Pointer(guid)), 0, 0, - uintptr(DIGCF_PRESENT|DIGCF_DEVICEINTERFACE)) + 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: %w", e1) } - return "", fmt.Errorf("Discovery: SetupDiGetClassDevsW failed with invalid handle") + return "", fmt.Errorf("discovery: SetupDiGetClassDevsW failed with invalid handle") } defer func() { - syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) + _, _, err := syscall.SyscallN(procSetupDiDestroyDeviceInfoList.Addr(), uintptr(devInfo)) + if err != 0 { + slog.Error("SetupDiDestroyDeviceInfoList failed", "error", err) + } }() - var interfaceData SP_DEVICE_INTERFACE_DATA + var interfaceData SpDeviceInterfaceData interfaceData.CbSize = uint32(unsafe.Sizeof(interfaceData)) r1, _, e2 := syscall.SyscallN(procSetupDiEnumDeviceInterfaces.Addr(), @@ -214,23 +217,26 @@ func getDeviceInterfacePath(guid *windows.GUID) (string, error) { 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: %w", e2) } - return "", fmt.Errorf("Discovery: usbip-win2 driver not found") + return "", fmt.Errorf("discovery: usbip-win2 driver not found") } var requiredSize uint32 - syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + _, _, err := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), uintptr(devInfo), uintptr(unsafe.Pointer(&interfaceData)), 0, 0, uintptr(unsafe.Pointer(&requiredSize)), 0) + if err != 0 { + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW (size query) failed: %w", err) + } detailData := make([]byte, requiredSize) - detailHeader := (*SP_DEVICE_INTERFACE_DETAIL_DATA)(unsafe.Pointer(&detailData[0])) - detailHeader.CbSize = uint32(unsafe.Sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA{})) + detailHeader := (*SpDeviceInterfaceDetailData)(unsafe.Pointer(&detailData[0])) + detailHeader.CbSize = uint32(unsafe.Sizeof(SpDeviceInterfaceDetailData{})) r2, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), uintptr(devInfo), @@ -242,9 +248,9 @@ func getDeviceInterfacePath(guid *windows.GUID) (string, error) { if r2 == 0 { if e3 != 0 { - return "", fmt.Errorf("Discovery: SetupDiGetDeviceInterfaceDetailW failed: %w", e3) + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW failed: %w", e3) } - return "", fmt.Errorf("Discovery: SetupDiGetDeviceInterfaceDetailW failed") + return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW failed") } path := windows.UTF16PtrToString(&detailHeader.DevicePath[0]) diff --git a/internal/server/api/device_stream_handler.go b/internal/server/api/device_stream_handler.go index 6d1149ea..fbb4ef88 100644 --- a/internal/server/api/device_stream_handler.go +++ b/internal/server/api/device_stream_handler.go @@ -16,7 +16,7 @@ import ( // 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() + defer conn.Close() //nolint:errcheck if dev == nil || *dev == nil { return fmt.Errorf("nil device") @@ -40,7 +40,7 @@ func inferDeviceType(dev any) string { return "" } t := reflect.TypeOf(dev) - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" diff --git a/internal/server/api/device_stream_handler_test.go b/internal/server/api/device_stream_handler_test.go index 02420b46..a835ea93 100644 --- a/internal/server/api/device_stream_handler_test.go +++ b/internal/server/api/device_stream_handler_test.go @@ -12,8 +12,8 @@ import ( "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/keyboard" - htesting "github.com/Alia5/VIIPER/internal/_testing" - th "github.com/Alia5/VIIPER/internal/_testing" + 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" @@ -26,7 +26,7 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { srv := srvusb.New(cfg, slog.Default(), log.NewRaw(nil)) logger := slog.Default() - bus, err := virtualbus.NewWithBusId(90001) + bus, err := virtualbus.NewWithBusID(90001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) dev, err := keyboard.New(nil) @@ -41,13 +41,13 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { metas := bus.GetAllDeviceMetas() for _, m := range metas { var busid string - for i, b := range m.Meta.USBBusId { + for i, b := range m.Meta.USBBusID { if b == 0 { - busid = string(m.Meta.USBBusId[:i]) + busid = string(m.Meta.USBBusID[:i]) break } } - if string(meta.USBBusId[:len(busid)]) == busid { + if string(meta.USBBusID[:len(busid)]) == busid { dv = m.Dev break } @@ -66,7 +66,7 @@ func TestDeviceStreamHandler_Dispatch(t *testing.T) { api.RegisterDevice("keyboard", testReg) clientConn, serverConn := net.Pipe() - defer clientConn.Close() + defer clientConn.Close() //nolint:errcheck handler := api.DeviceStreamHandler(srv) dvUSB := dv.(pusb.Device) @@ -89,7 +89,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { }) defer done() - bus, err := virtualbus.NewWithBusId(70001) + bus, err := virtualbus.NewWithBusID(70001) require.NoError(t, err) require.NoError(t, srv.AddBus(bus)) dev, err := keyboard.New(nil) @@ -100,9 +100,9 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { require.NotNil(t, meta) var deviceID string - for i, b := range meta.USBBusId { + for i, b := range meta.USBBusID { if b == 0 { - fullId := string(meta.USBBusId[:i]) + fullId := string(meta.USBBusID[:i]) splits := strings.Split(fullId, "-") deviceID = splits[1] break @@ -122,7 +122,7 @@ func TestAPIServer_StreamRoute_DispatchE2E(t *testing.T) { c, err := net.Dial("tcp", addr) require.NoError(t, err) - defer c.Close() + defer c.Close() //nolint:errcheck _, err = fmt.Fprintf(c, "bus/%d/%s\x00", bus.BusID(), deviceID) require.NoError(t, err) diff --git a/internal/server/api/error/apierror.go b/internal/server/api/error/apierror.go index f307603c..ff108fde 100644 --- a/internal/server/api/error/apierror.go +++ b/internal/server/api/error/apierror.go @@ -1,29 +1,29 @@ package apierror -import "github.com/Alia5/VIIPER/apitypes" +import "github.com/Alia5/VIIPER/viipertypes" -func ErrBadRequest(detail string) apitypes.ApiError { - return apitypes.ApiError{Status: 400, Title: "Bad Request", Detail: detail} +func ErrBadRequest(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 400, Title: "Bad Request", Detail: detail} } -func ErrNotFound(detail string) apitypes.ApiError { - return apitypes.ApiError{Status: 404, Title: "Not Found", Detail: detail} +func ErrNotFound(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 404, Title: "Not Found", Detail: detail} } -func ErrConflict(detail string) apitypes.ApiError { - return apitypes.ApiError{Status: 409, Title: "Conflict", Detail: detail} +func ErrConflict(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 409, Title: "Conflict", Detail: detail} } -func ErrInternal(detail string) apitypes.ApiError { - return apitypes.ApiError{Status: 500, Title: "Internal Server Error", Detail: detail} +func ErrInternal(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 500, Title: "Internal Server Error", Detail: detail} } -func ErrUnauthorized(detail string) apitypes.ApiError { - return apitypes.ApiError{Status: 401, Title: "Unauthorized", Detail: detail} +func ErrUnauthorized(detail string) viipertypes.APIError { + return viipertypes.APIError{Status: 401, Title: "Unauthorized", Detail: detail} } -// WrapError normalizes any error into apitypes.ApiError. -func WrapError(err error) apitypes.ApiError { - if ae, ok := err.(*apitypes.ApiError); ok { +// 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.(apitypes.ApiError); ok { + if ae, ok := err.(viipertypes.APIError); ok { return ae } // Default wrap as internal error diff --git a/internal/server/api/handler/bus_create.go b/internal/server/api/handler/bus_create.go index 15d3d5bc..5a3f8efc 100644 --- a/internal/server/api/handler/bus_create.go +++ b/internal/server/api/handler/bus_create.go @@ -6,10 +6,10 @@ import ( "log/slog" "strconv" - "github.com/Alia5/VIIPER/apitypes" "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" ) @@ -17,23 +17,23 @@ import ( 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) + 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()) + if busID == 0 { + busID = uint64(s.NextFreeBusID()) } - b, err := virtualbus.NewWithBusId(uint32(busId)) + 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)) + return apierror.ErrConflict(fmt.Sprintf("bus %d already exists", busID)) } - out, err := json.Marshal(apitypes.BusCreateResponse{BusID: b.BusID()}) + out, err := json.Marshal(viipertypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } @@ -41,12 +41,12 @@ func BusCreate(s *usb.Server) api.HandlerFunc { return nil } - busId := s.NextFreeBusID() - b := virtualbus.New(busId) + 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(apitypes.BusCreateResponse{BusID: b.BusID()}) + out, err := json.Marshal(viipertypes.BusCreateResponse{BusID: b.BusID()}) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } diff --git a/internal/server/api/handler/bus_create_test.go b/internal/server/api/handler/bus_create_test.go index da6d00e6..bcfd4a15 100644 --- a/internal/server/api/handler/bus_create_test.go +++ b/internal/server/api/handler/bus_create_test.go @@ -5,11 +5,11 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" 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" ) @@ -29,7 +29,7 @@ func TestBusCreate(t *testing.T) { { name: "duplicate bus", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60002) + b, err := virtualbus.NewWithBusID(60002) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -43,7 +43,7 @@ func TestBusCreate(t *testing.T) { { name: "create after remove allows reuse", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60003) + b, err := virtualbus.NewWithBusID(60003) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -83,7 +83,7 @@ func TestBusCreate(t *testing.T) { r.Register("bus/create", handler.BusCreate(s)) }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv) } diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 7d060589..e85b3d91 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -7,11 +7,11 @@ import ( "strconv" "strings" - "github.com/Alia5/VIIPER/apitypes" "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. @@ -32,7 +32,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { if req.Payload == "" { return apierror.ErrBadRequest("missing payload") } - var deviceCreateReq apitypes.DeviceCreateRequest + var deviceCreateReq viipertypes.DeviceCreateRequest err = json.Unmarshal([]byte(req.Payload), &deviceCreateReq) if err != nil { return apierror.ErrBadRequest(fmt.Sprintf("invalid JSON payload: %v", err)) @@ -49,8 +49,8 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } opts := device.CreateOptions{ - IdVendor: deviceCreateReq.IdVendor, - IdProduct: deviceCreateReq.IdProduct, + IDVendor: deviceCreateReq.IDVendor, + IDProduct: deviceCreateReq.IDProduct, DeviceSpecific: deviceCreateReq.DeviceSpecific, } @@ -78,7 +78,7 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { connTimer.Stop() return case <-connTimer.C: - deviceIDStr := fmt.Sprintf("%d", exportMeta.DevId) + deviceIDStr := fmt.Sprintf("%d", exportMeta.DevID) if err := s.RemoveDeviceByID(uint32(busID), deviceIDStr); err != nil { logger.Error("timeout: failed to remove device", "busID", busID, "deviceID", deviceIDStr, "error", err) } else { @@ -103,9 +103,9 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } } - payload, err := json.Marshal(apitypes.Device{ + payload, err := json.Marshal(viipertypes.Device{ BusID: uint32(busID), - DevId: fmt.Sprintf("%d", exportMeta.DevId), + 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, diff --git a/internal/server/api/handler/bus_device_add_test.go b/internal/server/api/handler/bus_device_add_test.go index 97263c06..b0393b1b 100644 --- a/internal/server/api/handler/bus_device_add_test.go +++ b/internal/server/api/handler/bus_device_add_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/xbox360" th "github.com/Alia5/VIIPER/internal/_testing" @@ -19,6 +18,7 @@ import ( "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" ) @@ -34,7 +34,7 @@ func TestBusDeviceAdd(t *testing.T) { { name: "add device to existing bus", setup: func(t *testing.T, s *usb.Server, as *api.Server) { - b, err := virtualbus.NewWithBusId(80001) + b, err := virtualbus.NewWithBusID(80001) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -49,7 +49,7 @@ func TestBusDeviceAdd(t *testing.T) { { 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) + b, err := virtualbus.NewWithBusID(80001) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -64,7 +64,7 @@ func TestBusDeviceAdd(t *testing.T) { { name: "invalid device specific args", setup: func(t *testing.T, s *usb.Server, as *api.Server) { - b, err := virtualbus.NewWithBusId(80001) + b, err := virtualbus.NewWithBusID(80001) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -93,7 +93,7 @@ func TestBusDeviceAdd(t *testing.T) { { name: "invalid json", setup: func(t *testing.T, s *usb.Server, as *api.Server) { - b, err := virtualbus.NewWithBusId(2) + b, err := virtualbus.NewWithBusID(2) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -108,7 +108,7 @@ func TestBusDeviceAdd(t *testing.T) { { name: "invalid payload", setup: func(t *testing.T, s *usb.Server, as *api.Server) { - b, err := virtualbus.NewWithBusId(3) + b, err := virtualbus.NewWithBusID(3) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -123,7 +123,7 @@ func TestBusDeviceAdd(t *testing.T) { { name: "correct device id after add/remove", setup: func(t *testing.T, s *usb.Server, as *api.Server) { - b, err := virtualbus.NewWithBusId(80005) + b, err := virtualbus.NewWithBusID(80005) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -149,7 +149,7 @@ func TestBusDeviceAdd(t *testing.T) { 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) + b, err := virtualbus.NewWithBusID(80250) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -181,7 +181,7 @@ func TestBusDeviceAdd(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv, as) } @@ -207,7 +207,7 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { BusCleanupTimeout: time.Millisecond * 500, }, slog.Default(), log.NewRaw(nil)) - b, err := virtualbus.NewWithBusId(80100) + b, err := virtualbus.NewWithBusID(80100) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(b)) @@ -222,7 +222,7 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { 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() + defer apiSrv.Close() //nolint:errcheck testReg := th.CreateMockRegistration(t, "xbox360", func(o *device.CreateOptions) (pusb.Device, error) { return xbox360.New(o) }, @@ -231,7 +231,7 @@ func TestBusDeviceAdd_NoConnection_TimeoutCleanup(t *testing.T) { api.RegisterDevice("xbox360", testReg) - c := apiclient.New(addr) + c := viiperclient.New(addr) _, err = c.DeviceAdd(80100, "xbox360", nil) require.NoError(t, err) diff --git a/internal/server/api/handler/bus_device_remove.go b/internal/server/api/handler/bus_device_remove.go index bbb110f2..d4ab4c3d 100644 --- a/internal/server/api/handler/bus_device_remove.go +++ b/internal/server/api/handler/bus_device_remove.go @@ -6,10 +6,10 @@ import ( "log/slog" "strconv" - "github.com/Alia5/VIIPER/apitypes" "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. @@ -36,7 +36,7 @@ func BusDeviceRemove(s *usb.Server) api.HandlerFunc { return apierror.ErrNotFound(fmt.Sprintf("device %s not found on bus %d", deviceID, busID)) } - j, err := json.Marshal(apitypes.DeviceRemoveResponse{BusID: uint32(busID), DevId: deviceID}) + 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)) } diff --git a/internal/server/api/handler/bus_device_remove_test.go b/internal/server/api/handler/bus_device_remove_test.go index 8bc98e1d..9377eb04 100644 --- a/internal/server/api/handler/bus_device_remove_test.go +++ b/internal/server/api/handler/bus_device_remove_test.go @@ -5,12 +5,12 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" "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" ) @@ -25,7 +25,7 @@ func TestBusDeviceRemove(t *testing.T) { { name: "remove existing device", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(90001) + b, err := virtualbus.NewWithBusID(90001) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -54,7 +54,7 @@ func TestBusDeviceRemove(t *testing.T) { { name: "remove non-existing device", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(90002) + b, err := virtualbus.NewWithBusID(90002) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -82,7 +82,7 @@ func TestBusDeviceRemove(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv) } diff --git a/internal/server/api/handler/bus_devices_list.go b/internal/server/api/handler/bus_devices_list.go index 6d8e5f0a..8a495411 100644 --- a/internal/server/api/handler/bus_devices_list.go +++ b/internal/server/api/handler/bus_devices_list.go @@ -9,10 +9,10 @@ import ( "strconv" "strings" - "github.com/Alia5/VIIPER/apitypes" "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. @@ -31,19 +31,19 @@ func BusDevicesList(s *usb.Server) api.HandlerFunc { return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } metas := b.GetAllDeviceMetas() - out := make([]apitypes.Device, 0, len(metas)) + out := make([]viipertypes.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), + 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(apitypes.DevicesListResponse{Devices: out}) + payload, err := json.Marshal(viipertypes.DevicesListResponse{Devices: out}) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } @@ -60,7 +60,7 @@ func inferDeviceType(dev any) string { return "" } t := reflect.TypeOf(dev) - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } pkg := t.PkgPath() // e.g., "github.com/Alia5/VIIPER/device/xbox360" diff --git a/internal/server/api/handler/bus_devices_list_test.go b/internal/server/api/handler/bus_devices_list_test.go index a941bdf1..fe9e80ba 100644 --- a/internal/server/api/handler/bus_devices_list_test.go +++ b/internal/server/api/handler/bus_devices_list_test.go @@ -5,12 +5,12 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" "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" ) @@ -24,7 +24,7 @@ func TestBusDevicesList(t *testing.T) { { name: "list devices on existing bus", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60008) + b, err := virtualbus.NewWithBusID(60008) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -38,7 +38,7 @@ func TestBusDevicesList(t *testing.T) { { name: "list devices after adding one", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60009) + b, err := virtualbus.NewWithBusID(60009) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -59,7 +59,7 @@ func TestBusDevicesList(t *testing.T) { { name: "list devices with multiple additions", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60010) + b, err := virtualbus.NewWithBusID(60010) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -105,7 +105,7 @@ func TestBusDevicesList(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv) } diff --git a/internal/server/api/handler/bus_list.go b/internal/server/api/handler/bus_list.go index e2001d2b..a5e850aa 100644 --- a/internal/server/api/handler/bus_list.go +++ b/internal/server/api/handler/bus_list.go @@ -4,9 +4,9 @@ import ( "encoding/json" "log/slog" - "github.com/Alia5/VIIPER/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. @@ -14,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/internal/server/api/handler/bus_list_test.go b/internal/server/api/handler/bus_list_test.go index ae16c83d..24d0099c 100644 --- a/internal/server/api/handler/bus_list_test.go +++ b/internal/server/api/handler/bus_list_test.go @@ -5,11 +5,11 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" 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" ) @@ -27,7 +27,7 @@ func TestBusList(t *testing.T) { { name: "list with one bus", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(60005) + b, err := virtualbus.NewWithBusID(60005) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -46,7 +46,7 @@ func TestBusList(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv) } diff --git a/internal/server/api/handler/bus_remove.go b/internal/server/api/handler/bus_remove.go index fc50c184..845c2b6f 100644 --- a/internal/server/api/handler/bus_remove.go +++ b/internal/server/api/handler/bus_remove.go @@ -6,10 +6,10 @@ import ( "log/slog" "strconv" - "github.com/Alia5/VIIPER/apitypes" "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. @@ -25,7 +25,7 @@ func BusRemove(s *usb.Server) api.HandlerFunc { if err := s.RemoveBus(uint32(busID)); err != nil { return apierror.ErrNotFound(fmt.Sprintf("bus %d not found", busID)) } - out, err := json.Marshal(apitypes.BusRemoveResponse{BusID: uint32(busID)}) + out, err := json.Marshal(viipertypes.BusRemoveResponse{BusID: uint32(busID)}) if err != nil { return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) } diff --git a/internal/server/api/handler/bus_remove_test.go b/internal/server/api/handler/bus_remove_test.go index a75e4b67..410745cf 100644 --- a/internal/server/api/handler/bus_remove_test.go +++ b/internal/server/api/handler/bus_remove_test.go @@ -5,12 +5,12 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" "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" ) @@ -24,7 +24,7 @@ func TestBusRemove(t *testing.T) { { name: "remove existing bus", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70001) + b, err := virtualbus.NewWithBusID(70001) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -38,7 +38,7 @@ func TestBusRemove(t *testing.T) { { name: "remove bus and reuse bus number", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70002) + b, err := virtualbus.NewWithBusID(70002) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -58,7 +58,7 @@ func TestBusRemove(t *testing.T) { { name: "remove bus with devices attached", setup: func(t *testing.T, s *usb.Server) { - b, err := virtualbus.NewWithBusId(70004) + b, err := virtualbus.NewWithBusID(70004) if err != nil { t.Fatalf("create bus failed: %v", err) } @@ -99,7 +99,7 @@ func TestBusRemove(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) if tt.setup != nil { tt.setup(t, srv) } @@ -112,7 +112,7 @@ func TestBusRemove(t *testing.T) { } if tt.name == "remove bus and reuse bus number" { - b, err := virtualbus.NewWithBusId(70002) + 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/ping.go b/internal/server/api/handler/ping.go index 356d3d15..b4773b49 100644 --- a/internal/server/api/handler/ping.go +++ b/internal/server/api/handler/ping.go @@ -4,9 +4,9 @@ import ( "encoding/json" "log/slog" - "github.com/Alia5/VIIPER/apitypes" "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. @@ -22,7 +22,7 @@ func Ping() api.HandlerFunc { logger.Error("ping: invalid version format", "error", err, "version", ver) } - payload := apitypes.PingResponse{Server: "VIIPER", Version: ver} + payload := viipertypes.PingResponse{Server: "VIIPER", Version: ver} b, err := json.Marshal(payload) if err != nil { return err diff --git a/internal/server/api/handler/ping_test.go b/internal/server/api/handler/ping_test.go index e2e61e5b..74bf3748 100644 --- a/internal/server/api/handler/ping_test.go +++ b/internal/server/api/handler/ping_test.go @@ -6,12 +6,12 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Alia5/VIIPER/apiclient" - "github.com/Alia5/VIIPER/apitypes" 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) { @@ -20,11 +20,11 @@ func TestPing(t *testing.T) { }) defer done() - c := apiclient.NewTransport(addr) + c := viiperclient.NewTransport(addr) line, err := c.Do("ping", nil, nil) assert.NoError(t, err) - var out apitypes.PingResponse + var out viipertypes.PingResponse err = json.Unmarshal([]byte(line), &out) assert.NoError(t, err) assert.Equal(t, "VIIPER", out.Server) diff --git a/internal/server/api/server.go b/internal/server/api/server.go index c73bd1be..5d4206cc 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -13,12 +13,12 @@ import ( "strconv" "strings" - "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/device" "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. @@ -107,19 +107,28 @@ func (s *Server) serve() { func (s *Server) writeError(w io.Writer, err error) { apiErr := apierror.WrapError(err) problemJSON, _ := json.Marshal(apiErr) - fmt.Fprintf(w, "%s\n", string(problemJSON)) + _, 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 == "" { - fmt.Fprintln(w) + _, err := fmt.Fprintln(w) + if err != nil { + s.logger.Error("failed to write OK response", "error", err) + } } else { - fmt.Fprintf(w, "%s\n", rest) + _, 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() + defer conn.Close() //nolint:errcheck connCtx, connCancel := context.WithCancel(context.Background()) defer connCancel() @@ -150,10 +159,9 @@ func (s *Server) handleConn(conn net.Conn) { clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, w, key, false) if err != nil { - var apiErr apitypes.ApiError - if errors.As(err, &apiErr) { - connLogger.Error("auth handshake failed", "error", err) - s.writeError(w, err) + 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) @@ -254,7 +262,7 @@ func (s *Server) handleConn(conn net.Conn) { var devCtx context.Context metas := bus.GetAllDeviceMetas() for _, meta := range metas { - if fmt.Sprintf("%d", meta.Meta.DevId) == devIDStr { + if fmt.Sprintf("%d", meta.Meta.DevID) == devIDStr { dev = meta.Dev devCtx = bus.GetDeviceContext(dev) break @@ -287,7 +295,7 @@ func (s *Server) handleConn(conn net.Conn) { case <-connTimer.C: exportMeta := device.GetDeviceMeta(devCtx) if exportMeta != nil { - deviceIDStr := fmt.Sprintf("%d", exportMeta.DevId) + 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 { diff --git a/internal/server/api/server_test.go b/internal/server/api/server_test.go index 46c39b4c..051f00cf 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/require" viiperTesting "github.com/Alia5/VIIPER/_testing" - "github.com/Alia5/VIIPER/apiclient" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/xbox360" th "github.com/Alia5/VIIPER/internal/_testing" @@ -25,6 +24,7 @@ import ( 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" ) @@ -41,9 +41,9 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { r := apiSrv.Router() r.RegisterStream("bus/{busId}/{deviceid}", api.DeviceStreamHandler(usbSrv)) require.NoError(t, apiSrv.Start()) - defer apiSrv.Close() + defer apiSrv.Close() //nolint:errcheck - bus, err := virtualbus.NewWithBusId(70002) + bus, err := virtualbus.NewWithBusID(70002) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(bus)) dev, err := xbox360.New(nil) @@ -55,7 +55,7 @@ func TestAPIServer_StreamHandlerError_ClosesConn(t *testing.T) { metas := bus.GetAllDeviceMetas() require.Greater(t, len(metas), 0) for _, m := range metas { - devID = fmt.Sprintf("%d", m.Meta.DevId) + devID = fmt.Sprintf("%d", m.Meta.DevID) } require.NotEmpty(t, devID) @@ -163,12 +163,12 @@ func TestAPIServer_WrappedConn(t *testing.T) { t.Run(tc.name, func(t *testing.T) { testSrvConfig := viiperTesting.TestServerConfig(t) - testSrvConfig.Server.ApiServerConfig.RequireLocalHostAuth = tc.requireLocalAuth - testSrvConfig.Server.ApiServerConfig.Password = tc.serverPass + testSrvConfig.Server.APIServerConfig.RequireLocalHostAuth = tc.requireLocalAuth + testSrvConfig.Server.APIServerConfig.Password = tc.serverPass s := viiperTesting.NewTestServerWithConfig(t, testSrvConfig) - defer s.ApiServer.Close() - defer s.UsbServer.Close() + 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)) @@ -179,15 +179,15 @@ func TestAPIServer_WrappedConn(t *testing.T) { } time.Sleep(50 * time.Millisecond) - b, err := virtualbus.NewWithBusId(1) + b, err := virtualbus.NewWithBusID(1) if err != nil { t.Fatalf("Failed to create virtual bus: %v", err) } - defer b.Close() + defer b.Close() //nolint:errcheck _ = s.UsbServer.AddBus(b) time.Sleep(50 * time.Millisecond) - client := apiclient.NewWithPassword(s.ApiServer.Addr(), tc.clientPass) + client := viiperclient.NewWithPassword(s.ApiServer.Addr(), tc.clientPass) time.Sleep(50 * time.Millisecond) @@ -206,7 +206,7 @@ func TestAPIServer_WrappedConn(t *testing.T) { time.Sleep(50 * time.Millisecond) - stream, err := client.OpenStream(context.Background(), b.BusID(), resp.DevId) + 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()) @@ -216,7 +216,7 @@ func TestAPIServer_WrappedConn(t *testing.T) { return } } - defer stream.Close() + defer stream.Close() //nolint:errcheck time.Sleep(50 * time.Millisecond) usbipClient := viiperTesting.NewUsbIpClient(t, s.UsbServer.Addr()) @@ -232,7 +232,7 @@ func TestAPIServer_WrappedConn(t *testing.T) { return } if imp != nil && imp.Conn != nil { - defer imp.Conn.Close() + defer imp.Conn.Close() //nolint:errcheck } time.Sleep(50 * time.Millisecond) diff --git a/internal/server/proxy/server.go b/internal/server/proxy/server.go index 5311e1b4..c9a85f5d 100644 --- a/internal/server/proxy/server.go +++ b/internal/server/proxy/server.go @@ -68,14 +68,14 @@ func (s *Server) Close() error { } func (s *Server) handleProxy(clientConn net.Conn) { - defer clientConn.Close() + 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() + defer upstreamConn.Close() //nolint:errcheck s.logger.Info("Proxying connection", "client", clientConn.RemoteAddr(), "upstream", upstreamConn.RemoteAddr()) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 24da95d5..6eb5a868 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -379,7 +379,7 @@ func (s *Server) GetListenPort() uint16 { // -- func (s *Server) handleConn(conn net.Conn) error { - defer conn.Close() + 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) @@ -401,7 +401,7 @@ func (s *Server) handleConn(conn net.Conn) error { return s.handleDevList(conn) case usbip.OpReqImport: s.logger.Info("OP_REQ_IMPORT") - dev, err := s.handleImport(conn, hdrBuf[:]) + dev, err := s.handleImport(conn) if err != nil { return fmt.Errorf("handle import: %w", err) } @@ -454,7 +454,7 @@ func (s *Server) handleDevList(conn net.Conn) error { return nil } -func (s *Server) handleImport(conn net.Conn, first8 []byte) (usb.Device, error) { +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) @@ -466,8 +466,8 @@ func (s *Server) handleImport(conn net.Conn, first8 []byte) (usb.Device, error) var chosenDesc *usb.Descriptor for _, m := range s.getAllDeviceMetas() { meta := m.Meta - end := bytes.IndexByte(meta.USBBusId[:], 0) - bid := string(meta.USBBusId[:end]) + end := bytes.IndexByte(meta.USBBusID[:], 0) + bid := string(meta.USBBusID[:end]) if bid == reqBus { chosen = m.Dev chosenMeta = &meta @@ -519,20 +519,6 @@ func (s *Server) getAllDeviceMetas() []virtualbus.DeviceMeta { return out } -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 diff --git a/internal/tray/tray_windows.go b/internal/tray/tray_windows.go index 4cc14929..4f9ce51d 100644 --- a/internal/tray/tray_windows.go +++ b/internal/tray/tray_windows.go @@ -74,7 +74,7 @@ func autoStartEnabled() bool { if err != nil { return false } - defer key.Close() + defer key.Close() //nolint:errcheck _, _, err = key.GetStringValue(runValueKey) return err == nil } @@ -86,7 +86,7 @@ func toggleAutoStart() bool { slog.Error("Failed to open registry key", "error", err) return true } - defer key.Close() + defer key.Close() //nolint:errcheck _ = key.DeleteValue(runValueKey) slog.Info("Auto-start disabled") return false @@ -106,7 +106,7 @@ func toggleAutoStart() bool { slog.Error("Failed to create registry key", "error", err) return false } - defer key.Close() + 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) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index fbe462b4..3b8ac665 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -102,7 +102,7 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) { slog.Error("failed to fetch releases", "error", err) return } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { slog.Error("unexpected status from GitHub API", "status", resp.StatusCode) return @@ -122,7 +122,7 @@ func CheckUpdate(currentVersion string, notify config.UpdateNotify) { slog.Error("failed to fetch latest release", "error", err) return } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { slog.Error("unexpected status from GitHub API", "status", resp.StatusCode) return diff --git a/internal/util/util_windows.go b/internal/util/util_windows.go index dde9ad1c..996d68be 100644 --- a/internal/util/util_windows.go +++ b/internal/util/util_windows.go @@ -55,7 +55,7 @@ func getParentProcessName() string { if err != nil { return "" } - defer windows.CloseHandle(snapshot) + defer windows.CloseHandle(snapshot) // nolint var pe windows.ProcessEntry32 pe.Size = uint32(unsafe.Sizeof(pe)) diff --git a/lib/viiper/bus.go b/lib/viiper/bus.go index 8bbd914b..98cb0c82 100644 --- a/lib/viiper/bus.go +++ b/lib/viiper/bus.go @@ -32,7 +32,7 @@ func CreateUSBBus(handle C.USBServerHandle, busID *uint32) bool { *busID = hw.s.NextFreeBusID() } - b, err := virtualbus.NewWithBusId(*busID) + b, err := virtualbus.NewWithBusID(*busID) if err != nil { return false } diff --git a/lib/viiper/dualshock4.go b/lib/viiper/dualshock4.go index 1882c188..bdcbd16a 100644 --- a/lib/viiper/dualshock4.go +++ b/lib/viiper/dualshock4.go @@ -105,10 +105,10 @@ func CreateDS4Device( opts := &device.CreateOptions{} if idVendor != 0 { - opts.IdVendor = &idVendor + opts.IDVendor = &idVendor } if idProduct != 0 { - opts.IdProduct = &idProduct + opts.IDProduct = &idProduct } d, err := dualshock4.New(opts) @@ -235,12 +235,12 @@ func RemoveDS4Device(handle C.DS4DeviceHandle) bool { if !ok { return false } - if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusId, fmt.Sprintf("%d", dhw.exportMeta.DevId)); err != nil { + 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 + busID := dhw.exportMeta.BusID shw.mtx.Lock() defer shw.mtx.Unlock() diff --git a/lib/viiper/keyboard.go b/lib/viiper/keyboard.go index 349eff94..6d61a121 100644 --- a/lib/viiper/keyboard.go +++ b/lib/viiper/keyboard.go @@ -176,10 +176,10 @@ func CreateKeyboardDevice( opts := &device.CreateOptions{} if idVendor != 0 { - opts.IdVendor = &idVendor + opts.IDVendor = &idVendor } if idProduct != 0 { - opts.IdProduct = &idProduct + opts.IDProduct = &idProduct } d, err := keyboard.New(opts) @@ -298,12 +298,12 @@ func RemoveKeyboardDevice(handle C.KeyboardDeviceHandle) bool { if !ok { return false } - if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusId, fmt.Sprintf("%d", dhw.exportMeta.DevId)); err != nil { + 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 + busID := dhw.exportMeta.BusID shw.mtx.Lock() defer shw.mtx.Unlock() diff --git a/lib/viiper/mouse.go b/lib/viiper/mouse.go index 67a8c3a1..37d1d889 100644 --- a/lib/viiper/mouse.go +++ b/lib/viiper/mouse.go @@ -65,10 +65,10 @@ func CreateMouseDevice( opts := &device.CreateOptions{} if idVendor != 0 { - opts.IdVendor = &idVendor + opts.IDVendor = &idVendor } if idProduct != 0 { - opts.IdProduct = &idProduct + opts.IDProduct = &idProduct } d, err := mouse.New(opts) @@ -146,12 +146,12 @@ func RemoveMouseDevice(handle C.MouseDeviceHandle) bool { if !ok { return false } - if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusId, fmt.Sprintf("%d", dhw.exportMeta.DevId)); err != nil { + 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 + busID := dhw.exportMeta.BusID shw.mtx.Lock() defer shw.mtx.Unlock() diff --git a/lib/viiper/postbuild/main.go b/lib/viiper/postbuild/main.go index 281f535b..3b4ee376 100644 --- a/lib/viiper/postbuild/main.go +++ b/lib/viiper/postbuild/main.go @@ -43,7 +43,7 @@ func main() { data, _ := os.ReadFile("dist/libVIIPER/libVIIPER.h") var out []string - for _, line := range strings.Split(string(data), "\n") { + 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 { @@ -60,5 +60,5 @@ func main() { } out = append(out, line) } - os.WriteFile("dist/libVIIPER/libVIIPER.h", []byte(strings.Join(out, "\n")), 0644) + os.WriteFile("dist/libVIIPER/libVIIPER.h", []byte(strings.Join(out, "\n")), 0644) // nolint } diff --git a/lib/viiper/xbox360.go b/lib/viiper/xbox360.go index d7bd6df4..2aa4a528 100644 --- a/lib/viiper/xbox360.go +++ b/lib/viiper/xbox360.go @@ -91,10 +91,10 @@ func CreateXbox360Device( opts := &device.CreateOptions{} if idVendor != 0 { - opts.IdVendor = &idVendor + opts.IDVendor = &idVendor } if idProduct != 0 { - opts.IdProduct = &idProduct + opts.IDProduct = &idProduct } if xinputSubType != 0 { subOpts := &xbox360.Xbox360CreateOptions{ @@ -194,12 +194,12 @@ func RemoveXbox360Device(handle C.Xbox360DeviceHandle) bool { if !ok { return false } - if err := dhw.usbServer.s.RemoveDeviceByID(dhw.exportMeta.BusId, fmt.Sprintf("%d", dhw.exportMeta.DevId)); err != nil { + 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 + busID := dhw.exportMeta.BusID shw.mtx.Lock() defer shw.mtx.Unlock() diff --git a/usbip/usbip.go b/usbip/usbip.go index 6f85bed7..4b6a6ba1 100644 --- a/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 { diff --git a/apiclient/client.go b/viiperclient/client.go similarity index 73% rename from apiclient/client.go rename to viiperclient/client.go index 8b9ef4c5..f0d0f5b8 100644 --- a/apiclient/client.go +++ b/viiperclient/client.go @@ -1,4 +1,4 @@ -package apiclient +package viiperclient import ( "bytes" @@ -7,8 +7,8 @@ import ( "errors" "fmt" - apitypes "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/viipertypes" ) // Client provides a high-level interface to the VIIPER API, handling request @@ -34,83 +34,83 @@ func NewWithConfig(addr string, cfg *Config) *Client { func WithTransport(t *Transport) *Client { return &Client{transport: t} } // Ping returns the version and identity of the VIIPER server. -func (c *Client) Ping() (*apitypes.PingResponse, error) { +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) (*apitypes.PingResponse, error) { +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[apitypes.PingResponse](raw) + 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) (*apitypes.BusCreateResponse, error) { +func (c *Client) BusCreate(busID uint32) (*viipertypes.BusCreateResponse, error) { return c.BusCreateCtx(context.Background(), busID) } -func (c *Client) BusCreateCtx(ctx context.Context, busID uint32) (*apitypes.BusCreateResponse, error) { +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[apitypes.BusCreateResponse](raw) + 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) (*apitypes.BusRemoveResponse, error) { +func (c *Client) BusRemove(busID uint32) (*viipertypes.BusRemoveResponse, error) { return c.BusRemoveCtx(context.Background(), busID) } -func (c *Client) BusRemoveCtx(ctx context.Context, busID uint32) (*apitypes.BusRemoveResponse, error) { +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[apitypes.BusRemoveResponse](raw) + return parse[viipertypes.BusRemoveResponse](raw) } // BusList retrieves a list of all active virtual USB bus numbers. -func (c *Client) BusList() (*apitypes.BusListResponse, error) { +func (c *Client) BusList() (*viipertypes.BusListResponse, error) { return c.BusListCtx(context.Background()) } -func (c *Client) BusListCtx(ctx context.Context) (*apitypes.BusListResponse, error) { +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[apitypes.BusListResponse](raw) + 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) (*apitypes.Device, error) { +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) (*apitypes.Device, error) { +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{} } - req := apitypes.DeviceCreateRequest{ + req := viipertypes.DeviceCreateRequest{ Type: &devType, - IdVendor: o.IdVendor, - IdProduct: o.IdProduct, + IDVendor: o.IDVendor, + IDProduct: o.IDProduct, DeviceSpecific: o.DeviceSpecific, } payloadBytes, err := json.Marshal(req) @@ -121,48 +121,48 @@ func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, if err != nil { return nil, err } - return parse[apitypes.Device](raw) + return parse[viipertypes.Device](raw) } // 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. +// 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, busid string) (*apitypes.DeviceRemoveResponse, error) { - return c.DeviceRemoveCtx(context.Background(), busID, busid) +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, busid string) (*apitypes.DeviceRemoveResponse, error) { +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, busid, pathParams) + raw, err := c.transport.DoCtx(ctx, path, devID, pathParams) if err != nil { return nil, err } - return parse[apitypes.DeviceRemoveResponse](raw) + 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) (*apitypes.DevicesListResponse, error) { +func (c *Client) DevicesList(busID uint32) (*viipertypes.DevicesListResponse, error) { return c.DevicesListCtx(context.Background(), busID) } -func (c *Client) DevicesListCtx(ctx context.Context, busID uint32) (*apitypes.DevicesListResponse, error) { +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[apitypes.DevicesListResponse](raw) + return parse[viipertypes.DevicesListResponse](raw) } func parse[T any](data string) (*T, error) { if data == "" { return nil, errors.New("empty response") } - var problem apitypes.ApiError + var problem viipertypes.APIError if err := json.Unmarshal([]byte(data), &problem); err == nil && (problem.Status != 0 || problem.Title != "") { return nil, &problem } diff --git a/apiclient/client_test.go b/viiperclient/client_test.go similarity index 69% rename from apiclient/client_test.go rename to viiperclient/client_test.go index 8693ed8b..48c14414 100644 --- a/apiclient/client_test.go +++ b/viiperclient/client_test.go @@ -1,12 +1,12 @@ -package apiclient_test +package viiperclient_test import ( "context" "errors" "testing" - apiclient "github.com/Alia5/VIIPER/apiclient" - apitypes "github.com/Alia5/VIIPER/apitypes" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" "github.com/stretchr/testify/assert" ) @@ -14,8 +14,8 @@ import ( // 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) *apiclient.Client { - return apiclient.WithTransport(apiclient.NewMockTransport(func(path string, _ any, _ map[string]string) (string, error) { +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 } @@ -30,17 +30,17 @@ func TestHighLevelClient(t *testing.T) { tests := []struct { name string setup func(responses map[string]string) (err error) - call func(c *apiclient.Client) (any, 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 *apiclient.Client) (any, error) { return c.BusCreate(42) }, + call: func(c *viiperclient.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") + _, ok := got.(*viipertypes.BusCreateResponse) + assert.True(t, ok, "expected *viipertypes.BusCreateResponse type") }, }, { @@ -49,7 +49,7 @@ func TestHighLevelClient(t *testing.T) { responses["bus/create"] = `{"status":400,"title":"Bad Request","detail":"invalid busId"}` return nil }, - call: func(c *apiclient.Client) (any, error) { return c.BusCreate(0) }, + call: func(c *viiperclient.Client) (any, error) { return c.BusCreate(0) }, wantErr: "400 Bad Request: invalid busId", }, { @@ -58,27 +58,27 @@ func TestHighLevelClient(t *testing.T) { responses["bus/{id}/list"] = `{"devices":[{"busId":1,"devId":"1","vid":"0x1234","pid":"0xabcd","type":"x"}]}` return nil }, - call: func(c *apiclient.Client) (any, error) { return c.DevicesList(1) }, + 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 *apiclient.Client) (any, error) { return c.BusList() }, + 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 *apiclient.Client) (any, error) { return c.BusList() }, + 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 *apiclient.Client) (any, error) { return c.DevicesList(1) }, + call: func(c *viiperclient.Client) (any, error) { return c.DevicesList(1) }, assertFunc: func(t *testing.T, got any) { - resp := got.(*apitypes.DevicesListResponse) + resp := got.(*viipertypes.DevicesListResponse) assert.Len(t, resp.Devices, 0) }, }, @@ -109,7 +109,7 @@ func TestHighLevelClient(t *testing.T) { } func TestContextCancellation(t *testing.T) { - c := apiclient.WithTransport(apiclient.NewTransport("127.0.0.1:9")) // address irrelevant due to early cancel + 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) diff --git a/apiclient/stream.go b/viiperclient/stream.go similarity index 96% rename from apiclient/stream.go rename to viiperclient/stream.go index 5abb2feb..2e6b6ea0 100644 --- a/apiclient/stream.go +++ b/viiperclient/stream.go @@ -1,4 +1,4 @@ -package apiclient +package viiperclient import ( "bufio" @@ -11,9 +11,9 @@ import ( "sync" "time" - apitypes "github.com/Alia5/VIIPER/apitypes" "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. @@ -60,14 +60,14 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) conn, err = auth.WrapConn(conn, sessionKey) if err != nil { - conn.Close() + 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() + conn.Close() // nolint return nil, fmt.Errorf("write stream path: %w", err) } @@ -81,13 +81,13 @@ func (c *Client) OpenStream(ctx context.Context, busID uint32, devID string) (*D // 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, *apitypes.Device, error) { +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) + stream, err := c.OpenStream(ctx, busID, resp.DevID) if err != nil { return nil, resp, err } diff --git a/apiclient/stream_test.go b/viiperclient/stream_test.go similarity index 89% rename from apiclient/stream_test.go rename to viiperclient/stream_test.go index de350955..6af6ef41 100644 --- a/apiclient/stream_test.go +++ b/viiperclient/stream_test.go @@ -1,4 +1,4 @@ -package apiclient_test +package viiperclient_test import ( "bufio" @@ -11,8 +11,6 @@ import ( "testing" "time" - apiclient "github.com/Alia5/VIIPER/apiclient" - apitypes "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/device/xbox360" htesting "github.com/Alia5/VIIPER/internal/_testing" @@ -22,6 +20,8 @@ import ( 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" @@ -39,7 +39,7 @@ func TestAddDeviceAndConnect(t *testing.T) { tests := []struct { name string setup func(responses map[string]string) error - wantDevice *apitypes.Device + wantDevice *viipertypes.Device wantErrSubstr string }{ { @@ -48,7 +48,7 @@ func TestAddDeviceAndConnect(t *testing.T) { responses["bus/{id}/add"] = `{"busId":42,"devId":"7","vid":"0x1234","pid":"0xabcd","type":"test"}` return nil }, - wantDevice: &apitypes.Device{BusID: 42, DevId: "7", Vid: "0x1234", Pid: "0xabcd", Type: "test"}, + wantDevice: &viipertypes.Device{BusID: 42, DevID: "7", Vid: "0x1234", Pid: "0xabcd", Type: "test"}, wantErrSubstr: "not supported with mock transport", }, { @@ -83,7 +83,7 @@ func TestAddDeviceAndConnect(t *testing.T) { 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.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) @@ -101,7 +101,7 @@ func TestAddDeviceAndConnect(t *testing.T) { } func TestDeviceStream_Operations(t *testing.T) { - type operation func(t *testing.T, stream *apiclient.DeviceStream) + type operation func(t *testing.T, stream *viiperclient.DeviceStream) tests := []struct { name string @@ -112,7 +112,7 @@ func TestDeviceStream_Operations(t *testing.T) { { name: "read deadline timeout", busID: 201, - op: func(t *testing.T, stream *apiclient.DeviceStream) { + 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) @@ -130,7 +130,7 @@ func TestDeviceStream_Operations(t *testing.T) { name: "closed stream read/write errors", busID: 202, customRegistration: true, - op: func(t *testing.T, stream *apiclient.DeviceStream) { + op: func(t *testing.T, stream *viiperclient.DeviceStream) { require.NoError(t, stream.Close()) buf := make([]byte, 1) _, rErr := stream.Read(buf) @@ -166,13 +166,13 @@ func TestDeviceStream_Operations(t *testing.T) { 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() + defer apiSrv.Close() //nolint:errcheck - b, err := virtualbus.NewWithBusId(tt.busID) + b, err := virtualbus.NewWithBusID(tt.busID) require.NoError(t, err) require.NoError(t, usbSrv.AddBus(b)) - c := apiclient.New(addr) + c := viiperclient.New(addr) stream, devResp, err := c.AddDeviceAndConnect(context.Background(), tt.busID, "xbox360", nil) require.NoError(t, err) require.NotNil(t, devResp) @@ -193,7 +193,7 @@ func TestEncryptedStream(t *testing.T) { } echoStreamHandler := func(t *testing.T, conn net.Conn) { - defer conn.Close() + defer conn.Close() //nolint:errcheck r := bufio.NewReader(conn) key, err := auth.DeriveKey("test123") @@ -201,7 +201,7 @@ func TestEncryptedStream(t *testing.T) { clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, false) if err != nil { - var apiErr apitypes.ApiError + var apiErr viipertypes.APIError if errors.As(err, &apiErr) { b, err := json.Marshal(apiErr) if err != nil { @@ -244,7 +244,7 @@ func TestEncryptedStream(t *testing.T) { name: "bad handshake response", password: "test123", serverHandler: func(t *testing.T, conn net.Conn) { - defer conn.Close() + defer conn.Close() //nolint:errcheck _, _ = conn.Write([]byte("NO\x00" + strings.Repeat("x", 32))) }, expectedErr: errors.New(""), @@ -263,7 +263,7 @@ func TestEncryptedStream(t *testing.T) { 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() + defer ln.Close() // nolint go func() { conn, err := ln.Accept() @@ -273,13 +273,13 @@ func TestEncryptedStream(t *testing.T) { tc.serverHandler(t, conn) }() - cfg := &apiclient.Config{ + cfg := &viiperclient.Config{ DialTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, Password: tc.password, } - client := apiclient.NewWithConfig(ln.Addr().String(), cfg) + client := viiperclient.NewWithConfig(ln.Addr().String(), cfg) stream, err := client.OpenStream(context.Background(), 1, "1") if tc.expectedErr != nil { diff --git a/apiclient/transport.go b/viiperclient/transport.go similarity index 98% rename from apiclient/transport.go rename to viiperclient/transport.go index 650f3110..646bb5d4 100644 --- a/apiclient/transport.go +++ b/viiperclient/transport.go @@ -1,4 +1,4 @@ -package apiclient +package viiperclient import ( "bufio" @@ -102,7 +102,7 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar if err != nil { return "", fmt.Errorf("dial: %w", err) } - defer conn.Close() + defer conn.Close() //nolint:errcheck if tcpConn, ok := conn.(*net.TCPConn); ok { if err := tcpConn.SetNoDelay(true); err != nil { @@ -131,7 +131,7 @@ func (t *Transport) DoCtx(ctx context.Context, path string, payload any, pathPar sessionKey := auth.DeriveSessionKey(key, serverNonce, clientNonce) conn, err = auth.WrapConn(conn, sessionKey) if err != nil { - conn.Close() + conn.Close() // nolint return "", err } } diff --git a/apiclient/transport_test.go b/viiperclient/transport_test.go similarity index 85% rename from apiclient/transport_test.go rename to viiperclient/transport_test.go index 4348ae70..346a0da4 100644 --- a/apiclient/transport_test.go +++ b/viiperclient/transport_test.go @@ -1,4 +1,4 @@ -package apiclient_test +package viiperclient_test import ( "bufio" @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/Alia5/VIIPER/apiclient" - apitypes "github.com/Alia5/VIIPER/apitypes" "github.com/Alia5/VIIPER/internal/server/api/auth" + "github.com/Alia5/VIIPER/viiperclient" + "github.com/Alia5/VIIPER/viipertypes" "github.com/stretchr/testify/assert" ) @@ -26,11 +26,14 @@ func startTestServer(t *testing.T, response string) (addr string, gotReqLine *st if err != nil { return } - defer conn.Close() + defer conn.Close() //nolint:errcheck var buf []byte var tmp [1]byte for { - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + 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 @@ -101,7 +104,7 @@ func TestTransportPayloadEncoding(t *testing.T) { for _, tc := range cases { addr, got, closeFn := startTestServer(t, "ok\n") - client := apiclient.NewTransport(addr) + client := viiperclient.NewTransport(addr) out, err := client.Do("echo", tc.payload, nil) closeFn() assert.NoError(t, err, tc.name) @@ -126,7 +129,7 @@ func TestTransportPayloadEncoding(t *testing.T) { func TestTransportMultiLineResponse(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") assert.NoError(t, err) - defer ln.Close() + defer ln.Close() // nolint resp := "{\n \"a\": 1,\n \"b\": 2\n}\n" // multi-line + trailing newline @@ -138,22 +141,25 @@ func TestTransportMultiLineResponse(t *testing.T) { buf := make([]byte, 0, 128) tmp := make([]byte, 1) for { - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, err := conn.Read(tmp) + 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) - if b == '\x00' { // end of request + buf = append(buf, b) // nolint + if b == '\x00' { // end of request break } } _, _ = conn.Write([]byte(resp)) - conn.Close() + conn.Close() // nolint }() - client := apiclient.NewTransport(ln.Addr().String()) + 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) @@ -169,7 +175,7 @@ func TestEncryptedTransport(t *testing.T) { } echoHandler := func(t *testing.T, conn net.Conn) { - defer conn.Close() + defer conn.Close() //nolint:errcheck r := bufio.NewReader(conn) key, err := auth.DeriveKey("test123") @@ -177,7 +183,7 @@ func TestEncryptedTransport(t *testing.T) { clientNonce, serverNonce, err := auth.HandleAuthHandshake(r, conn, key, false) if err != nil { - var apiErr apitypes.ApiError + var apiErr viipertypes.APIError if errors.As(err, &apiErr) { b, err := json.Marshal(apiErr) if err != nil { @@ -221,7 +227,7 @@ func TestEncryptedTransport(t *testing.T) { name: "bad handshake response", password: "test123", serverHandler: func(t *testing.T, conn net.Conn) { - defer conn.Close() + defer conn.Close() //nolint:errcheck _, _ = conn.Write([]byte("NO\x00" + strings.Repeat("x", 32))) }, expectedErr: errors.New(""), @@ -240,7 +246,7 @@ func TestEncryptedTransport(t *testing.T) { 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() + defer ln.Close() // nolint go func() { conn, err := ln.Accept() @@ -250,7 +256,7 @@ func TestEncryptedTransport(t *testing.T) { tc.serverHandler(t, conn) }() - client := apiclient.NewTransportWithPassword(ln.Addr().String(), tc.password) + client := viiperclient.NewTransportWithPassword(ln.Addr().String(), tc.password) path, payload, _ := strings.Cut(tc.line, " ") out, err := client.Do(path, payload, nil) diff --git a/apitypes/structs.go b/viipertypes/structs.go similarity index 86% rename from apitypes/structs.go rename to viipertypes/structs.go index b8dffda5..6269e5f4 100644 --- a/apitypes/structs.go +++ b/viipertypes/structs.go @@ -1,4 +1,4 @@ -package apitypes +package viipertypes import ( "encoding/json" @@ -10,8 +10,8 @@ import ( "golang.org/x/exp/constraints" ) -// ApiError represents an RFC 7807 (problem+json) error response. -type ApiError struct { +// 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 @@ -20,7 +20,7 @@ type ApiError struct { Detail string `json:"detail"` } -func (e ApiError) Error() string { +func (e APIError) Error() string { if e.Status == 0 && e.Title == "" { return "unknown error" } @@ -51,7 +51,7 @@ type BusRemoveResponse struct { type Device struct { BusID uint32 `json:"busId"` - DevId string `json:"devId"` + DevID string `json:"devId"` Vid string `json:"vid"` Pid string `json:"pid"` Type string `json:"type"` @@ -64,13 +64,13 @@ type DevicesListResponse struct { type DeviceRemoveResponse struct { BusID uint32 `json:"busId"` - DevId string `json:"devId"` + DevID string `json:"devId"` } type DeviceCreateRequest struct { Type *string `json:"type"` - IdVendor *uint16 `json:"idVendor,omitempty"` - IdProduct *uint16 `json:"idProduct,omitempty"` + IDVendor *uint16 `json:"idVendor,omitempty"` + IDProduct *uint16 `json:"idProduct,omitempty"` DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"` } @@ -80,8 +80,8 @@ 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"` + IDVendor any `json:"idVendor,omitempty"` + IDProduct any `json:"idProduct,omitempty"` DeviceSpecific map[string]any `json:"deviceSpecific,omitempty"` } @@ -91,20 +91,20 @@ func (d *DeviceCreateRequest) UnmarshalJSON(data []byte) error { d.Type = raw.Type - if raw.IdVendor != nil { - val, err := parseNumberOrHex[uint16](raw.IdVendor) + if raw.IDVendor != nil { + val, err := parseNumberOrHex[uint16](raw.IDVendor) if err != nil { return fmt.Errorf("idVendor: %w", err) } - d.IdVendor = &val + d.IDVendor = &val } - if raw.IdProduct != nil { - val, err := parseNumberOrHex[uint16](raw.IdProduct) + if raw.IDProduct != nil { + val, err := parseNumberOrHex[uint16](raw.IDProduct) if err != nil { return fmt.Errorf("idProduct: %w", err) } - d.IdProduct = &val + d.IDProduct = &val } d.DeviceSpecific = raw.DeviceSpecific diff --git a/virtualbus/virtualbus.go b/virtualbus/virtualbus.go index 505676e1..95918cc0 100644 --- a/virtualbus/virtualbus.go +++ b/virtualbus/virtualbus.go @@ -16,13 +16,13 @@ const basepath = "/sys/devices/pci0000:00/0000:00:08.1/0000:00:04:00.3/usb" var ( allocatedBusIds = make(map[uint32]bool) - globalMutex sync.Mutex + globalMtx sync.Mutex ) // VirtualBus manages USB bus topology and auto-assigns device addresses. type VirtualBus struct { - mutex sync.Mutex - busId uint32 + mtx sync.Mutex + busID uint32 nextDevID uint32 allocatedDevIDs map[uint32]bool devices []busDevice @@ -38,31 +38,31 @@ type DeviceMeta struct { // New creates a new VirtualBus instance with a unique auto-assigned bus number. func New(busID uint32) *VirtualBus { - globalMutex.Lock() - defer globalMutex.Unlock() + globalMtx.Lock() + defer globalMtx.Unlock() allocatedBusIds[busID] = true return &VirtualBus{ - busId: busID, + busID: busID, nextDevID: 0, allocatedDevIDs: make(map[uint32]bool), } } -// NewWithBusId creates a new VirtualBus instance starting at a specific bus number. +// 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() +func NewWithBusID(busID uint32) (*VirtualBus, error) { + globalMtx.Lock() + defer globalMtx.Unlock() - if allocatedBusIds[busId] { - return nil, fmt.Errorf("bus number %d already allocated", busId) + if allocatedBusIds[busID] { + return nil, fmt.Errorf("bus number %d already allocated", busID) } - allocatedBusIds[busId] = true + allocatedBusIds[busID] = true return &VirtualBus{ - busId: busId, + busID: busID, nextDevID: 0, allocatedDevIDs: make(map[uint32]bool), }, nil @@ -77,8 +77,8 @@ func NewWithBusId(busId uint32) (*VirtualBus, error) { // 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.mutex.Lock() - defer vb.mutex.Unlock() + vb.mtx.Lock() + defer vb.mtx.Unlock() if vb.emptyCancel != nil { vb.emptyCancel() @@ -91,7 +91,7 @@ func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { return nil, fmt.Errorf("device already registered on this bus") } } - busID := vb.busId + busID := vb.busID var devID uint32 for i := uint32(1); ; i++ { if !vb.allocatedDevIDs[i] { @@ -106,9 +106,9 @@ func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { var meta usbip.ExportMeta copy(meta.Path[:], path) - copy(meta.USBBusId[:], busDevID) - meta.BusId = busID - meta.DevId = devID + copy(meta.USBBusID[:], busDevID) + meta.BusID = busID + meta.DevID = devID connTimer := time.NewTimer(0) ctx, cancel := context.WithCancel(context.Background()) @@ -121,8 +121,8 @@ func (vb *VirtualBus) Add(dev usb.Device) (context.Context, error) { // 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() + 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}) @@ -132,15 +132,15 @@ func (vb *VirtualBus) GetAllDeviceMetas() []DeviceMeta { // BusID returns the bus number for this VirtualBus. func (vb *VirtualBus) BusID() uint32 { - vb.mutex.Lock() - defer vb.mutex.Unlock() - return vb.busId + 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.mutex.Lock() - defer vb.mutex.Unlock() + 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) @@ -149,8 +149,8 @@ func (vb *VirtualBus) Devices() []usb.Device { } func (vb *VirtualBus) GetBusEmptyContext() context.Context { - vb.mutex.Lock() - defer vb.mutex.Unlock() + vb.mtx.Lock() + defer vb.mtx.Unlock() if len(vb.devices) > 0 { return nil } @@ -163,20 +163,20 @@ func (vb *VirtualBus) GetBusEmptyContext() context.Context { // 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() + vb.mtx.Lock() + defer vb.mtx.Unlock() for i, d := range vb.devices { - if fmt.Sprintf("%d", d.meta.DevId) == deviceID { + if fmt.Sprintf("%d", d.meta.DevID) == deviceID { if d.cancel != nil { d.cancel() } - delete(vb.allocatedDevIDs, d.meta.DevId) + 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) + return fmt.Errorf("device with id %s not found on bus %d", deviceID, vb.busID) } // Remove unregisters a device from the bus. @@ -184,14 +184,14 @@ func (vb *VirtualBus) RemoveDeviceByID(deviceID string) error { // 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() + 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) + delete(vb.allocatedDevIDs, d.meta.DevID) vb.devices = append(vb.devices[:i], vb.devices[i+1:]...) return nil } @@ -202,8 +202,8 @@ func (vb *VirtualBus) Remove(dev usb.Device) error { // 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() + vb.mtx.Lock() + defer vb.mtx.Unlock() for i := range vb.devices { if vb.devices[i].cancel != nil { @@ -213,10 +213,10 @@ func (vb *VirtualBus) Close() error { vb.devices[i].cancel = nil } - globalMutex.Lock() - defer globalMutex.Unlock() + globalMtx.Lock() + defer globalMtx.Unlock() - delete(allocatedBusIds, vb.busId) + delete(allocatedBusIds, vb.busID) return nil } @@ -226,8 +226,8 @@ func (vb *VirtualBus) Close() error { // 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() + vb.mtx.Lock() + defer vb.mtx.Unlock() for i := range vb.devices { if vb.devices[i].dev == dev { return vb.devices[i].ctx From 717a95ee45c83dc19ac0bc58567d163dda47e67b Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 28 May 2026 22:22:30 +0200 Subject: [PATCH 167/298] Fix CI: build windows targets on windows --- .github/workflows/build_base.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index fbd2a289..5b44f178 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -35,6 +35,12 @@ jobs: - 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 @@ -56,16 +62,16 @@ jobs: build: name: Build binaries (${{ matrix.target.goos }}/${{ matrix.target.goarch }}) - runs-on: ubuntu-latest + runs-on: ${{ matrix.target.runner }} 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" } + - { 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 @@ -83,6 +89,11 @@ jobs: - 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 }} @@ -92,7 +103,6 @@ jobs: OUTPUT_NAME: viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}${{ matrix.target.ext }} run: | just build Release - ls -la dist - name: Upload artifact if: ${{ inputs.upload_artifacts }} From 520f842f07380fe2d1cb33245bb66031a12566c8 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 29 May 2026 15:11:52 +0200 Subject: [PATCH 168/298] Include license notices in CI artifacts --- .github/workflows/build_base.yml | 39 ++++- justfile | 269 +++++++++++++++++-------------- scripts/install.ps1 | 12 +- scripts/install.sh | 9 +- scripts/licenses.tpl | 37 +++++ 5 files changed, 234 insertions(+), 132 deletions(-) create mode 100644 scripts/licenses.tpl diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index 5b44f178..ccc09f4a 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -104,12 +104,41 @@ jobs: run: | just build Release - - name: Upload artifact - if: ${{ inputs.upload_artifacts }} + - 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 }}${{ matrix.target.ext }} + path: dist/viiper-${{ matrix.target.goos }}-${{ matrix.target.goarch }}.zip if-no-files-found: error libviiper-linux: @@ -138,7 +167,7 @@ jobs: just build-libVIIPER Release - name: Package - run: cd dist/libVIIPER && zip libVIIPER-linux-amd64.zip libVIIPER.so libVIIPER.h + run: cd dist/libVIIPER && zip libVIIPER-linux-amd64.zip libVIIPER.so libVIIPER.h licenses.txt - name: Upload artifact if: ${{ inputs.upload_artifacts }} @@ -186,7 +215,7 @@ jobs: - name: Package shell: pwsh run: | - Compress-Archive -Path dist/libVIIPER/libVIIPER.dll, dist/libVIIPER/libVIIPER.def, dist/libVIIPER/libVIIPER.h -DestinationPath dist/libVIIPER/libVIIPER-windows-amd64.zip -Force + 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 }} diff --git a/justfile b/justfile index ef64fb76..28e512fa 100644 --- a/justfile +++ b/justfile @@ -1,119 +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) - -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 -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} - -[arg("type", pattern="Debug|Release")] -[unix] -build type=build_type: - {{ mkdir_p }} {{ dist_dir }} - CGO_ENABLED=0 go build -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} - -[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 - -[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 - -clean: clean-versioninfo - -{{ rm_rf }} {{ dist_dir }} - -{{ rm_f }} coverage.out - -{{ rm_f }} coverage.html - go clean - -fmt: - go fmt ./... - -lint: - golangci-lint run ./... - -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 }} +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 -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 -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)); {{ 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)); {{ 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 }} && {{ 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 }} && {{ 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/scripts/install.ps1 b/scripts/install.ps1 index 71e5ba1d..ffe5fece 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -25,8 +25,8 @@ if ((Get-CimInstance Win32_ComputerSystem).SystemType -match "ARM") { $arch = "arm64" } -$binaryName = "viiper-windows-$arch.exe" -$downloadUrl = "https://github.com/$repo/releases/download/$version/$binaryName" +$archiveName = "viiper-windows-$arch.zip" +$downloadUrl = "https://github.com/$repo/releases/download/$version/$archiveName" Write-Host "Downloading from: $downloadUrl" $tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } @@ -34,7 +34,7 @@ $tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemTy try { function Get-ViiperVersion($path) { try { - $help = & $path --help -p 2>$null + $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 @@ -52,8 +52,12 @@ try { catch { return $null } } + $tempArchive = Join-Path $tempDir "release.zip" + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempArchive -ErrorAction Stop + + Expand-Archive -LiteralPath $tempArchive -DestinationPath $tempDir -Force + $tempViiper = Join-Path $tempDir "viiper.exe" - Invoke-WebRequest -Uri $downloadUrl -OutFile $tempViiper -ErrorAction Stop $newVersion = Get-ViiperVersion $tempViiper if (-not $newVersion) { $newVersion = "unknown" } diff --git a/scripts/install.sh b/scripts/install.sh index 4eda1480..e96b418c 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -33,19 +33,20 @@ case "$ARCH" in ;; esac -BINARY_NAME="viiper-linux-${ARCH}" -DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}" +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 viiper "$DOWNLOAD_URL"; then - echo "Error: Could not download VIIPER binary" +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) 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 From 2491ecb75a30d40f35763cdacabdd6421109dca4 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 29 May 2026 18:29:20 +0200 Subject: [PATCH 169/298] Fix CI --- justfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/justfile b/justfile index 28e512fa..c635f0c0 100644 --- a/justfile +++ b/justfile @@ -121,22 +121,22 @@ lint: [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)); {{ 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 + {{ 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)); {{ 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 + {{ 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 }} && {{ go_licenses_cmd }} report {{ main_pkg }} --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ licenses_out }} && rm -f {{ licenses_template_work }} + {{ 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 }} && {{ go_licenses_cmd }} report ./lib/viiper --ignore {{ licenses_ignore }} --template {{ licenses_template_work }} > {{ lib_licenses_out }} && rm -f {{ licenses_template_work }} + {{ 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 }} From 10f42b74493b2e41c06d9fdc252d5291751fc4b2 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:24:24 +0200 Subject: [PATCH 170/298] Add log level alias Changelog(misc) --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index dc9dae83..40e8fec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ const ( ) type Log struct { - Level string `help:"Log level: trace, debug, info, warn, error" default:"info" env:"VIIPER_LOG_LEVEL"` + 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"` } From 8c11acd5b7257c4e8c5701a0411ee8b4bba3ae54 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:24:36 +0200 Subject: [PATCH 171/298] Default server command CHangelog(misc) --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 40e8fec7..d93a714a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,7 +26,7 @@ type CLI struct { UpdateNotify UpdateNotify `help:"Update notification level: none, stable, prerelease" default:"stable" env:"VIIPER_UPDATE_NOTIFY"` Log `embed:"" prefix:"log."` - Server cmd.Server `cmd:"" help:"Start the VIIPER USB-IP server"` + 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"` From 0a07638713f7ab5645cb7c4a51e9ac197e7c1266 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:24:45 +0200 Subject: [PATCH 172/298] Update deps --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 5e90da3f..a83535ea 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,17 @@ module github.com/Alia5/VIIPER go 1.26.2 require ( - fyne.io/systray v1.12.0 + 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.50.0 + golang.org/x/crypto v0.52.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 2bb9d868..2eb865f8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= -fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +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= @@ -39,16 +39,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu 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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +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= From a8e946aca2d804a49d376001711d4e87564abde1 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:25:20 +0200 Subject: [PATCH 173/298] Improve/Fix "isRunfromCLI" logic (Windows) --- internal/util/util_windows.go | 81 +++++++++++++++-------------------- 1 file changed, 35 insertions(+), 46 deletions(-) diff --git a/internal/util/util_windows.go b/internal/util/util_windows.go index 996d68be..cd775aa0 100644 --- a/internal/util/util_windows.go +++ b/internal/util/util_windows.go @@ -5,7 +5,6 @@ package util import ( "log/slog" "os" - "strings" "unsafe" "golang.org/x/sys/windows" @@ -15,6 +14,7 @@ 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") ) @@ -23,20 +23,16 @@ func IsRunFromGUI() bool { hwnd, _, _ := procGetConsoleWindow.Call() hasConsole := hwnd != 0 - parentName := getParentProcessName() - isCliParent := isCliProcess(parentName) - - slog.Debug("Parent Process Info", "parentName", parentName, "hasConsole", hasConsole, "isCliParent", isCliParent) - if !hasConsole { return true } - if isCliParent { - return false - } + parentPID := getParentProcessID() + parentAttached := isPIDAttachedToCurrentConsole(parentPID) + + slog.Debug("Console launch detection", "hasConsole", hasConsole, "parentPID", parentPID, "parentAttached", parentAttached) - return strings.EqualFold(parentName, "explorer.exe") + return !parentAttached } func HideConsoleWindow() { @@ -50,68 +46,61 @@ func HideConsoleWindow() { _, _, _ = procFreeConsole.Call() } -func getParentProcessName() string { +func getParentProcessID() uint32 { snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { - return "" + return 0 } - defer windows.CloseHandle(snapshot) // nolint + defer windows.CloseHandle(snapshot) // nolint:errcheck var pe windows.ProcessEntry32 pe.Size = uint32(unsafe.Sizeof(pe)) currentPID := uint32(os.Getpid()) - var parentPID uint32 if err := windows.Process32First(snapshot, &pe); err != nil { - return "" + return 0 } for { if pe.ProcessID == currentPID { - parentPID = pe.ParentProcessID - break + return pe.ParentProcessID } if err := windows.Process32Next(snapshot, &pe); err != nil { - return "" + return 0 } } +} - if parentPID == 0 { - return "" +func isPIDAttachedToCurrentConsole(pid uint32) bool { + if pid == 0 { + return false } - if err := windows.Process32First(snapshot, &pe); err != nil { - return "" - } + buf := make([]uint32, 8) for { - if pe.ProcessID == parentPID { - return windows.UTF16ToString(pe.ExeFile[:]) - } - if err := windows.Process32Next(snapshot, &pe); err != nil { - break - } - } + count, _, _ := procGetConsoleProcs.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) - return "" -} + if count == 0 { + return false + } -func isCliProcess(name string) bool { - cliProcesses := []string{ - "cmd.exe", - "powershell.exe", - "pwsh.exe", - "wt.exe", - "conhost.exe", - "windowsterminal.exe", - } + needed := int(count) + if needed > len(buf) { + buf = make([]uint32, needed) + continue + } - nameLower := strings.ToLower(name) - for _, cli := range cliProcesses { - if nameLower == cli { - return true + for _, consolePID := range buf[:needed] { + if consolePID == pid { + return true + } } + + return false } - return false } From 0e3192c6795f2ff573d316950df96c95881ef529 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:25:38 +0200 Subject: [PATCH 174/298] Attempt fixing tray menu not showing up anymore after a while --- .vscode/launch.json | 4 ++-- internal/cmd/server.go | 2 +- internal/tray/tray.go | 4 +++- internal/tray/tray_windows.go | 11 +++++++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 907b8157..05d40ee7 100644 --- a/.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/internal/cmd/server.go b/internal/cmd/server.go index 4661f09a..4be3ff5c 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -39,7 +39,7 @@ func (s *Server) Run(logger *slog.Logger, rawLogger log.RawLogger) error { func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger log.RawLogger) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - go tray.Run(cancel) + tray.Run(ctx, cancel) s.USBServerConfig.ConnectionTimeout = s.ConnectionTimeout s.APIServerConfig.ConnectionTimeout = s.ConnectionTimeout diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 1938c905..178bdcae 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -2,4 +2,6 @@ package tray -func Run(shutdown func()) {} +import "context" + +func Run(ctx context.Context, shutdown func()) {} diff --git a/internal/tray/tray_windows.go b/internal/tray/tray_windows.go index 4f9ce51d..82bb530f 100644 --- a/internal/tray/tray_windows.go +++ b/internal/tray/tray_windows.go @@ -3,11 +3,13 @@ package tray import ( + "context" _ "embed" "fmt" "log/slog" "os" "path/filepath" + "runtime" "runtime/debug" "fyne.io/systray" @@ -22,8 +24,10 @@ const ( runValueKey = "VIIPER" ) -func Run(shutdown func()) { - systray.Run(func() { +func Run(ctx context.Context, shutdown func()) { + go systray.Run(func() { + runtime.LockOSThread() + systray.SetIcon(trayIcon) systray.SetTooltip("VIIPER") @@ -43,6 +47,8 @@ func Run(shutdown func()) { go func() { for { select { + case <-ctx.Done(): + return case <-autoStartItem.ClickedCh: if toggleAutoStart() { autoStartItem.Check() @@ -56,6 +62,7 @@ func Run(shutdown func()) { } } }() + }, func() {}) } From d2af157623519805d0646891a1a55b7bc1da13e0 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sat, 30 May 2026 17:32:58 +0200 Subject: [PATCH 175/298] Solidify autoattach handling --- internal/server/api/autoattach_windows.go | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index eed6ee96..47a805a9 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -72,7 +72,13 @@ const ( func attachLocalhostClientImpl(ctx context.Context, deviceExportMeta *usbip.ExportMeta, usbipServerPort uint16, useNativeIOCTL bool, logger *slog.Logger) error { if useNativeIOCTL { - return attachViaIOCTL(ctx, deviceExportMeta, usbipServerPort, logger) + 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) } @@ -223,22 +229,25 @@ func getDeviceInterfacePath(guid *windows.GUID) (string, error) { } var requiredSize uint32 - _, _, err := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + r2, _, err := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), uintptr(devInfo), uintptr(unsafe.Pointer(&interfaceData)), 0, 0, uintptr(unsafe.Pointer(&requiredSize)), 0) - if err != 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{})) - r2, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), + r3, _, e3 := syscall.SyscallN(procSetupDiGetDeviceInterfaceDetailW.Addr(), uintptr(devInfo), uintptr(unsafe.Pointer(&interfaceData)), uintptr(unsafe.Pointer(detailHeader)), @@ -246,7 +255,7 @@ func getDeviceInterfacePath(guid *windows.GUID) (string, error) { 0, 0) - if r2 == 0 { + if r3 == 0 { if e3 != 0 { return "", fmt.Errorf("discovery: SetupDiGetDeviceInterfaceDetailW failed: %w", e3) } @@ -261,9 +270,9 @@ func CheckAutoAttachPrerequisites(useNativeIOCTL bool, logger *slog.Logger) bool if useNativeIOCTL { _, err := getDeviceInterfacePath(&deviceGUID) if err != nil { - logger.Warn("usbip-win2 driver not found or not installed") - logger.Warn("Native IOCTL auto-attach requires the usbip-win2 driver") - logger.Info("Download and install usbip-win2:") + 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 From 176ee7c1838b52fbde1c8c49110d90e37b9a755a Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 09:47:41 +0200 Subject: [PATCH 176/298] Add FallBack HID transfer processing --- internal/server/usb/server.go | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 6eb5a868..41370672 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -137,6 +137,26 @@ const ( usbReqTypeStandardToDevice = 0x00 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 @@ -708,6 +728,7 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by return dev.HandleTransfer(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] @@ -807,6 +828,27 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by } } + if iface := int(wIndex & usbIfaceIndexMask); iface >= 0 && iface < len(desc.Interfaces) { + if desc.Interfaces[iface].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 + 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 } From 7e94d44abd2b3cc8cbd65dea06f7fef66640d3a3 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 11:54:12 +0200 Subject: [PATCH 177/298] Rename hid.Repoer -> hid.ReportDescriptor --- device/keyboard/device.go | 4 ++-- device/mouse/device.go | 4 ++-- usb/hid/hid.go | 6 +++--- usb/hid/items.go | 28 ++++++++++++++++++++++++++++ usb/usbdesc.go | 25 +++++++++++++++++++++---- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index c4d9d00f..e5720c7b 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -102,7 +102,7 @@ func (k *Keyboard) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { } // HID Report Descriptor for a full keyboard with 256-bit key bitmap and LED output. -var reportDescriptor = hid.Report{ +var reportDescriptor = hid.ReportDescriptor{ Items: []hid.Item{ hid.UsagePage{Page: hid.UsagePageGenericDesktop}, hid.Usage{Usage: hid.UsageKeyboard}, @@ -187,7 +187,7 @@ var defaultDescriptor = usb.Descriptor{ {Type: usb.ReportDescType}, // Length auto-filled from Report }, }, - Report: reportDescriptor, + ReportDescriptor: reportDescriptor, }, Endpoints: []usb.EndpointDescriptor{ { diff --git a/device/mouse/device.go b/device/mouse/device.go index 0a4ee50c..241f5b45 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -73,7 +73,7 @@ func (m *Mouse) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { // HID Report Descriptor for a 5-button mouse with vertical and horizontal wheels. // Boot protocol compatible. -var reportDescriptor = hid.Report{ +var reportDescriptor = hid.ReportDescriptor{ Items: []hid.Item{ hid.UsagePage{Page: hid.UsagePageGenericDesktop}, hid.Usage{Usage: hid.UsageMouse}, @@ -156,7 +156,7 @@ var defaultDescriptor = usb.Descriptor{ {Type: usb.ReportDescType}, }, }, - Report: reportDescriptor, + ReportDescriptor: reportDescriptor, }, Endpoints: []usb.EndpointDescriptor{ { diff --git a/usb/hid/hid.go b/usb/hid/hid.go index e743b7b4..d2d1aacc 100644 --- a/usb/hid/hid.go +++ b/usb/hid/hid.go @@ -32,13 +32,13 @@ type Item interface { encode(e *encoder) error } -// Report is a complete HID report descriptor (type 0x22). -type Report struct { +// ReportDescriptor is a complete HID report descriptor (type 0x22). +type ReportDescriptor struct { Items []Item } // Bytes encodes the report descriptor. -func (r Report) Bytes() (Data, error) { +func (r ReportDescriptor) Bytes() (Data, error) { e := &encoder{} for _, it := range r.Items { if it == nil { diff --git a/usb/hid/items.go b/usb/hid/items.go index 45c7d26f..3c61e8f9 100644 --- a/usb/hid/items.go +++ b/usb/hid/items.go @@ -97,3 +97,31 @@ 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 index 59c19a6c..8f5d7d15 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -240,12 +240,23 @@ func (d ClassSpecificDescriptor) Bytes() Data { // HIDFunction bundles the HID class descriptor (0x21) and the report descriptor (0x22) // for a HID-class interface. type HIDFunction struct { - Descriptor HIDDescriptor - Report hid.Report + 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) { - rb, err := f.Report.Bytes() + 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 } @@ -270,7 +281,13 @@ func (f HIDFunction) DescriptorBytes() (Data, error) { // ReportBytes returns the HID report descriptor (0x22) bytes. func (f HIDFunction) ReportBytes() (Data, error) { - rb, err := f.Report.Bytes() + 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 } From 3f90b68ea9457e192b10502c918c44c36984a4af Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 11:54:37 +0200 Subject: [PATCH 178/298] Rename DeviceRegister -> DeviceHandler --- internal/_testing/mocks.go | 6 +++++- internal/server/api/device_registry.go | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/_testing/mocks.go b/internal/_testing/mocks.go index db89a762..0c07a61c 100644 --- a/internal/_testing/mocks.go +++ b/internal/_testing/mocks.go @@ -23,12 +23,16 @@ 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.DeviceRegistration { +) api.DeviceHandler { return &mockRegistration{ deviceName: name, handlerFunc: h, diff --git a/internal/server/api/device_registry.go b/internal/server/api/device_registry.go index 887017db..97982ac0 100644 --- a/internal/server/api/device_registry.go +++ b/internal/server/api/device_registry.go @@ -7,24 +7,25 @@ import ( "github.com/Alia5/VIIPER/usb" ) -// DeviceRegistration describes a device type, providing both device creation +// DeviceHandler describes a device type, providing both device creation // and stream handler registration. -type DeviceRegistration interface { +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]DeviceRegistration) + 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 DeviceRegistration) { +func RegisterDevice(name string, reg DeviceHandler) { deviceRegistryMu.Lock() defer deviceRegistryMu.Unlock() deviceRegistry[toLower(name)] = reg @@ -32,7 +33,7 @@ func RegisterDevice(name string, reg DeviceRegistration) { // 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 { +func GetRegistration(name string) DeviceHandler { deviceRegistryMu.RLock() defer deviceRegistryMu.RUnlock() return deviceRegistry[toLower(name)] From e27552a43c7845cb7d2e73fc486dd03f0865b3ef Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 11:55:51 +0200 Subject: [PATCH 179/298] Fix DS4 Emulation, add metadata handling - Fix Gyro-Stutter - Fix Triggers Changelog(fix) --- device/dualshock4/const.go | 76 ++- device/dualshock4/descriptor.go | 374 +++++++++++ device/dualshock4/device.go | 593 ++++++++++-------- device/dualshock4/dualshock4_test.go | 63 +- device/dualshock4/handler.go | 60 +- device/dualshock4/helpers.go | 46 +- device/dualshock4/{inputstate.go => state.go} | 60 ++ device/errors.go | 5 + device/keyboard/handler.go | 4 + device/mouse/handler.go | 4 + device/xbox360/handler.go | 4 + 11 files changed, 979 insertions(+), 310 deletions(-) create mode 100644 device/dualshock4/descriptor.go rename device/dualshock4/{inputstate.go => state.go} (68%) create mode 100644 device/errors.go diff --git a/device/dualshock4/const.go b/device/dualshock4/const.go index d790a36a..8679cde2 100644 --- a/device/dualshock4/const.go +++ b/device/dualshock4/const.go @@ -1,24 +1,34 @@ package dualshock4 +import "time" + const ( DefaultVID = 0x054C - DefaultPID = 0x05C4 + 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 ( EndpointIn = 0x84 EndpointOut = 0x03 ) const ( - ReportIDInput = 0x01 - ReportIDOutput = 0x05 - ReportIDFeature = 0x02 + ReportIDInput = 0x01 + ReportIDOutput = 0x05 ) const ( - InputReportSize = 64 - OutputReportSize = 32 + InputReportSize = 64 ) const ( @@ -48,7 +58,6 @@ const ( ButtonPSUSB uint8 = 0x01 ButtonTouchpadClickUSB uint8 = 0x02 - CounterMask = 0xFC CounterShift = 2 ) @@ -97,6 +106,12 @@ const ( DefaultAccelZRaw int16 = -5023 ) +const ( + DefaultLedRed = 0x00 + DefaultLedGreen = 0x00 + DefaultLedBlue = 0x40 +) + const ( TouchpadMinX uint16 = 0 TouchpadMaxX uint16 = 1920 @@ -109,24 +124,43 @@ const ( const ( BatteryLevelMask = 0x0F BatteryChargingFlag = 0x10 - BatteryFullyCharged = 0x0B - BatteryDefault = 0x1B + BatteryFullyCharged = 0x0A +) +const ( + HardwareVersionMajor uint16 = 0x0001 + HardwareVersionMinor uint16 = 0xB400 + SoftwareVersionMajor uint32 = 0x00000001 + SoftwareVersionMinor uint16 = 0xA00B ) const ( - OutOffsetReportID = 0 - OutOffsetFlags = 1 - OutOffsetRumbleSmall = 4 - OutOffsetRumbleLarge = 5 - OutOffsetLedRed = 6 - OutOffsetLedGreen = 7 - OutOffsetLedBlue = 8 - OutOffsetFlashOn = 9 // Flash on time (units of 2.5ms) - OutOffsetFlashOff = 10 // Flash off time (units of 2.5ms) + hidClassIN uint8 = 0xA1 // bmRequestType: HID class IN (device→host) + hidClassOUT uint8 = 0x21 // bmRequestType: HID class OUT (host→device) ) const ( - DefaultLedRed = 0x00 - DefaultLedGreen = 0x00 - DefaultLedBlue = 0x40 + 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..90b17461 --- /dev/null +++ b/device/dualshock4/descriptor.go @@ -0,0 +1,374 @@ +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, + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + 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", + }, +} diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index a98a2ec6..30c39e78 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -2,29 +2,50 @@ package dualshock4 import ( "encoding/binary" + "encoding/json" + "fmt" "log/slog" "sync" "sync/atomic" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" - "github.com/Alia5/VIIPER/usb/hid" "github.com/Alia5/VIIPER/usbip" ) type DualShock4 struct { inputState *InputState - stateMu sync.Mutex + metaState *MetaState + outputFunc func(OutputState) descriptor usb.Descriptor - usbReportTimestamp uint32 - usbPacketCounter uint32 + probeSelector [3]byte + telemetrySubcommand byte + + usbPacketCounter uint32 + lastUSBReportAt time.Time + + 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 != nil { + metaState.UpdateFromMap(o.DeviceSpecific) + } + d := &DualShock4{ descriptor: defaultDescriptor, + metaState: metaState, } if o != nil { if o.IDVendor != nil { @@ -33,70 +54,83 @@ func New(o *device.CreateOptions) (*DualShock4, error) { if o.IDProduct != nil { d.descriptor.Device.IDProduct = *o.IDProduct } + if o.DeviceSpecific != nil { + if s, ok := o.DeviceSpecific["serial"].(string); ok && + len(s) <= 16 { + serial := fmt.Sprintf("%016s", s) + d.metaState.SerialNumber = serial + } + } } + slog.Info("DS4 device instantiated", + "vid", d.descriptor.Device.IDVendor, + "pid", d.descriptor.Device.IDProduct, + "interfaces", len(d.descriptor.Interfaces)) + d.inputState = &InputState{ - LX: 0, - LY: 0, - 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: DefaultAccelXRaw, - AccelY: DefaultAccelYRaw, - AccelZ: DefaultAccelZRaw, + AccelX: DefaultAccelXRaw, + AccelY: DefaultAccelYRaw, + AccelZ: DefaultAccelZRaw, } 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)) { d.outputFunc = f } func (d *DualShock4) UpdateInputState(state *InputState) { - d.stateMu.Lock() - defer d.stateMu.Unlock() + d.mtx.Lock() + defer d.mtx.Unlock() d.inputState = 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{} + } + return res +} + func (d *DualShock4) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { if dir == usbip.DirIn { switch ep { case 4: - d.stateMu.Lock() - st := *d.inputState - d.stateMu.Unlock() - return d.buildUSBInputReport(st) + d.mtx.Lock() + is := *d.inputState + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) default: return nil } } if dir == usbip.DirOut && ep == 3 { - if len(out) >= 11 && out[OutOffsetReportID] == ReportIDOutput { - feedback := OutputState{ - RumbleSmall: out[OutOffsetRumbleSmall], - RumbleLarge: out[OutOffsetRumbleLarge], - LedRed: out[OutOffsetLedRed], - LedGreen: out[OutOffsetLedGreen], - LedBlue: out[OutOffsetLedBlue], - FlashOn: out[OutOffsetFlashOn], - FlashOff: out[OutOffsetFlashOff], - } + if len(out) >= 11 && out[0] == ReportIDOutput { if d.outputFunc != nil { - d.outputFunc(feedback) + d.outputFunc(parseOutputReport(out)) } } } @@ -104,81 +138,264 @@ func (d *DualShock4) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { return nil } -func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, _ /* wIndex */, wLength uint16, data []byte) ([]byte, bool) { - const ( - hidGetReport = 0x01 - hidSetReport = 0x09 - ) - - const ( - reportTypeInput = 0x01 - reportTypeOutput = 0x02 - reportTypeFeature = 0x03 - ) - +func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { reportType := uint8(wValue >> 8) reportID := uint8(wValue & 0xFF) - if bmRequestType == 0xA1 && bRequest == hidGetReport { - if reportType == reportTypeInput && reportID == ReportIDInput { - d.stateMu.Lock() - st := *d.inputState - d.stateMu.Unlock() - report := d.buildUSBInputReport(st) - if wLength > 0 && int(wLength) < len(report) { - return report[:wLength], true + 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 } - return report, true - } - - if reportType == reportTypeFeature { - switch reportID { - case 0x02: // Gyro calibration - return make([]byte, 37), true - case 0x03: // Device capabilities - return make([]byte, 48), true - case 0x05: // Gyro calibration - return make([]byte, 41), true - case 0x12: // Serial number - return make([]byte, 16), 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 } - } - - if bmRequestType == 0x21 && bRequest == hidSetReport { - if reportType == reportTypeOutput && reportID == ReportIDOutput && len(data) >= 11 { - feedback := OutputState{ - RumbleSmall: data[OutOffsetRumbleSmall], - RumbleLarge: data[OutOffsetRumbleLarge], - LedRed: data[OutOffsetLedRed], - LedGreen: data[OutOffsetLedGreen], - LedBlue: data[OutOffsetLedBlue], - FlashOn: data[OutOffsetFlashOn], - FlashOff: data[OutOffsetFlashOff], + 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: + if d.outputFunc != nil { + d.outputFunc(parseOutputReport(data)) + } + return nil, true } - if d.outputFunc != nil { - d.outputFunc(feedback) - } - return nil, true } } - slog.Warn("Unsupported control request", + slog.Warn("DS4 control request unhandled", "bmRequestType", bmRequestType, - "bRequest", bRequest) + "bRequest", bRequest, + "reportType", reportType, + "reportID", reportID, + "wIndex", wIndex, + "wLength", wLength, + "dataLen", len(data)) return nil, false } -func (d *DualShock4) GetDescriptor() *usb.Descriptor { - return &d.descriptor +// 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 (d *DualShock4) GetDeviceSpecificArgs() map[string]any { - return map[string]any{} +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) buildUSBInputReport(s InputState) []byte { +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 @@ -189,21 +406,22 @@ func (d *DualShock4) buildUSBInputReport(s InputState) []byte { b[4] = uint8(int16(s.RY) + 128) usbDPad := uint8(DPadUSBNeutral) - if s.DPad&DPadUp != 0 && s.DPad&DPadRight != 0 { + switch { + case s.DPad&DPadUp != 0 && s.DPad&DPadRight != 0: usbDPad = DPadUSBUpRight - } else if s.DPad&DPadUp != 0 && s.DPad&DPadLeft != 0 { + case s.DPad&DPadUp != 0 && s.DPad&DPadLeft != 0: usbDPad = DPadUSBUpLeft - } else if s.DPad&DPadDown != 0 && s.DPad&DPadRight != 0 { + case s.DPad&DPadDown != 0 && s.DPad&DPadRight != 0: usbDPad = DPadUSBDownRight - } else if s.DPad&DPadDown != 0 && s.DPad&DPadLeft != 0 { + case s.DPad&DPadDown != 0 && s.DPad&DPadLeft != 0: usbDPad = DPadUSBDownLeft - } else if s.DPad&DPadUp != 0 { + case s.DPad&DPadUp != 0: usbDPad = DPadUSBUp - } else if s.DPad&DPadDown != 0 { + case s.DPad&DPadDown != 0: usbDPad = DPadUSBDown - } else if s.DPad&DPadLeft != 0 { + case s.DPad&DPadLeft != 0: usbDPad = DPadUSBLeft - } else if s.DPad&DPadRight != 0 { + case s.DPad&DPadRight != 0: usbDPad = DPadUSBRight } @@ -224,11 +442,9 @@ func (d *DualShock4) buildUSBInputReport(s InputState) []byte { b[8] = s.L2 b[9] = s.R2 - ts := atomic.AddUint32(&d.usbReportTimestamp, 1) + ts := d.nextReportTimestamp() binary.LittleEndian.PutUint16(b[10:12], uint16(ts)) - b[12] = 0x00 - binary.LittleEndian.PutUint16(b[13:15], uint16(s.GyroX)) binary.LittleEndian.PutUint16(b[15:17], uint16(s.GyroY)) binary.LittleEndian.PutUint16(b[17:19], uint16(s.GyroZ)) @@ -237,7 +453,10 @@ func (d *DualShock4) buildUSBInputReport(s InputState) []byte { binary.LittleEndian.PutUint16(b[21:23], uint16(s.AccelY)) binary.LittleEndian.PutUint16(b[23:25], uint16(s.AccelZ)) - b[30] = BatteryFullyCharged + b[12] = 0x09 // status: touchpad connected, no extension + b[30] = m.BatteryStatus // low nibble = level, bit4 = cable + b[33] = 0x01 // nvslocked + b[34] = 0x01 touch1Counter := uint8(0) if !s.Touch1Active { @@ -256,149 +475,19 @@ func (d *DualShock4) buildUSBInputReport(s InputState) []byte { return b } -func encodeTouchCoords(b []byte, x, y uint16) { - if x > TouchpadMaxX { - x = TouchpadMaxX +func (d *DualShock4) nextReportTimestamp() uint32 { + d.mtx.Lock() + defer d.mtx.Unlock() + now := time.Now() + if d.lastUSBReportAt.IsZero() { + d.lastUSBReportAt = now + return 188 } - if y > TouchpadMaxY { - y = TouchpadMaxY + elapsed := now.Sub(d.lastUSBReportAt).Nanoseconds() + d.lastUSBReportAt = now + ts := uint32(elapsed * 3 / 16000) + if ts == 0 { + ts = 1 } - - b[0] = uint8(x & 0xFF) - b[1] = uint8((x>>8)&0x0F) | uint8((y&0x0F)<<4) - b[2] = uint8(y >> 4) -} - -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, - }, - Interfaces: []usb.InterfaceConfig{ - { - Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, - 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}, - }, - }, - Report: hid.Report{ - Items: []hid.Item{ - hid.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: hid.UsageGamePad}, - hid.Collection{Kind: hid.CollectionApplication, Items: []hid.Item{ - - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x08, Data: hid.Data{0x01}}, - - 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.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: 0x39}, - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 7}, - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x3, Data: hid.Data{0x00}}, - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x4, Data: hid.Data{0x3B, 0x01}}, - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x6, Data: hid.Data{0x14}}, - hid.ReportSize{Bits: 4}, - hid.ReportCount{Count: 1}, - hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs | hid.MainNullState}, - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x6, Data: hid.Data{0x00}}, - - hid.UsagePage{Page: hid.UsagePageButton}, - hid.UsageMinimum{Min: 0x01}, - hid.UsageMaximum{Max: 0x0E}, - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 1}, - hid.ReportCount{Count: 14}, - hid.ReportSize{Bits: 1}, - 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.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, - - hid.UsagePage{Page: hid.UsagePageGenericDesktop}, - hid.Usage{Usage: 0x32}, - hid.Usage{Usage: 0x35}, - 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: 0x20}, - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 255}, - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 54}, - hid.Input{Flags: hid.MainData | hid.MainVar | hid.MainAbs}, - - hid.AnyItem{Type: hid.ItemTypeGlobal, Tag: 0x08, Data: hid.Data{0x05}}, - - hid.UsagePage{Page: 0xFF00}, - hid.Usage{Usage: 0x21}, - hid.LogicalMinimum{Min: 0}, - hid.LogicalMaximum{Max: 255}, - hid.ReportSize{Bits: 8}, - hid.ReportCount{Count: 31}, - hid.Output{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", - }, + return ts } diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 31978c04..235d5d37 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -51,26 +51,19 @@ func TestInputReports(t *testing.T) { AccelY: 0, AccelZ: 0, }, - expectedReport: []byte{ - 0x01, - 0x80, 0x80, 0x80, 0x80, - 0x08, - 0x00, - 0x00, - 0x00, 0x00, - 0x00, 0x00, - 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, - 0x0b, - 0x00, 0x00, 0x00, 0x00, - 0x80, 0x00, 0x00, 0x00, - 0x80, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, - }, + expectedReport: func() []byte { + b := make([]byte, dualshock4.InputReportSize) + b[0] = 0x01 + b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 + b[5] = 0x08 + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 + b[35] = 0x80 + b[39] = 0x80 + return b + }(), }, { name: "dpad up", @@ -89,7 +82,10 @@ func TestInputReports(t *testing.T) { b[0] = 0x01 b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 b[5] = 0x00 - b[30] = 0x0b + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x80 b[39] = 0x80 return b @@ -112,7 +108,10 @@ func TestInputReports(t *testing.T) { b[0] = 0x01 b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 b[5] = 0x18 - b[30] = 0x0b + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x80 b[39] = 0x80 return b @@ -136,7 +135,10 @@ func TestInputReports(t *testing.T) { b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 b[5] = 0x08 b[7] = 0x01 - b[30] = 0x0b + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x80 b[39] = 0x80 return b @@ -163,7 +165,10 @@ func TestInputReports(t *testing.T) { b[5] = 0x08 b[8] = 0x12 b[9] = 0xFE - b[30] = 0x0b + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x80 b[39] = 0x80 return b @@ -188,7 +193,10 @@ func TestInputReports(t *testing.T) { b[0] = 0x01 b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 b[5] = 0x08 - b[30] = 0x0b + b[12] = 0x09 + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x00 b[36] = 0x7b b[37] = 0x80 @@ -220,13 +228,16 @@ func TestInputReports(t *testing.T) { b[0] = 0x01 b[1], b[2], b[3], b[4] = 0x80, 0x80, 0x80, 0x80 b[5] = 0x08 + b[12] = 0x09 b[13], b[14] = 0xD2, 0x04 b[15], b[16] = 0xD7, 0xF6 b[17], b[18] = 0x80, 0x0D b[19], b[20] = 0x91, 0xFF b[21], b[22] = 0xDE, 0x00 b[23], b[24] = 0xB3, 0xFE - b[30] = 0x0b + b[30] = 0x1a + b[33] = 0x01 + b[34] = 0x01 b[35] = 0x80 b[39] = 0x80 return b diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 7e9ee321..8ccf2286 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -1,6 +1,7 @@ package dualshock4 import ( + "encoding/json" "fmt" "io" "log/slog" @@ -17,16 +18,56 @@ func init() { type handler struct{} -func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return New(o) } +var serials = map[string]struct{}{} + +func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { + if o == nil { + o = &device.CreateOptions{} + } + + metaState := MetaState{} + if len(o.DeviceSpecific) == 0 { + o.DeviceSpecific = map[string]any{} + } else { + metaState.UpdateFromMap(o.DeviceSpecific) + } + 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{}{} + o.DeviceSpecific = metaState.ToMap() + return New(o) +} 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("device is not dualshock4") + return fmt.Errorf("%w: expected DualShock4", device.ErrWrongDeviceType) } ds4.SetOutputCallback(func(feedback OutputState) { @@ -58,3 +99,18 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } } } + +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 index 37d766ed..a20201c7 100644 --- a/device/dualshock4/helpers.go +++ b/device/dualshock4/helpers.go @@ -1,11 +1,15 @@ package dualshock4 -import "math" +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 clampI16(math.Round(dps * GyroCountsPerDps)) + return int16(min(max(math.Round(dps*GyroCountsPerDps), math.MinInt16), math.MaxInt16)) } // GyroRawToDps converts a fixed-point raw gyro value into degrees/second (°/s). @@ -16,7 +20,7 @@ func GyroRawToDps(raw int16) float64 { // 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 clampI16(math.Round(ms2 * AccelCountsPerMS2)) + return int16(min(max(math.Round(ms2*AccelCountsPerMS2), math.MinInt16), math.MaxInt16)) } // AccelRawToMS2 converts a fixed-point raw accelerometer value into m/s². @@ -30,12 +34,36 @@ func DefaultAccelRaw() (x, y, z int16) { return DefaultAccelXRaw, DefaultAccelYRaw, DefaultAccelZRaw } -func clampI16(v float64) int16 { - if v > math.MaxInt16 { - return math.MaxInt16 +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 } - if v < math.MinInt16 { - return math.MinInt16 + n, err := hex.Decode(out[:], []byte(str)) + if err != nil || n != len(out) { + return out } - return int16(v) + return out } diff --git a/device/dualshock4/inputstate.go b/device/dualshock4/state.go similarity index 68% rename from device/dualshock4/inputstate.go rename to device/dualshock4/state.go index 5c6e4d86..063ee622 100644 --- a/device/dualshock4/inputstate.go +++ b/device/dualshock4/state.go @@ -2,7 +2,10 @@ package dualshock4 import ( "encoding/binary" + "encoding/json" "io" + "log/slog" + "time" ) // nolint @@ -120,3 +123,60 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { 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/keyboard/handler.go b/device/keyboard/handler.go index de38232f..5ad11066 100644 --- a/device/keyboard/handler.go +++ b/device/keyboard/handler.go @@ -85,3 +85,7 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } } } + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} diff --git a/device/mouse/handler.go b/device/mouse/handler.go index fa7d4e8c..05479cea 100644 --- a/device/mouse/handler.go +++ b/device/mouse/handler.go @@ -47,3 +47,7 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } } } + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} diff --git a/device/xbox360/handler.go b/device/xbox360/handler.go index 88450234..04ae1068 100644 --- a/device/xbox360/handler.go +++ b/device/xbox360/handler.go @@ -58,3 +58,7 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } } } + +func (h *handler) UpdateMetaState(meta string, dev *usb.Device) error { + return nil +} From c5b58c47743d7b0c7cf67622fdaca7ef1769174c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 12:15:12 +0200 Subject: [PATCH 180/298] Handle deviceSpecific create data as string internally --- device/dualshock4/device.go | 34 ++++++++++++++----- device/dualshock4/handler.go | 14 +++++--- device/options.go | 2 +- device/xbox360/device.go | 8 ++--- internal/server/api/handler/bus_device_add.go | 12 +++++-- lib/viiper/xbox360.go | 7 +--- viiperclient/client.go | 9 ++++- 7 files changed, 56 insertions(+), 30 deletions(-) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 30c39e78..1267259e 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -39,8 +39,30 @@ func New(o *device.CreateOptions) (*DualShock4, error) { TemperatureCelsius: DefaultTemperature, BatteryVoltage: DefaultVoltage, } - if o != nil && o.DeviceSpecific != nil { - metaState.UpdateFromMap(o.DeviceSpecific) + 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{ @@ -54,12 +76,8 @@ func New(o *device.CreateOptions) (*DualShock4, error) { if o.IDProduct != nil { d.descriptor.Device.IDProduct = *o.IDProduct } - if o.DeviceSpecific != nil { - if s, ok := o.DeviceSpecific["serial"].(string); ok && - len(s) <= 16 { - serial := fmt.Sprintf("%016s", s) - d.metaState.SerialNumber = serial - } + if len(d.metaState.SerialNumber) > 0 && len(d.metaState.SerialNumber) <= 16 { + d.metaState.SerialNumber = fmt.Sprintf("%016s", d.metaState.SerialNumber) } } diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 8ccf2286..e75cc5e1 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -26,10 +26,10 @@ func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { } metaState := MetaState{} - if len(o.DeviceSpecific) == 0 { - o.DeviceSpecific = map[string]any{} - } else { - metaState.UpdateFromMap(o.DeviceSpecific) + 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 != "" { @@ -47,7 +47,11 @@ func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { } metaState.SerialNumber = serial serials[serial] = struct{}{} - o.DeviceSpecific = metaState.ToMap() + b, err := json.Marshal(metaState) + if err != nil { + return nil, fmt.Errorf("marshal meta state: %w", err) + } + o.DeviceSpecific = string(b) return New(o) } diff --git a/device/options.go b/device/options.go index 285937a5..519e5466 100644 --- a/device/options.go +++ b/device/options.go @@ -3,5 +3,5 @@ package device type CreateOptions struct { IDVendor *uint16 IDProduct *uint16 - DeviceSpecific map[string]any + DeviceSpecific string } diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 4324423d..2ace97ce 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -36,13 +36,9 @@ func New(o *device.CreateOptions) (*Xbox360, error) { if o.IDProduct != nil { d.descriptor.Device.IDProduct = *o.IDProduct } - if o.DeviceSpecific != nil { - data, err := json.Marshal(o.DeviceSpecific) + if o.DeviceSpecific != "" { var args Xbox360CreateOptions - if err != nil { - return nil, fmt.Errorf("invalid JSON payload: %w", err) - } - err = json.Unmarshal(data, &args) + err := json.Unmarshal([]byte(o.DeviceSpecific), &args) if err != nil { return nil, fmt.Errorf("invalid JSON payload: %w", err) } diff --git a/internal/server/api/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index e85b3d91..626c06f6 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -49,9 +49,15 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { } opts := device.CreateOptions{ - IDVendor: deviceCreateReq.IDVendor, - IDProduct: deviceCreateReq.IDProduct, - DeviceSpecific: deviceCreateReq.DeviceSpecific, + 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) diff --git a/lib/viiper/xbox360.go b/lib/viiper/xbox360.go index 2aa4a528..80c3ee57 100644 --- a/lib/viiper/xbox360.go +++ b/lib/viiper/xbox360.go @@ -104,12 +104,7 @@ func CreateXbox360Device( if err != nil { return false } - var deviceSpecific map[string]any - err = json.Unmarshal(str, &deviceSpecific) - if err != nil { - return false - } - opts.DeviceSpecific = deviceSpecific + opts.DeviceSpecific = string(str) } d, err := xbox360.New(opts) if err != nil { diff --git a/viiperclient/client.go b/viiperclient/client.go index f0d0f5b8..dbb1327c 100644 --- a/viiperclient/client.go +++ b/viiperclient/client.go @@ -107,11 +107,18 @@ func (c *Client) DeviceAddCtx(ctx context.Context, busID uint32, devType string, 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: o.DeviceSpecific, + DeviceSpecific: deviceSpecific, } payloadBytes, err := json.Marshal(req) if err != nil { From f4eda9ba0fd0fd95268a47530c482af6d560658d Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 12:25:31 +0200 Subject: [PATCH 181/298] Fix client lib builds --- internal/codegen/common/constants_filter.go | 25 +++++++++++++++++++ .../codegen/generator/cpp/device_header.go | 10 +++++++- .../codegen/generator/csharp/constants.go | 4 +++ internal/codegen/generator/rust/constants.go | 3 +++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 internal/codegen/common/constants_filter.go diff --git a/internal/codegen/common/constants_filter.go b/internal/codegen/common/constants_filter.go new file mode 100644 index 00000000..1e217f7c --- /dev/null +++ b/internal/codegen/common/constants_filter.go @@ -0,0 +1,25 @@ +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 + } + + 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/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go index c25d4922..20b5965a 100644 --- a/internal/codegen/generator/cpp/device_header.go +++ b/internal/codegen/generator/cpp/device_header.go @@ -272,6 +272,14 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md } } + 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 @@ -285,7 +293,7 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md }{ Header: writeFileHeader(), DeviceName: deviceName, - Constants: devicePkg.Constants, + Constants: constants, Maps: devicePkg.Maps, HasInput: hasInput, HasOutput: hasOutput, diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index fab03ca2..4c59fc8b 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -113,6 +113,10 @@ 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 diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go index 03b4db78..6979f81c 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -88,6 +88,9 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, } for _, c := range devicePkg.Constants { + if !common.IsIntegerConst(c.Value, c.Type) { + continue + } rustType := goTypeToRust(c.Type) value := formatConstValue(c.Value, c.Type) constants = append(constants, rustConstant{ From 6fd0c9f8b387b255bd6c02b9b4179162a13c1297 Mon Sep 17 00:00:00 2001 From: OddC Date: Tue, 26 May 2026 23:15:28 +1000 Subject: [PATCH 182/298] Nintendo Switch 2 Pro virtual device support Changelog: feat --- README.md | 1 + device/ns2pro/commands.go | 131 +++++++ device/ns2pro/const.go | 74 ++++ device/ns2pro/descriptor.go | 149 ++++++++ device/ns2pro/device.go | 228 +++++++++++ device/ns2pro/flash.go | 24 ++ device/ns2pro/handler.go | 67 ++++ device/ns2pro/inputstate.go | 270 +++++++++++++ device/ns2pro/ns2pro_test.go | 508 +++++++++++++++++++++++++ docs/clients/go.md | 9 +- docs/devices/ns2pro.md | 63 +++ examples/go/virtual_ns2pro/main.go | 194 ++++++++++ internal/registry/devices.go | 1 + internal/server/usb/descriptor_test.go | 70 ++++ internal/server/usb/server.go | 79 +++- mkdocs.yml | 9 +- usb/usbdesc.go | 182 ++++++++- 17 files changed, 2036 insertions(+), 23 deletions(-) create mode 100644 device/ns2pro/commands.go create mode 100644 device/ns2pro/const.go create mode 100644 device/ns2pro/descriptor.go create mode 100644 device/ns2pro/device.go create mode 100644 device/ns2pro/flash.go create mode 100644 device/ns2pro/handler.go create mode 100644 device/ns2pro/inputstate.go create mode 100644 device/ns2pro/ns2pro_test.go create mode 100644 docs/devices/ns2pro.md create mode 100644 examples/go/virtual_ns2pro/main.go create mode 100644 internal/server/usb/descriptor_test.go diff --git a/README.md b/README.md index 952bb6e7..6d7b0247 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio - 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) - PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) +- Nintendo Switch 2 Pro Controller emulation; see [Devices › Switch 2 Pro Controller](docs/devices/ns2pro.md) - 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) ## 🔌 Requirements diff --git a/device/ns2pro/commands.go b/device/ns2pro/commands.go new file mode 100644 index 00000000..5b8a33a3 --- /dev/null +++ b/device/ns2pro/commands.go @@ -0,0 +1,131 @@ +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 0x02: + d.handleFlashCommand(seq, sub, out) + case 0x03: + d.handleUSBCommand(seq, sub, out) + case 0x0C: + d.handleFeatureCommand(seq, sub, out) + case 0x09: + d.handlePlayerLEDCommand(seq, sub, out) + default: + d.enqueueResponse(commandHeader(cmd, seq, sub)) + } +} + +func (d *NS2Pro) handlePlayerLEDCommand(seq, sub uint8, out []byte) { + if sub == 0x07 && len(out) >= 9 { + d.emitOutput(OutputState{ + Flags: OutputFlagLED, + PlayerLedMask: out[8], + }) + } + d.enqueueResponse(commandHeader(0x09, seq, sub)) +} + +func (d *NS2Pro) handleFlashCommand(seq, sub uint8, out []byte) { + if sub != 0x01 || len(out) < 16 { + d.enqueueResponse(commandHeader(0x02, seq, sub)) + return + } + + address := binary.LittleEndian.Uint32(out[12:16]) + resp := make([]byte, 0x50) + copy(resp[0:8], commandHeader(0x02, seq, sub)) + resp[8] = 0x40 + binary.LittleEndian.PutUint32(resp[12:16], address) + copy(resp[16:], minimalFlashBlock(address)) + d.enqueueResponse(resp) +} + +func (d *NS2Pro) handleUSBCommand(seq, sub uint8, out []byte) { + switch sub { + case 0x03: + if len(out) >= 9 { + d.usbReportsEnabled = out[8] != 0 + } + d.enqueueResponse(append(commandHeader(0x03, seq, sub), 0x01, 0x00, 0x00, 0x00)) + case 0x0A: + if len(out) >= 9 { + switch out[8] { + case ReportIDCommon, ReportIDPro: + d.activeReportID = out[8] + } + } + d.enqueueResponse(commandHeader(0x03, seq, sub)) + case 0x0D: + d.usbReportsEnabled = true + d.enqueueResponse(append(commandHeader(0x03, seq, sub), 0x01, 0x00, 0x00, 0x00)) + default: + d.enqueueResponse(commandHeader(0x03, seq, sub)) + } +} + +func (d *NS2Pro) handleFeatureCommand(seq, sub uint8, out []byte) { + flags := uint8(0) + if len(out) >= 9 { + flags = out[8] + } + + switch sub { + case 0x01: + payload := make([]byte, 12) + copy(payload[4:], featureInfo(flags)) + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), payload...)) + case 0x02: + d.featureMask = flags + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case 0x03: + d.featureMask = 0 + d.featureFlags = 0 + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case 0x04: + d.featureFlags |= d.maskedFeatures(flags) + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + case 0x05: + d.featureFlags &^= d.maskedFeatures(flags) + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + default: + d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + } +} + +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..a589b72c --- /dev/null +++ b/device/ns2pro/const.go @@ -0,0 +1,74 @@ +package ns2pro + +const ( + DefaultVID = 0x057E + DefaultPID = 0x2069 + DefaultSerial = "00" +) + +const ( + EndpointHIDIn = 0x81 + EndpointHIDOut = 0x01 + EndpointBulkOut = 0x02 + EndpointBulkIn = 0x82 +) + +const ( + ReportIDCommon = 0x05 + ReportIDPro = 0x09 + ReportIDOutput = 0x02 +) + +const ( + InputReportSize = 64 + OutputReportSize = 64 + InputWireSize = 27 + OutputRumbleSize = 32 + OutputWireSize = 34 +) + +const ( + OutputFlagRumble = 0x01 + OutputFlagLED = 0x02 +) + +const ( + StickMin uint16 = 0 + StickCenter uint16 = 0x0800 + StickMax uint16 = 0x0FFF + BatteryMax uint8 = 9 + BatteryVolts uint16 = 3800 +) + +const ( + FeatureButtons = 0x01 + FeatureSticks = 0x02 + FeatureIMU = 0x04 + FeatureMouse = 0x10 + FeatureRumble = 0x20 +) + +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 +) diff --git a/device/ns2pro/descriptor.go b/device/ns2pro/descriptor.go new file mode 100644 index 00000000..3511d8d4 --- /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: DefaultSerial, + 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..9f77314a --- /dev/null +++ b/device/ns2pro/device.go @@ -0,0 +1,228 @@ +// Package ns2pro provides a Nintendo Switch 2 Pro Controller compatible HID device. +package ns2pro + +import ( + "sync" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type NS2Pro struct { + stateMu sync.Mutex + inputState *InputState + outputMu sync.RWMutex + outputCallback func(OutputState) + outputVersion uint64 + descriptor usb.Descriptor + + batteryVolts uint16 + + protoMu sync.Mutex + activeReportID uint8 + featureMask uint8 + featureFlags uint8 + usbReportsEnabled bool + reportCounter32 uint32 + reportCounter8 uint8 + motionTimestamp uint32 + bulkInQueue [][]byte +} + +func New(o *device.CreateOptions) (*NS2Pro, error) { + d := &NS2Pro{ + inputState: defaultInputState(), + descriptor: MakeDescriptor(), + activeReportID: ReportIDPro, + featureFlags: FeatureButtons | FeatureSticks, + batteryVolts: BatteryVolts, + } + 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() + defer d.stateMu.Unlock() + d.inputState = &state +} + +func (d *NS2Pro) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { + switch { + case dir == usbip.DirIn && ep == 1: + return d.nextInputReport() + case dir == usbip.DirIn && ep == 2: + return d.popBulkIn() + 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) { + const ( + hidGetReport = 0x01 + hidSetReport = 0x09 + ) + const ( + reportTypeInput = 0x01 + reportTypeOutput = 0x02 + ) + + reportType := uint8(wValue >> 8) + reportID := uint8(wValue) + + if bmRequestType == 0xA1 && bRequest == hidGetReport && reportType == reportTypeInput { + switch reportID { + case ReportIDCommon, ReportIDPro, 0: + return d.inputReportForID(reportID), true + } + } + + if bmRequestType == 0x21 && bRequest == hidSetReport && reportType == reportTypeOutput && reportID == ReportIDOutput { + d.handleOutputReport(data) + return nil, true + } + + if isAudioClassRequest(bmRequestType) { + switch bRequest { + case 0x01: // SET_CUR + return nil, true + case 0x81, 0x82, 0x83, 0x84: // GET_CUR/MIN/MAX/RES + return make([]byte, wLength), true + } + } + + return nil, false +} + +func isAudioClassRequest(bmRequestType uint8) bool { + const ( + requestTypeMask = 0x60 + requestClass = 0x20 + recipientMask = 0x1F + recipientIface = 0x01 + recipientEndpoint = 0x02 + ) + 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 { + return nil +} + +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 + d.stateMu.Unlock() + + d.protoMu.Lock() + if reportID == 0 { + reportID = d.activeReportID + } + features := d.featureFlags + var report []byte + switch reportID { + case ReportIDCommon: + d.reportCounter32++ + if features&FeatureIMU != 0 { + d.motionTimestamp += 4000 + } + report = st.buildCommonReport(d.reportCounter32, d.motionTimestamp, features, d.batteryVolts) + default: + d.reportCounter8++ + report = st.buildProReport(d.reportCounter8, features) + } + d.protoMu.Unlock() + return report +} + +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() + defer d.protoMu.Unlock() + d.bulkInQueue = append(d.bulkInQueue, append([]byte(nil), resp...)) +} + +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..2658eb27 --- /dev/null +++ b/device/ns2pro/flash.go @@ -0,0 +1,24 @@ +package ns2pro + +func minimalFlashBlock(address uint32) []byte { + block := make([]byte, 0x40) + switch address { + case 0x13000: + copy(block[2:], []byte("VIIPER-NS2PRO-00")) + 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..83629eaf --- /dev/null +++ b/device/ns2pro/handler.go @@ -0,0 +1,67 @@ +package ns2pro + +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("ns2pro", &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") + } + 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 { + return nil +} diff --git a/device/ns2pro/inputstate.go b/device/ns2pro/inputstate.go new file mode 100644 index 00000000..86882c82 --- /dev/null +++ b/device/ns2pro/inputstate.go @@ -0,0 +1,270 @@ +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 batteryLevel:u8 charging:bool externalPower:bool +type InputState struct { + Buttons uint32 + + LX, LY uint16 + RX, RY uint16 + + AccelX, AccelY, AccelZ int16 + GyroX, GyroY, GyroZ int16 + + BatteryLevel uint8 + Charging bool + ExternalPower bool +} + +func defaultInputState() *InputState { + return &InputState{ + LX: StickCenter, + LY: StickCenter, + RX: StickCenter, + RY: StickCenter, + BatteryLevel: BatteryMax, + ExternalPower: true, + } +} + +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)) + b[24] = s.BatteryLevel + if s.Charging { + b[25] = 1 + } + if s.ExternalPower { + b[26] = 1 + } + 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])) + s.BatteryLevel = data[24] + s.Charging = data[25] != 0 + s.ExternalPower = data[26] != 0 + 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, batteryVolts uint16) []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], batteryVolts) + b[0x22] = chargingState(s) + 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) []byte { + b := make([]byte, InputReportSize) + b[0] = ReportIDPro + b[1] = counter + b[2] = powerInfo(s) + + 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(s InputState) uint8 { + level := s.BatteryLevel + if level > BatteryMax { + level = BatteryMax + } + out := (level & 0x0F) << 2 + if s.ExternalPower { + out |= 0x01 + } + if s.Charging { + out |= 0x02 + } + return out +} + +func chargingState(s InputState) uint8 { + if s.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..45113899 --- /dev/null +++ b/device/ns2pro/ns2pro_test.go @@ -0,0 +1,508 @@ +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, + BatteryLevel: 7, + Charging: true, + ExternalPower: true, + } + dev.UpdateInputState(state) + + dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + dev.HandleTransfer(2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + + report := dev.HandleTransfer(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, BatteryVolts, binary.LittleEndian.Uint16(report[0x20:0x22])) + assert.Equal(t, byte(0x34), report[0x22]) + assert.Equal(t, byte(0x01), report[0x2A]) + assert.Equal(t, uint32(4000), 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])) + + next := dev.HandleTransfer(1, usbip.DirIn, nil) + require.Len(t, next, InputReportSize) + assert.Equal(t, uint32(2), binary.LittleEndian.Uint32(next[1:5])) + assert.Equal(t, uint32(8000), binary.LittleEndian.Uint32(next[0x2B:0x2F])) +} + +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, DefaultSerial, 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 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, + BatteryLevel: 5, + ExternalPower: true, + } + dev.UpdateInputState(state) + dev.HandleTransfer(2, usbip.DirOut, selectReportCommand(ReportIDPro)) + assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + + report := dev.HandleTransfer(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(2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) + assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + + dev.HandleTransfer(2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + report := dev.HandleTransfer(1, usbip.DirIn, nil) + require.Len(t, report, InputReportSize) + assert.Equal(t, byte(ReportIDCommon), report[0]) + + dev.HandleTransfer(2, usbip.DirOut, flashReadCommand(0x13000)) + resp := dev.HandleTransfer(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 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(2, usbip.DirOut, flashReadCommand(address)) + resp := dev.HandleTransfer(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(2, usbip.DirOut, cmd) + assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil), "cmd %#x sub %#x", cmd[0], cmd[3]) + } + + dev.UpdateInputState(InputState{BatteryLevel: 9}) + report := dev.HandleTransfer(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, uint32(4000), 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(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(2, usbip.DirOut, []byte{0x09, 0x91, 0x12, 0x07, 0x00, 0x08, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00}) + resp := dev.HandleTransfer(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(DefaultSerial), 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)) + + state := InputState{ + Buttons: ButtonA | ButtonHome | ButtonRight, + LX: 0x0123, + LY: 0x0456, + RX: 0x0789, + RY: 0x0ABC, + BatteryLevel: 6, + ExternalPower: true, + } + require.NoError(t, stream.WriteBinary(&state)) + + expected := state.buildProReport(0, FeatureButtons|FeatureSticks) + got := pollInputIgnoringCounter(t, usbipClient, imp.Conn, expected, 750*time.Millisecond) + 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(750 * time.Millisecond)) + _, 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{0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00, reportID, 0x00, 0x00, 0x00} +} + +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 { + 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(750 * time.Millisecond)) + 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/docs/clients/go.md b/docs/clients/go.md index 9d479698..66508410 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -200,10 +200,11 @@ if err != nil { Full working examples are available in the repository: -- **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` -- More examples are always being added! +- **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 Switch 2 Pro Controller**: `examples/go/virtual_ns2pro/main.go` +- More examples are always being added! ## See Also diff --git a/docs/devices/ns2pro.md b/docs/devices/ns2pro.md new file mode 100644 index 00000000..f918bc82 --- /dev/null +++ b/docs/devices/ns2pro.md @@ -0,0 +1,63 @@ +# 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` | diff --git a/examples/go/virtual_ns2pro/main.go b/examples/go/virtual_ns2pro/main.go new file mode 100644 index 00000000..15258f7e --- /dev/null +++ b/examples/go/virtual_ns2pro/main.go @@ -0,0 +1,194 @@ +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)), + BatteryLevel: ns2pro.BatteryMax, + ExternalPower: true, + } + + 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/internal/registry/devices.go b/internal/registry/devices.go index 9e57d753..15ce89a0 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -4,5 +4,6 @@ import ( _ "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/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go new file mode 100644 index 00000000..19f8449e --- /dev/null +++ b/internal/server/usb/descriptor_test.go @@ -0,0 +1,70 @@ +package usb + +import ( + "bytes" + "encoding/binary" + "testing" + + usbdesc "github.com/Alia5/VIIPER/usb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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, + 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{0x07, 0x25, 0x01, 0x00, 0x00, 0x00, 0x00})) +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 41370672..27bf1cd7 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -456,10 +456,10 @@ func (s *Server) handleDevList(conn net.Conn) error { BDeviceProtocol: desc.Device.BDeviceProtocol, BConfigurationValue: usbConfigValueDefault, BNumConfigurations: desc.Device.BNumConfigurations, - BNumInterfaces: uint8(len(desc.Interfaces)), + BNumInterfaces: desc.NumInterfaces(), } - for _, iface := range desc.Interfaces { + for _, iface := range descriptorListInterfaces(desc) { exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ Class: iface.Descriptor.BInterfaceClass, SubClass: iface.Descriptor.BInterfaceSubClass, @@ -512,9 +512,9 @@ func (s *Server) handleImport(conn net.Conn) (usb.Device, error) { BDeviceProtocol: chosenDesc.Device.BDeviceProtocol, BConfigurationValue: usbConfigValueDefault, BNumConfigurations: chosenDesc.Device.BNumConfigurations, - BNumInterfaces: uint8(len(chosenDesc.Interfaces)), + BNumInterfaces: chosenDesc.NumInterfaces(), } - for _, iface := range chosenDesc.Interfaces { + for _, iface := range descriptorListInterfaces(chosenDesc) { exp.Interfaces = append(exp.Interfaces, usbip.InterfaceDesc{ Class: iface.Descriptor.BInterfaceClass, SubClass: iface.Descriptor.BInterfaceSubClass, @@ -762,7 +762,9 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by case usbDescTypeConfiguration: data = s.buildConfigDescriptor(desc) case usbDescTypeString: - if s, ok := desc.Strings[dindex]; ok { + if dindex == 0xEE && desc.MicrosoftOS10 != nil { + data = desc.MicrosoftOS10.StringDescriptor() + } else if s, ok := desc.Strings[dindex]; ok { data = usb.EncodeStringDescriptor(s) } } @@ -774,12 +776,23 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by } return data } + + if desc.MicrosoftOS10 != nil && + breq == desc.MicrosoftOS10.EffectiveVendorCode() && + (bm == 0xC0 || bm == 0xC1) { + 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 int(iface) < len(desc.Interfaces) { - ifaceConf := desc.Interfaces[iface] + if ifaceConf, ok := desc.Interface(iface); ok { if ifaceConf.HID != nil { switch dtype { case usbDescTypeHID: @@ -854,16 +867,33 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by 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: uint8(len(desc.Interfaces)), - BConfigurationValue: usbConfigValueDefault, - IConfiguration: 0, - BMAttributes: usbConfigAttrBusPowered, - BMaxPower: usbConfigMaxPower100mA, + 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() @@ -879,6 +909,9 @@ func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { } for _, ep := range iface.Endpoints { ep.Write(&b) + for _, cd := range ep.ClassDescriptors { + b.Write([]byte(cd.Bytes())) + } } } @@ -886,3 +919,25 @@ func (s *Server) buildConfigDescriptor(desc *usb.Descriptor) []byte { 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 +} diff --git a/mkdocs.yml b/mkdocs.yml index 0943d2f8..0e9f9205 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,9 +81,10 @@ nav: - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md - Devices: - - Xbox 360 Controller: devices/xbox360.md - - DualShock 4 Controller: devices/dualshock4.md - - Keyboard: devices/keyboard.md - - Mouse: devices/mouse.md + - Xbox 360 Controller: devices/xbox360.md + - DualShock 4 Controller: devices/dualshock4.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/usb/usbdesc.go b/usb/usbdesc.go index 8f5d7d15..e45b71ba 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/binary" "fmt" + "unicode/utf16" "github.com/Alia5/VIIPER/usb/hid" ) @@ -15,6 +16,7 @@ const ( ConfigDescType = 0x02 InterfaceDescType = 0x04 EndpointDescType = 0x05 + IADDescType = 0x0B HIDDescType = 0x21 ReportDescType = 0x22 ) @@ -23,6 +25,7 @@ const ( const ( DeviceDescLen = 18 ConfigDescLen = 9 + IADDescLen = 8 InterfaceDescLen = 9 EndpointDescLen = 7 HIDDescLen = 9 @@ -32,9 +35,147 @@ type Data []uint8 // Descriptor holds all static descriptor/config data for a device. type Descriptor struct { - Device DeviceDescriptor - Interfaces []InterfaceConfig - Strings map[uint8]string + 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. @@ -122,6 +263,37 @@ type ConfigHeader struct { 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) @@ -164,6 +336,10 @@ type EndpointDescriptor struct { BMAttributes uint8 WMaxPacketSize uint16 // LE BInterval uint8 + + // ClassDescriptors are optional endpoint-level class-specific descriptors + // emitted immediately after this endpoint descriptor. + ClassDescriptors []ClassSpecificDescriptor } func (e EndpointDescriptor) Write(b *bytes.Buffer) { From 9fddebb39da627736645983e98d17e4862e69323 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 13:05:34 +0200 Subject: [PATCH 183/298] NS2: Factor out Serial/Battery/ChargingState to separate struct --- device/ns2pro/const.go | 19 +++--- device/ns2pro/descriptor.go | 2 +- device/ns2pro/device.go | 62 +++++++++++++++-- device/ns2pro/flash.go | 2 +- device/ns2pro/handler.go | 68 +++++++++++++++++++ device/ns2pro/inputstate.go | 61 ++++++++--------- device/ns2pro/ns2pro_test.go | 103 ++++++++++++++++++++--------- examples/go/virtual_ns2pro/main.go | 24 +++---- 8 files changed, 248 insertions(+), 93 deletions(-) diff --git a/device/ns2pro/const.go b/device/ns2pro/const.go index a589b72c..d9af9108 100644 --- a/device/ns2pro/const.go +++ b/device/ns2pro/const.go @@ -1,9 +1,11 @@ package ns2pro const ( - DefaultVID = 0x057E - DefaultPID = 0x2069 - DefaultSerial = "00" + DefaultVID = 0x057E + DefaultPID = 0x2069 + DefaultSerialEnding = "00" + DefaultSerial = "VIIPER-NS2PRO-" + DefaultSerialEnding + DefaultBatteryVolts uint16 = 3800 ) const ( @@ -22,7 +24,7 @@ const ( const ( InputReportSize = 64 OutputReportSize = 64 - InputWireSize = 27 + InputWireSize = 24 OutputRumbleSize = 32 OutputWireSize = 34 ) @@ -33,11 +35,10 @@ const ( ) const ( - StickMin uint16 = 0 - StickCenter uint16 = 0x0800 - StickMax uint16 = 0x0FFF - BatteryMax uint8 = 9 - BatteryVolts uint16 = 3800 + StickMin uint16 = 0 + StickCenter uint16 = 0x0800 + StickMax uint16 = 0x0FFF + BatteryMax uint8 = 9 ) const ( diff --git a/device/ns2pro/descriptor.go b/device/ns2pro/descriptor.go index 3511d8d4..0e8a4ac9 100644 --- a/device/ns2pro/descriptor.go +++ b/device/ns2pro/descriptor.go @@ -84,7 +84,7 @@ func MakeDescriptor() usb.Descriptor { 0: "\u0409", 1: "Nintendo", 2: "Switch 2 Pro Controller", - 3: DefaultSerial, + 3: DefaultSerialEnding, 4: "Nintendo Switch 2 Pro Controller", 5: "Nintendo Switch 2 Pro Controller", 6: "Pro Controller", diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 9f77314a..ce641bc2 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -2,6 +2,8 @@ package ns2pro import ( + "encoding/json" + "fmt" "sync" "github.com/Alia5/VIIPER/device" @@ -12,13 +14,12 @@ import ( type NS2Pro struct { stateMu sync.Mutex inputState *InputState + metaState *MetaState outputMu sync.RWMutex outputCallback func(OutputState) outputVersion uint64 descriptor usb.Descriptor - batteryVolts uint16 - protoMu sync.Mutex activeReportID uint8 featureMask uint8 @@ -31,13 +32,26 @@ type NS2Pro struct { } func New(o *device.CreateOptions) (*NS2Pro, error) { + metaState := defaultMetaState() + if o != nil && o.DeviceSpecific != "" { + if err := json.Unmarshal([]byte(o.DeviceSpecific), metaState); err != nil { + return nil, fmt.Errorf("invalid device specific JSON: %w", err) + } + } + d := &NS2Pro{ inputState: defaultInputState(), + metaState: metaState, descriptor: MakeDescriptor(), activeReportID: ReportIDPro, featureFlags: FeatureButtons | FeatureSticks, - batteryVolts: BatteryVolts, } + 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 @@ -71,6 +85,19 @@ func (d *NS2Pro) UpdateInputState(state InputState) { d.inputState = &state } +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(ep uint32, dir uint32, out []byte) []byte { switch { case dir == usbip.DirIn && ep == 1: @@ -139,7 +166,20 @@ func (d *NS2Pro) GetDescriptor() *usb.Descriptor { } func (d *NS2Pro) GetDeviceSpecificArgs() map[string]any { - return nil + 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) nextInputReport() []byte { @@ -152,6 +192,7 @@ func (d *NS2Pro) nextInputReport() []byte { func (d *NS2Pro) inputReportForID(reportID uint8) []byte { d.stateMu.Lock() st := *d.inputState + meta := *d.metaState d.stateMu.Unlock() d.protoMu.Lock() @@ -166,15 +207,24 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { if features&FeatureIMU != 0 { d.motionTimestamp += 4000 } - report = st.buildCommonReport(d.reportCounter32, d.motionTimestamp, features, d.batteryVolts) + report = st.buildCommonReport(d.reportCounter32, d.motionTimestamp, features, meta) default: d.reportCounter8++ - report = st.buildProReport(d.reportCounter8, features) + 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 diff --git a/device/ns2pro/flash.go b/device/ns2pro/flash.go index 2658eb27..8712c2c1 100644 --- a/device/ns2pro/flash.go +++ b/device/ns2pro/flash.go @@ -4,7 +4,7 @@ func minimalFlashBlock(address uint32) []byte { block := make([]byte, 0x40) switch address { case 0x13000: - copy(block[2:], []byte("VIIPER-NS2PRO-00")) + copy(block[2:], []byte(DefaultSerial)) case 0x13080, 0x130C0: encodeStickCalibration(block[0x28:], StickCenter, StickCenter, 2047, 2047, 2048, 2048) case 0x13040, 0x13100, 0x1FC040, 0x1FC080: diff --git a/device/ns2pro/handler.go b/device/ns2pro/handler.go index 83629eaf..5af917b1 100644 --- a/device/ns2pro/handler.go +++ b/device/ns2pro/handler.go @@ -1,6 +1,7 @@ package ns2pro import ( + "encoding/json" "fmt" "io" "log/slog" @@ -17,12 +18,68 @@ func init() { 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") } @@ -63,5 +120,16 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } 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 index 86882c82..0d4da4b6 100644 --- a/device/ns2pro/inputstate.go +++ b/device/ns2pro/inputstate.go @@ -6,7 +6,7 @@ import ( ) // 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 batteryLevel:u8 charging:bool externalPower:bool +// 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 @@ -15,20 +15,31 @@ type InputState struct { AccelX, AccelY, AccelZ int16 GyroX, GyroY, GyroZ int16 - - BatteryLevel uint8 - Charging bool - ExternalPower bool } func defaultInputState() *InputState { return &InputState{ - LX: StickCenter, - LY: StickCenter, - RX: StickCenter, - RY: StickCenter, + LX: StickCenter, + LY: StickCenter, + RX: StickCenter, + RY: StickCenter, + } +} + +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, } } @@ -45,13 +56,6 @@ func (s *InputState) MarshalBinary() ([]byte, error) { 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)) - b[24] = s.BatteryLevel - if s.Charging { - b[25] = 1 - } - if s.ExternalPower { - b[26] = 1 - } return b, nil } @@ -70,9 +74,6 @@ func (s *InputState) UnmarshalBinary(data []byte) error { 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])) - s.BatteryLevel = data[24] - s.Charging = data[25] != 0 - s.ExternalPower = data[26] != 0 return nil } @@ -105,7 +106,7 @@ func (o *OutputState) UnmarshalBinary(data []byte) error { return nil } -func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features uint8, batteryVolts uint16) []byte { +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) @@ -115,8 +116,8 @@ func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features packStick12(b[11:14], s.LX, s.LY) packStick12(b[14:17], s.RX, s.RY) - binary.LittleEndian.PutUint16(b[0x20:0x22], batteryVolts) - b[0x22] = chargingState(s) + binary.LittleEndian.PutUint16(b[0x20:0x22], meta.BatteryVolts) + b[0x22] = chargingState(meta) b[0x2A] = 0x01 if features&FeatureIMU != 0 { @@ -132,11 +133,11 @@ func (s InputState) buildCommonReport(counter, motionTimestamp uint32, features return b } -func (s InputState) buildProReport(counter uint8, features uint8) []byte { +func (s InputState) buildProReport(counter uint8, features uint8, meta MetaState) []byte { b := make([]byte, InputReportSize) b[0] = ReportIDPro b[1] = counter - b[2] = powerInfo(s) + b[2] = powerInfo(meta) buttons := s.proButtonBytes() copy(b[3:6], buttons[:]) @@ -247,23 +248,23 @@ func clampStick(v uint16) uint16 { return v } -func powerInfo(s InputState) uint8 { - level := s.BatteryLevel +func powerInfo(meta MetaState) uint8 { + level := meta.BatteryLevel if level > BatteryMax { level = BatteryMax } out := (level & 0x0F) << 2 - if s.ExternalPower { + if meta.ExternalPower { out |= 0x01 } - if s.Charging { + if meta.Charging { out |= 0x02 } return out } -func chargingState(s InputState) uint8 { - if s.Charging { +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 index 45113899..e78f7f29 100644 --- a/device/ns2pro/ns2pro_test.go +++ b/device/ns2pro/ns2pro_test.go @@ -27,22 +27,26 @@ func TestBuildReport05(t *testing.T) { 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, + 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, - } - dev.UpdateInputState(state) + BatteryVolts: DefaultBatteryVolts, + }) dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) @@ -60,7 +64,7 @@ func TestBuildReport05(t *testing.T) { packStick12(rightStick[:], state.RX, state.RY) assert.Equal(t, leftStick[:], report[11:14]) assert.Equal(t, rightStick[:], report[14:17]) - assert.Equal(t, BatteryVolts, binary.LittleEndian.Uint16(report[0x20:0x22])) + assert.Equal(t, DefaultBatteryVolts, binary.LittleEndian.Uint16(report[0x20:0x22])) assert.Equal(t, byte(0x34), report[0x22]) assert.Equal(t, byte(0x01), report[0x2A]) assert.Equal(t, uint32(4000), binary.LittleEndian.Uint32(report[0x2B:0x2F])) @@ -92,7 +96,7 @@ func TestDescriptor(t *testing.T) { 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, DefaultSerial, desc.Strings[3]) + 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]) @@ -122,20 +126,49 @@ func TestDescriptor(t *testing.T) { 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, - BatteryLevel: 5, - ExternalPower: true, + 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(2, usbip.DirOut, selectReportCommand(ReportIDPro)) assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) @@ -211,7 +244,13 @@ func TestSDLUSBInitializationSequence(t *testing.T) { assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil), "cmd %#x sub %#x", cmd[0], cmd[3]) } - dev.UpdateInputState(InputState{BatteryLevel: 9}) + dev.SetMetaState(MetaState{ + SerialNumber: DefaultSerial, + BatteryLevel: 9, + ExternalPower: true, + BatteryVolts: DefaultBatteryVolts, + }) + dev.UpdateInputState(InputState{}) report := dev.HandleTransfer(1, usbip.DirIn, nil) require.Len(t, report, InputReportSize) assert.Equal(t, byte(ReportIDCommon), report[0]) @@ -348,7 +387,7 @@ func TestStreamInputAndRumble(t *testing.T) { serialString, err := controlIn(imp.Conn, controlSetup(0x0303, 0, 64)) require.NoError(t, err) - assert.Equal(t, usb.EncodeStringDescriptor(DefaultSerial), serialString) + assert.Equal(t, usb.EncodeStringDescriptor(DefaultSerialEnding), serialString) msOSString, err := controlIn(imp.Conn, controlSetup(0x03EE, 0, 18)) require.NoError(t, err) @@ -374,17 +413,15 @@ func TestStreamInputAndRumble(t *testing.T) { require.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 2, selectReportCommand(ReportIDPro), nil)) state := InputState{ - Buttons: ButtonA | ButtonHome | ButtonRight, - LX: 0x0123, - LY: 0x0456, - RX: 0x0789, - RY: 0x0ABC, - BatteryLevel: 6, - ExternalPower: true, + Buttons: ButtonA | ButtonHome | ButtonRight, + LX: 0x0123, + LY: 0x0456, + RX: 0x0789, + RY: 0x0ABC, } require.NoError(t, stream.WriteBinary(&state)) - expected := state.buildProReport(0, FeatureButtons|FeatureSticks) + expected := state.buildProReport(0, FeatureButtons|FeatureSticks, *defaultMetaState()) got := pollInputIgnoringCounter(t, usbipClient, imp.Conn, expected, 750*time.Millisecond) require.Len(t, got, InputReportSize) got[1] = 0 @@ -442,7 +479,7 @@ func utf16leToString(b []byte) string { return string(utf16.Decode(units)) } -func controlSetup(wValue, wIndex, wLength uint16) [8]byte { +func controlSetup(wValue, wIndex, wLength uint16) [8]byte { // nolint:unparam var setup [8]byte setup[0] = 0x80 // bmRequestType setup[1] = 0x06 // bRequest diff --git a/examples/go/virtual_ns2pro/main.go b/examples/go/virtual_ns2pro/main.go index 15258f7e..2b65db9c 100644 --- a/examples/go/virtual_ns2pro/main.go +++ b/examples/go/virtual_ns2pro/main.go @@ -107,19 +107,17 @@ func main() { 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)), - BatteryLevel: ns2pro.BatteryMax, - ExternalPower: true, + 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 { From 29064653d0497030f0d5a071055217331bd42147 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 13:40:51 +0200 Subject: [PATCH 184/298] Fix MSOS10 Descriptors after rebase --- internal/server/usb/server.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 27bf1cd7..fe0802be 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -691,6 +691,11 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { return fmt.Errorf("write RET_SUBMIT payload: %w", err) } } + if bw != nil && ep == 0 { + if err := bw.Flush(); err != nil { + return fmt.Errorf("flush EP0 response: %w", err) + } + } _ = xferFlags _ = devid } @@ -778,8 +783,9 @@ func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []by } if desc.MicrosoftOS10 != nil && - breq == desc.MicrosoftOS10.EffectiveVendorCode() && - (bm == 0xC0 || bm == 0xC1) { + (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] From 2280786ae3690850e57ec9477c1fdec7fc546c89 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 14:09:25 +0200 Subject: [PATCH 185/298] Update SDK generator --- internal/codegen/common/constants_filter.go | 2 + .../codegen/generator/cpp/device_header.go | 55 ++- .../codegen/generator/csharp/constants.go | 56 ++- internal/codegen/generator/rust/constants.go | 10 +- .../codegen/generator/typescript/constants.go | 44 ++- internal/codegen/scanner/constants.go | 329 ++++++++++++------ internal/codegen/scanner/constants_test.go | 30 ++ 7 files changed, 418 insertions(+), 108 deletions(-) diff --git a/internal/codegen/common/constants_filter.go b/internal/codegen/common/constants_filter.go index 1e217f7c..907a91f7 100644 --- a/internal/codegen/common/constants_filter.go +++ b/internal/codegen/common/constants_filter.go @@ -7,6 +7,8 @@ func IsIntegerConst(value interface{}, goType string) bool { 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) { diff --git a/internal/codegen/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go index 20b5965a..0c68ee81 100644 --- a/internal/codegen/generator/cpp/device_header.go +++ b/internal/codegen/generator/cpp/device_header.go @@ -173,14 +173,55 @@ struct Input { 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 eq .Type "u8"}} +{{- 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"}} @@ -270,6 +311,18 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md } } } + 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)) diff --git a/internal/codegen/generator/csharp/constants.go b/internal/codegen/generator/csharp/constants.go index 4c59fc8b..4d03134d 100644 --- a/internal/codegen/generator/csharp/constants.go +++ b/internal/codegen/generator/csharp/constants.go @@ -56,10 +56,12 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, Device string OutputSize int EnumGroups []enumGroup + Scalars []scalarConstant Maps []mapData }{ Device: pascalDevice, OutputSize: outputSize, + Scalars: extractScalarConstants(deviceConsts.Constants), Maps: maps, } @@ -75,7 +77,14 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, } defer f.Close() //nolint:errcheck - tmpl := template.Must(template.New("constants").Parse(constantsTemplate)) + 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) } @@ -97,6 +106,12 @@ type constantInfo struct { Type string } +type scalarConstant struct { + Name string + Value string + Type string +} + type mapData struct { Name string KeyType string @@ -151,6 +166,24 @@ func groupConstantsByPrefix(constants []scanner.ConstantInfo) []enumGroup { 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 } @@ -268,12 +301,21 @@ func formatConstValue(value interface{}, goType string) string { 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" { @@ -319,6 +361,10 @@ func mapGoConstTypeToCSharp(goType string) string { return "uint" case "uint64": return "ulong" + case "float32": + return "float" + case "float64": + return "double" case "string": return "string" case "char": @@ -491,14 +537,18 @@ using System.Collections.Generic; namespace Viiper.Client.Devices.{{.Device}}; -{{if gt .OutputSize 0}} +{{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}} @@ -535,7 +585,7 @@ public static class {{.Name}} /// /// Get the value for the given key, or return the default value if not found. /// - public static {{.ValueType}} GetValueOrDefault({{.KeyType}} key, {{.ValueType}} defaultValue = default) + public static {{nullableType .ValueType}} GetValueOrDefault({{.KeyType}} key, {{nullableType .ValueType}} defaultValue = default) { return _map.TryGetValue(key, out var value) ? value : defaultValue; } diff --git a/internal/codegen/generator/rust/constants.go b/internal/codegen/generator/rust/constants.go index 6979f81c..b769df11 100644 --- a/internal/codegen/generator/rust/constants.go +++ b/internal/codegen/generator/rust/constants.go @@ -91,7 +91,7 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, if !common.IsIntegerConst(c.Value, c.Type) { continue } - rustType := goTypeToRust(c.Type) + rustType := constGoTypeToRust(c.Type) value := formatConstValue(c.Value, c.Type) constants = append(constants, rustConstant{ Name: c.Name, @@ -165,6 +165,14 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, 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) diff --git a/internal/codegen/generator/typescript/constants.go b/internal/codegen/generator/typescript/constants.go index 41f9112e..fab6e1ef 100644 --- a/internal/codegen/generator/typescript/constants.go +++ b/internal/codegen/generator/typescript/constants.go @@ -25,6 +25,12 @@ type tsConstInfo struct { Value string } +type tsScalarConst struct { + Name string + Type string + Value string +} + type tsMapData struct { Name string KeyType string @@ -50,10 +56,11 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, enums := groupConstants(deviceConsts.Constants) maps := convertMaps(deviceConsts.Maps) data := struct { - Device string - Enums []tsEnumGroup - Maps []tsMapData - }{Device: pascalDevice, Enums: enums, Maps: maps} + 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) @@ -72,6 +79,9 @@ func generateConstants(logger *slog.Logger, deviceDir string, deviceName string, 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 @@ -94,13 +104,35 @@ func groupConstants(constants []scanner.ConstantInfo) []tsEnumGroup { 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) @@ -218,6 +250,10 @@ export enum {{.Name}} { {{end}}} {{end}} +{{range .Scalars}} +export const {{.Name}}: {{.Type}} = {{.Value}}; +{{end}} + {{range .Maps}} export const {{.Name}}: Record = { {{range .Entries}} [{{.Key}}]: {{.Value}}, diff --git a/internal/codegen/scanner/constants.go b/internal/codegen/scanner/constants.go index cf5de100..5150bed4 100644 --- a/internal/codegen/scanner/constants.go +++ b/internal/codegen/scanner/constants.go @@ -5,6 +5,7 @@ import ( "go/ast" "go/parser" "go/token" + "math" "os" "path/filepath" "strconv" @@ -13,17 +14,17 @@ import ( // 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" + 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]interface{} `json:"entries"` // Key as string, value as interface{} + 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 @@ -41,6 +42,7 @@ func ScanDeviceConstants(devicePkgPath string) (*DeviceConstants, error) { Constants: []ConstantInfo{}, Maps: []MapInfo{}, } + constEnv := make(map[string]ConstantInfo) entries, err := os.ReadDir(devicePkgPath) if err != nil { @@ -63,7 +65,7 @@ func ScanDeviceConstants(devicePkgPath string) (*DeviceConstants, error) { if genDecl, ok := decl.(*ast.GenDecl); ok { switch genDecl.Tok { case token.CONST: - result.Constants = append(result.Constants, extractConstants(genDecl)...) + result.Constants = append(result.Constants, extractConstants(genDecl, constEnv)...) case token.VAR: maps := extractMaps(genDecl) result.Maps = append(result.Maps, maps...) @@ -88,8 +90,11 @@ func parseGoSource(fset *token.FileSet, filename string, src []byte) (*ast.File, return parser.ParseFile(fset, filename, src, parser.ParseComments) } -func extractConstants(genDecl *ast.GenDecl) []ConstantInfo { +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) @@ -97,35 +102,239 @@ func extractConstants(genDecl *ast.GenDecl) []ConstantInfo { continue } - var typeName string + currentType := previousType + if len(valueSpec.Values) > 0 { + currentType = "" + } if valueSpec.Type != nil { - typeName = exprToString(valueSpec.Type) + 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 !name.IsExported() { + if len(exprs) == 0 { continue } + exprIndex := i + if exprIndex >= len(exprs) { + exprIndex = len(exprs) - 1 + } constInfo := ConstantInfo{ Name: name.Name, - Type: typeName, + Type: currentType, } - - if i < len(valueSpec.Values) { - constInfo.Value = extractValue(valueSpec.Values[i]) - if typeName == "" { - constInfo.Type = inferType(constInfo.Value) - } + constInfo.Value = evaluator.eval(exprs[exprIndex]) + if currentType == "" { + constInfo.Type = inferType(constInfo.Value) } + env[name.Name] = constInfo - constants = append(constants, 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 @@ -200,85 +409,7 @@ func extractMapEntries(compositeLit *ast.CompositeLit) map[string]interface{} { } 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: - if unquoted, err := strconv.Unquote(e.Value); err == nil { - return unquoted - } - return strings.Trim(e.Value, "'") - } - case *ast.Ident: - return e.Name - case *ast.BinaryExpr: - lx := extractValue(e.X) - ry := extractValue(e.Y) - - toU64 := func(v interface{}) (uint64, bool) { - switch n := v.(type) { - case uint64: - return n, true - case int64: - if n < 0 { - return 0, false - } - return uint64(n), true - case int: - if n < 0 { - return 0, false - } - return uint64(n), true - default: - return 0, false - } - } - - lu, lok := toU64(lx) - ru, rok := toU64(ry) - if lok && rok { - switch e.Op { - case token.SHL: - if ru < 64 { - return lu << ru - } - case token.SHR: - if ru < 64 { - return lu >> ru - } - case token.OR: - return lu | ru - case token.AND: - return lu & ru - case token.XOR: - return lu ^ ru - case token.ADD: - return lu + ru - case token.SUB: - if lu >= ru { - return lu - ru - } - } - } - - return fmt.Sprintf("%v %s %v", lx, e.Op.String(), ry) - case *ast.UnaryExpr: - return fmt.Sprintf("%s%v", e.Op.String(), extractValue(e.X)) - } - return nil + return constEvaluator{}.eval(expr) } func exprToString(expr ast.Expr) string { diff --git a/internal/codegen/scanner/constants_test.go b/internal/codegen/scanner/constants_test.go index d3ae865d..e0d72d2a 100644 --- a/internal/codegen/scanner/constants_test.go +++ b/internal/codegen/scanner/constants_test.go @@ -54,3 +54,33 @@ func TestScanXbox360Constants(t *testing.T) { 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) + } +} From eacbd6cc6f45871c8c962514b6b59d6b7b255476 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 14:26:14 +0200 Subject: [PATCH 186/298] Include meta in genned client libs --- .../codegen/generator/cpp/device_header.go | 77 ++++++++- internal/codegen/generator/cpp/helpers.go | 11 +- .../generator/csharp/device_specific.go | 145 ++++++++++++++++ internal/codegen/generator/csharp/gen.go | 4 + internal/codegen/generator/csharp/helpers.go | 15 +- internal/codegen/generator/csharp/types.go | 3 +- internal/codegen/generator/generator.go | 12 +- .../codegen/generator/rust/device_specific.go | 142 ++++++++++++++++ internal/codegen/generator/rust/gen.go | 9 + internal/codegen/generator/rust/helpers.go | 15 +- internal/codegen/generator/rust/types.go | 3 +- .../generator/typescript/device_specific.go | 160 ++++++++++++++++++ internal/codegen/generator/typescript/gen.go | 3 + .../codegen/generator/typescript/helpers.go | 15 +- .../codegen/generator/typescript/index.go | 10 ++ .../codegen/generator/typescript/types.go | 3 +- internal/codegen/meta/meta.go | 1 + internal/codegen/scanner/device_structs.go | 120 +++++++++++++ 18 files changed, 715 insertions(+), 33 deletions(-) create mode 100644 internal/codegen/generator/csharp/device_specific.go create mode 100644 internal/codegen/generator/rust/device_specific.go create mode 100644 internal/codegen/generator/typescript/device_specific.go create mode 100644 internal/codegen/scanner/device_structs.go diff --git a/internal/codegen/generator/cpp/device_header.go b/internal/codegen/generator/cpp/device_header.go index 0c68ee81..c37a57c3 100644 --- a/internal/codegen/generator/cpp/device_header.go +++ b/internal/codegen/generator/cpp/device_header.go @@ -18,15 +18,18 @@ const deviceHeaderTemplate = `{{.Header}} #pragma once #include "../error.hpp" +#include "../detail/json.hpp" #include #include {{- if or .HasMaps .HasFixedWireArrays}} #include {{- end}} -{{- if .HasMaps}} +{{- if or .HasMaps .HasDeviceSpecific}} #include #include #include +{{- end}} +{{- if .HasMaps}} #include #include {{- end}} @@ -43,6 +46,74 @@ constexpr std::size_t OUTPUT_SIZE = {{.OutputSize}}; {{- 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"}} @@ -337,20 +408,24 @@ func generateDeviceHeader(logger *slog.Logger, devicesDir, deviceName string, md 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, } diff --git a/internal/codegen/generator/cpp/helpers.go b/internal/codegen/generator/cpp/helpers.go index 99565cb2..52276977 100644 --- a/internal/codegen/generator/cpp/helpers.go +++ b/internal/codegen/generator/cpp/helpers.go @@ -93,6 +93,15 @@ func fieldCppType(field scanner.FieldInfo) string { 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" } @@ -204,7 +213,7 @@ func isCustomType(goType string) bool { case "uint8", "byte", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "int", "float32", "float64", "bool", "string", - "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64": + "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "time.Time": return false default: return true diff --git a/internal/codegen/generator/csharp/device_specific.go b/internal/codegen/generator/csharp/device_specific.go new file mode 100644 index 00000000..8317a667 --- /dev/null +++ b/internal/codegen/generator/csharp/device_specific.go @@ -0,0 +1,145 @@ +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" +) + +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 toPascalCase(typeStr) + } + + return goTypeToCSharp(typeStr) +} diff --git a/internal/codegen/generator/csharp/gen.go b/internal/codegen/generator/csharp/gen.go index b09959a5..32056c8b 100644 --- a/internal/codegen/generator/csharp/gen.go +++ b/internal/codegen/generator/csharp/gen.go @@ -58,6 +58,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { 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 { diff --git a/internal/codegen/generator/csharp/helpers.go b/internal/codegen/generator/csharp/helpers.go index 653a5b5f..0353ba68 100644 --- a/internal/codegen/generator/csharp/helpers.go +++ b/internal/codegen/generator/csharp/helpers.go @@ -52,20 +52,19 @@ func goTypeToCSharp(goType string) string { } } -func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { +func parseGoMapType(typeStr string) (string, bool) { if !strings.HasPrefix(typeStr, "map[") { - return "", "", false + return "", false } closeIdx := strings.Index(typeStr, "]") if closeIdx < 0 { - return "", "", false + return "", false } - keyType = typeStr[len("map["):closeIdx] - valueType = typeStr[closeIdx+1:] - if keyType == "" || valueType == "" { - return "", "", false + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false } - return keyType, valueType, true + return valueType, true } func writeFileHeader() string { return common.FileHeader("//", "C#") } diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go index 6344feb5..c2719944 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -74,11 +74,10 @@ func fieldTypeToCSharp(field interface{}) string { typeKind := v.FieldByName("TypeKind").String() if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { - keyType, valueType, ok := parseGoMapType(typeStr) + valueType, ok := parseGoMapType(typeStr) if !ok { return "Dictionary" } - _ = keyType csVal := goTypeToCSharp(valueType) if valueType == "any" || valueType == "interface{}" { csVal = "object?" diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index e0e90b66..dc85f9f1 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -86,6 +86,7 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { md := &meta.Metadata{ DevicePackages: make(map[string]*scanner.DeviceConstants), + DeviceStructs: make(map[string][]scanner.DTOSchema), CTypeNames: make(map[string]string), } @@ -137,10 +138,19 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { } 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)) + "maps", len(deviceConsts.Maps), + "json_structs", len(md.DeviceStructs[deviceName])) } g.logger.Debug("Scanning viiper:wire tags") 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/gen.go b/internal/codegen/generator/rust/gen.go index caa69d6e..5dded7f6 100644 --- a/internal/codegen/generator/rust/gen.go +++ b/internal/codegen/generator/rust/gen.go @@ -72,6 +72,10 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { return err } + if err := generateDeviceSpecific(logger, deviceDir, deviceName, md); err != nil { + return err + } + if err := generateDeviceModFile(logger, deviceDir, deviceName, md); err != nil { return err } @@ -157,6 +161,7 @@ func generateDeviceModFile(logger *slog.Logger, deviceDir string, deviceName str 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" @@ -170,6 +175,10 @@ func generateDeviceModFile(logger *slog.Logger, deviceDir string, deviceName str 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) diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go index bbb739c1..4aa4d89d 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -54,20 +54,19 @@ func goTypeToRust(goType string) string { return rustType } -func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { +func parseGoMapType(typeStr string) (string, bool) { if !strings.HasPrefix(typeStr, "map[") { - return "", "", false + return "", false } closeIdx := strings.Index(typeStr, "]") if closeIdx < 0 { - return "", "", false + return "", false } - keyType = typeStr[len("map["):closeIdx] - valueType = typeStr[closeIdx+1:] - if keyType == "" || valueType == "" { - return "", "", false + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false } - return keyType, valueType, true + return valueType, true } func wireTypeToRust(wireType string) string { diff --git a/internal/codegen/generator/rust/types.go b/internal/codegen/generator/rust/types.go index 1db45330..28cb3441 100644 --- a/internal/codegen/generator/rust/types.go +++ b/internal/codegen/generator/rust/types.go @@ -110,9 +110,8 @@ func fieldTypeToRust(field interface{}) string { var rustType string if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { - keyType, valueType, ok := parseGoMapType(typeStr) + valueType, ok := parseGoMapType(typeStr) if ok { - _ = keyType rustType = fmt.Sprintf("std::collections::HashMap", goTypeToRust(valueType)) } else { rustType = "std::collections::HashMap" diff --git a/internal/codegen/generator/typescript/device_specific.go b/internal/codegen/generator/typescript/device_specific.go new file mode 100644 index 00000000..243a7f62 --- /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.ToPascalCase(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/gen.go b/internal/codegen/generator/typescript/gen.go index c1f63e82..91db7f44 100644 --- a/internal/codegen/generator/typescript/gen.go +++ b/internal/codegen/generator/typescript/gen.go @@ -61,6 +61,9 @@ func Generate(logger *slog.Logger, outputDir string, md *meta.Metadata) error { 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 } diff --git a/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go index bde95a30..0ce9ccf6 100644 --- a/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -22,20 +22,19 @@ func goTypeToTS(goType string) string { } } -func parseGoMapType(typeStr string) (keyType string, valueType string, ok bool) { +func parseGoMapType(typeStr string) (string, bool) { if !strings.HasPrefix(typeStr, "map[") { - return "", "", false + return "", false } closeIdx := strings.Index(typeStr, "]") if closeIdx < 0 { - return "", "", false + return "", false } - keyType = typeStr[len("map["):closeIdx] - valueType = typeStr[closeIdx+1:] - if keyType == "" || valueType == "" { - return "", "", false + valueType := typeStr[closeIdx+1:] + if valueType == "" { + return "", false } - return keyType, valueType, true + 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 index d2400870..1a4990bb 100644 --- a/internal/codegen/generator/typescript/index.go +++ b/internal/codegen/generator/typescript/index.go @@ -23,6 +23,8 @@ 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 { @@ -52,6 +54,12 @@ func generateDeviceIndex(logger *slog.Logger, deviceDir, deviceName string) erro 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) @@ -65,9 +73,11 @@ func generateDeviceIndex(logger *slog.Logger, deviceDir, deviceName string) erro data := struct { PascalName string HasOutput bool + HasMeta bool }{ PascalName: pascalName, HasOutput: hasOutput, + HasMeta: hasMeta, } if err := tmpl.Execute(f, data); err != nil { diff --git a/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go index 9f443a10..651eb92a 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -58,9 +58,8 @@ func fieldTypeToTS(field interface{}) string { typeKind := v.FieldByName("TypeKind").String() if typeKind == "map" || strings.HasPrefix(typeStr, "map[") { - k, val, ok := parseGoMapType(typeStr) + val, ok := parseGoMapType(typeStr) if ok { - _ = k return "Record" } return "Record" diff --git a/internal/codegen/meta/meta.go b/internal/codegen/meta/meta.go index 8e11ac94..eff5dffa 100644 --- a/internal/codegen/meta/meta.go +++ b/internal/codegen/meta/meta.go @@ -8,6 +8,7 @@ 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/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 +} From e01dc153e6aa7f6cf1c8f4468f39d98d9013048b Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 14:32:23 +0200 Subject: [PATCH 187/298] Exclude codegen from release builds --- internal/cmd/codegen.go | 2 ++ internal/config/codegen_command.go | 9 +++++++++ internal/config/codegen_command_release.go | 5 +++++ internal/config/config.go | 2 +- justfile | 4 ++-- 5 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 internal/config/codegen_command.go create mode 100644 internal/config/codegen_command_release.go diff --git a/internal/cmd/codegen.go b/internal/cmd/codegen.go index cb568ab4..4d816335 100644 --- a/internal/cmd/codegen.go +++ b/internal/cmd/codegen.go @@ -1,3 +1,5 @@ +//go:build !release + package cmd import ( 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 index d93a714a..1a4215cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,12 +25,12 @@ type CLI struct { 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"` - Codegen cmd.Codegen `cmd:"" help:"Generate client libraries from server code"` 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/justfile b/justfile index c635f0c0..de2c4fa3 100644 --- a/justfile +++ b/justfile @@ -75,14 +75,14 @@ clean-versioninfo: [windows] build type=build_type: generate-versioninfo {{ mkdir_p }} {{ dist_dir }} - $env:CGO_ENABLED='0'; go build -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + $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 -trimpath -ldflags "{{ if type == "Release" { ldflags_release } else { ldflags_common } }}" -o {{ build_path }} {{ main_pkg }} + 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")] From 77916d858099fd8ce32b6663afdd3718a4c0ce81 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:03:46 +0200 Subject: [PATCH 188/298] NS2Pro: optional metaState fields --- device/ns2pro/device.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index ce641bc2..4d51aa0a 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -34,9 +34,25 @@ type NS2Pro struct { func New(o *device.CreateOptions) (*NS2Pro, error) { metaState := defaultMetaState() if o != nil && o.DeviceSpecific != "" { - if err := json.Unmarshal([]byte(o.DeviceSpecific), metaState); err != nil { + 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 + } } d := &NS2Pro{ From 7f7ffd0f50b8b2c76769e3149f3635a8726eec3f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:04:29 +0200 Subject: [PATCH 189/298] libVIIPER: add newly introduced metaState to DS4 device create --- lib/viiper/dualshock4.go | 25 +++++++++++++++++++++++++ lib/viiper/viiper.go | 7 +++++++ 2 files changed, 32 insertions(+) diff --git a/lib/viiper/dualshock4.go b/lib/viiper/dualshock4.go index bdcbd16a..c5122a30 100644 --- a/lib/viiper/dualshock4.go +++ b/lib/viiper/dualshock4.go @@ -56,6 +56,14 @@ typedef struct { 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) { @@ -66,6 +74,7 @@ static void viiper_call_ds4_output(DS4OutputCallback fn, DS4DeviceHandle handle, import "C" import ( "context" + "encoding/json" "fmt" "log/slog" "runtime/cgo" @@ -83,6 +92,7 @@ import ( // @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( @@ -92,6 +102,7 @@ func CreateDS4Device( autoAttachLocalhost bool, idVendor uint16, idProduct uint16, + meta *C.DS4MetaState, ) bool { sh := cgo.Handle(serverHandle) shw, ok := sh.Value().(*usbServerHandleWrapper) @@ -110,6 +121,20 @@ func CreateDS4Device( 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 { diff --git a/lib/viiper/viiper.go b/lib/viiper/viiper.go index 218f07f7..f21b57ae 100644 --- a/lib/viiper/viiper.go +++ b/lib/viiper/viiper.go @@ -15,6 +15,13 @@ import ( func main() {} +func goStringOrEmpty(p *C.char) string { + if p == nil { + return "" + } + return C.GoString(p) +} + type deviceHandle cgo.Handle type usbServerHandleWrapper struct { From 583a36f396df5cd76cd5813c123e6c5d8ce9a548 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:04:43 +0200 Subject: [PATCH 190/298] libVIIPER: NS2Pro support --- lib/viiper/ns2pro.go | 274 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 lib/viiper/ns2pro.go 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 +} From c7c00ca7fb3d2f9fcae0218e19e922d4f0946558 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:31:27 +0200 Subject: [PATCH 191/298] Add libVIIPER NS2Pro example --- .../libVIIPER/C/ns2pro_cli/CMakeLists.txt | 41 ++ examples/libVIIPER/C/ns2pro_cli/main.c | 369 ++++++++++++++++++ .../libVIIPER/C/{ => xbox360}/CMakeLists.txt | 2 +- examples/libVIIPER/C/{ => xbox360}/main.c | 0 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 examples/libVIIPER/C/ns2pro_cli/CMakeLists.txt create mode 100644 examples/libVIIPER/C/ns2pro_cli/main.c rename examples/libVIIPER/C/{ => xbox360}/CMakeLists.txt (93%) rename examples/libVIIPER/C/{ => xbox360}/main.c (100%) 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/CMakeLists.txt b/examples/libVIIPER/C/xbox360/CMakeLists.txt similarity index 93% rename from examples/libVIIPER/C/CMakeLists.txt rename to examples/libVIIPER/C/xbox360/CMakeLists.txt index 51136f96..52e6fded 100644 --- a/examples/libVIIPER/C/CMakeLists.txt +++ b/examples/libVIIPER/C/xbox360/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(libVIIPER_example C) -set(LIB_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../dist/libVIIPER") +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}") diff --git a/examples/libVIIPER/C/main.c b/examples/libVIIPER/C/xbox360/main.c similarity index 100% rename from examples/libVIIPER/C/main.c rename to examples/libVIIPER/C/xbox360/main.c From 1ca2ea019d1b8cb517206eb4314d11cae3f2125f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:31:44 +0200 Subject: [PATCH 192/298] NS2Pro: Fix serial override --- device/ns2pro/commands.go | 2 +- device/ns2pro/flash.go | 8 ++++++-- device/ns2pro/ns2pro_test.go | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/device/ns2pro/commands.go b/device/ns2pro/commands.go index 5b8a33a3..0353b8c3 100644 --- a/device/ns2pro/commands.go +++ b/device/ns2pro/commands.go @@ -45,7 +45,7 @@ func (d *NS2Pro) handleFlashCommand(seq, sub uint8, out []byte) { copy(resp[0:8], commandHeader(0x02, seq, sub)) resp[8] = 0x40 binary.LittleEndian.PutUint32(resp[12:16], address) - copy(resp[16:], minimalFlashBlock(address)) + copy(resp[16:], d.minimalFlashBlock(address)) d.enqueueResponse(resp) } diff --git a/device/ns2pro/flash.go b/device/ns2pro/flash.go index 8712c2c1..b992b49f 100644 --- a/device/ns2pro/flash.go +++ b/device/ns2pro/flash.go @@ -1,10 +1,14 @@ package ns2pro -func minimalFlashBlock(address uint32) []byte { +func (d *NS2Pro) minimalFlashBlock(address uint32) []byte { block := make([]byte, 0x40) switch address { case 0x13000: - copy(block[2:], []byte(DefaultSerial)) + 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: diff --git a/device/ns2pro/ns2pro_test.go b/device/ns2pro/ns2pro_test.go index e78f7f29..e4d9bc70 100644 --- a/device/ns2pro/ns2pro_test.go +++ b/device/ns2pro/ns2pro_test.go @@ -214,6 +214,26 @@ func TestBulkCommands(t *testing.T) { 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(2, usbip.DirOut, flashReadCommand(0x13000)) + resp := dev.HandleTransfer(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) From aedbbe78eb3d161da6b28fdf999ef2c6a10ea3d1 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 15:37:50 +0200 Subject: [PATCH 193/298] Fix generate changelog script --- .github/scripts/generate-changelog.sh | 161 ++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 24 deletions(-) diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh index 1db04442..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,40 +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 from subject or body (case-insensitive, robust) - changelog_type="" - # Check subject first - if echo "$commit_msg" | grep -iqE 'changelog[(: ]'; then - if echo "$commit_msg" | grep -iqE 'changelog\((feature|feat)\)'; then - changelog_type="feature" - elif echo "$commit_msg" | grep -iqE 'changelog\((fix)\)'; then - changelog_type="fix" - elif echo "$commit_msg" | grep -iqE 'changelog\((misc)\)'; then - changelog_type="misc" - fi - fi - # If not found in subject, check body - if [ -z "$changelog_type" ]; then - if echo "$commit_body" | grep -iqE 'changelog[(: ]'; then - if echo "$commit_body" | grep -iqE 'changelog\((feature|feat)\)'; then - changelog_type="feature" - elif echo "$commit_body" | grep -iqE 'changelog\((fix)\)'; then - changelog_type="fix" - elif echo "$commit_body" | grep -iqE 'changelog\((misc)\)'; then - changelog_type="misc" - fi - fi - fi + 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[(: ]/ && !/^[[: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 From 4e5cd9687029efcf9a5825dbf035f04eb80b1330 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Sun, 31 May 2026 19:03:22 +0200 Subject: [PATCH 194/298] Install-Script: Always restart VIIPER server --- scripts/install.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ffe5fece..62efa3c2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -191,6 +191,9 @@ try { 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 "" From e694ab061a7d5e673b809a94ec756d3a04990826 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 18:21:00 +0200 Subject: [PATCH 195/298] lower ds4 descriptor interval --- device/dualshock4/descriptor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/device/dualshock4/descriptor.go b/device/dualshock4/descriptor.go index 90b17461..be2f3b36 100644 --- a/device/dualshock4/descriptor.go +++ b/device/dualshock4/descriptor.go @@ -355,13 +355,13 @@ var defaultDescriptor = usb.Descriptor{ BEndpointAddress: EndpointIn, BMAttributes: 0x03, WMaxPacketSize: 64, - BInterval: 5, + BInterval: 4, }, { BEndpointAddress: EndpointOut, BMAttributes: 0x03, WMaxPacketSize: 64, - BInterval: 5, + BInterval: 4, }, }, }, From 31a240ccc19be2170d4f8c775d65312081cb5c27 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 18:21:00 +0200 Subject: [PATCH 196/298] Experimental DualSense (and Edge) Emulation Changelog(feat) --- device/dualsense/const.go | 188 +++++ device/dualsense/descriptor.go | 171 +++++ device/dualsense/device.go | 446 ++++++++++++ device/dualsense/ds_handler.go | 155 +++++ device/dualsense/dsedge_handler.go | 155 +++++ device/dualsense/helpers.go | 31 + device/dualsense/serial_dedupe.go | 6 + device/dualsense/state.go | 182 +++++ examples/go/virtual_ds_and_edge_cli/main.go | 720 ++++++++++++++++++++ internal/registry/devices.go | 1 + lib/viiper/dualsense.go | 345 ++++++++++ 11 files changed, 2400 insertions(+) create mode 100644 device/dualsense/const.go create mode 100644 device/dualsense/descriptor.go create mode 100644 device/dualsense/device.go create mode 100644 device/dualsense/ds_handler.go create mode 100644 device/dualsense/dsedge_handler.go create mode 100644 device/dualsense/helpers.go create mode 100644 device/dualsense/serial_dedupe.go create mode 100644 device/dualsense/state.go create mode 100644 examples/go/virtual_ds_and_edge_cli/main.go create mode 100644 lib/viiper/dualsense.go diff --git a/device/dualsense/const.go b/device/dualsense/const.go new file mode 100644 index 00000000..e4c8db8f --- /dev/null +++ b/device/dualsense/const.go @@ -0,0 +1,188 @@ +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 +) + +const ( + ReportIDInput = 0x01 + ReportIDOutput = 0x02 +) + +const ( + InputReportSize = 64 + OutputReportSize = 64 + InputStateSize = 33 + OutputStateSize = 6 +) + +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 + + ButtonEdgeRFn uint32 = 0x00080000 + ButtonEdgeLFn uint32 = 0x00100000 + ButtonEdgeR4 uint32 = 0x00200000 + ButtonEdgeL4 uint32 = 0x00400000 +) + +const ( + DPadUp = 0x01 + DPadDown = 0x02 + DPadLeft = 0x04 + DPadRight = 0x08 +) + +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..9e46721b --- /dev/null +++ b/device/dualsense/descriptor.go @@ -0,0 +1,171 @@ +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: 2, // Full speed + }, + Interfaces: []usb.InterfaceConfig{ + { + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x00, + BAlternateSetting: 0x00, + BNumEndpoints: 0x02, + BInterfaceClass: 0x03, // HID + 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.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: 63}, + 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: 52}, 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: 2, + }, + { + BEndpointAddress: EndpointOut, + BMAttributes: 0x03, // Interrupt + WMaxPacketSize: 64, + BInterval: 2, + }, + }, + }, + }, + Strings: map[uint8]string{ + 0: "\u0409", // LangID: en-US (0x0409) + 1: "Sony Interactive Entertainment", + 2: "DualSense Wireless Controller", + }, +} diff --git a/device/dualsense/device.go b/device/dualsense/device.go new file mode 100644 index 00000000..5dd1d2ca --- /dev/null +++ b/device/dualsense/device.go @@ -0,0 +1,446 @@ +package dualsense + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "log/slog" + "math" + "net" + "sync" + "sync/atomic" + + "github.com/Alia5/VIIPER/device" + "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +type DualSense struct { + inputState *InputState + metaState *MetaState + + outputFunc func(OutputState) + descriptor usb.Descriptor + + subcommand [2]byte + + seqCounter uint8 + usbReportTimestamp uint32 + + 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: defaultDescriptor, + metaState: metaState, + } + d.descriptor.Device.IDProduct = DefaultPIDDS + if edge { + d.descriptor.Device.IDProduct = DefaultPIDDSEdge + d.descriptor.Strings[2] = "DualSense Edge Wireless Controller" + } + + 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 = &InputState{ + AccelX: DefaultAccelXRaw, + AccelY: DefaultAccelYRaw, + AccelZ: DefaultAccelZRaw, + } + + 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.outputFunc = f +} + +func (d *DualSense) UpdateInputState(state *InputState) { + d.mtx.Lock() + defer d.mtx.Unlock() + d.inputState = state +} + +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{} + } + return res +} + +func (d *DualSense) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { + if dir == usbip.DirIn { + switch ep { + case 4: + d.mtx.Lock() + is := *d.inputState + ms := *d.metaState + d.mtx.Unlock() + return d.buildUSBInputReport(&is, &ms) + default: + return nil + } + } + + if dir == usbip.DirOut && ep == 3 { + if len(out) >= 48 && out[0] == ReportIDOutput { + if d.outputFunc != nil { + d.outputFunc(parseOutputReport(out)) + } + } + } + + return nil +} + +func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { + 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 hidClassOUT: + if bRequest == hidSetReport { + 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 && len(data) >= 48: + if d.outputFunc != nil { + d.outputFunc(parseOutputReport(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 +} + +var featureGetHandlers = map[byte]func(*DualSense) []byte{ + featureIDCalibration: (*DualSense).featureReportCalibration, + featureIDPairing: (*DualSense).featureReportPairing, + featureIDFirmware: (*DualSense).featureReportFirmware, + featureIDCommandResponse: (*DualSense).featureReportCommandResponse, +} + +func parseOutputReport(out []byte) OutputState { + feedback := OutputState{ + RumbleSmall: out[3], + 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] + } + } + 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) + + 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 := atomic.AddUint32(&d.usbReportTimestamp, 1) + binary.LittleEndian.PutUint32(b[28:32], ts) + + touch1 := uint8(0) + if !s.Touch1Active { + touch1 |= TouchInactiveMask + } + b[33] = touch1 + encodeTouchCoords(b[34:37], s.Touch1X, s.Touch1Y) + + touch2 := uint8(0) + if !s.Touch2Active { + touch2 |= TouchInactiveMask + } + b[37] = touch2 + encodeTouchCoords(b[38:41], s.Touch2X, s.Touch2Y) + + b[49] = 0x10 + b[53] = m.BatteryStatus + + return b +} diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go new file mode 100644 index 00000000..3a02c100 --- /dev/null +++ b/device/dualsense/ds_handler.go @@ -0,0 +1,155 @@ +package dualsense + +import ( + "encoding/json" + "fmt" + "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{}) +} + +type dshandler struct{} + +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) + + return new(o, false) +} + +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) + } + + dse.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) + } + }) + + 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) + } + } +} + +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/dsedge_handler.go b/device/dualsense/dsedge_handler.go new file mode 100644 index 00000000..aa455220 --- /dev/null +++ b/device/dualsense/dsedge_handler.go @@ -0,0 +1,155 @@ +package dualsense + +import ( + "encoding/json" + "fmt" + "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("dualsenseedge", &dsedgehandler{}) +} + +type dsedgehandler struct{} + +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) + + return new(o, true) +} + +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) + } + + dse.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) + } + }) + + 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) + } + } +} + +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/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..33245439 --- /dev/null +++ b/device/dualsense/state.go @@ -0,0 +1,182 @@ +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 + Touch2X, Touch2Y uint16 + Touch2Active bool + + GyroX, GyroY, GyroZ int16 + AccelX, AccelY, AccelZ int16 +} + +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) + if s.Touch1Active { + b[15] = 1 + } + binary.LittleEndian.PutUint16(b[16:18], s.Touch2X) + binary.LittleEndian.PutUint16(b[18:20], s.Touch2Y) + if s.Touch2Active { + 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 = data[15] != 0 + s.Touch2X = binary.LittleEndian.Uint16(data[16:18]) + s.Touch2Y = binary.LittleEndian.Uint16(data[18:20]) + s.Touch2Active = data[20] != 0 + 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 +type OutputState struct { + RumbleSmall uint8 + RumbleLarge uint8 + LedRed uint8 + LedGreen uint8 + LedBlue uint8 + PlayerLeds uint8 +} + +func (f *OutputState) MarshalBinary() ([]byte, error) { + return []byte{ + f.RumbleSmall, + f.RumbleLarge, + f.LedRed, + f.LedGreen, + f.LedBlue, + f.PlayerLeds, + }, 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] + return nil +} + +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/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..46a95aa5 --- /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 [7]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/internal/registry/devices.go b/internal/registry/devices.go index 15ce89a0..141b959f 100644 --- a/internal/registry/devices.go +++ b/internal/registry/devices.go @@ -1,6 +1,7 @@ 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" diff --git a/lib/viiper/dualsense.go b/lib/viiper/dualsense.go new file mode 100644 index 00000000..6991b489 --- /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 0x00080000u +#define DS_BUTTON_LFN 0x00100000u +#define DS_BUTTON_R4 0x00200000u +#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 +} From e674634a21b9f146c0f3dd648ff348ebe07b6286 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 18:21:00 +0200 Subject: [PATCH 197/298] Update docs --- README.md | 9 +- docs/clients/go.md | 11 +- docs/devices/dualsense.md | 210 +++++++++++++++++++++++++++++++++++++ docs/index.md | 13 ++- docs/libviiper/overview.md | 1 + mkdocs.yml | 12 ++- 6 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 docs/devices/dualsense.md diff --git a/README.md b/README.md index 6d7b0247..cfd7b0d2 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ VIIPER comes in two distinct flavors: - **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. + or the [libVIIPER documentation](docs/libviiper/overview.md) for details and examples. For why you should pick one over the other see the [FAQ](#why-choose-the-standalone-executable-and-interfacing-via-tcp-over-the-shared-object-libviiper-library) @@ -59,8 +59,8 @@ Beyond device emulation, VIIPER can proxy real USB devices for traffic inspectio - 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) - PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) +- PS5 DualSense controller emulation (including Edge variant); see [Devices › DualSense Controller](docs/devices/dualsense.md) - Nintendo Switch 2 Pro Controller emulation; see [Devices › Switch 2 Pro Controller](docs/devices/ns2pro.md) -- 🔜 Future plugin system allows for more device types (other gamepads, specialized HID) ## 🔌 Requirements @@ -165,7 +165,8 @@ VIIPER uses it because it's already built into Linux and available for Windows, - 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 - - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. + - 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? @@ -190,7 +191,7 @@ Adding a new device type never requires touching kernel code. Yes! VIIPER's architecture is designed to be extensible. Check the [xbox360 device implementation](./device/xbox360/) as a reference for creating new device types. -In the future there will be a plugin system to load and expose device types dynamically. + ### You mentioned proxying USBIP? diff --git a/docs/clients/go.md b/docs/clients/go.md index 66508410..082dddd9 100644 --- a/docs/clients/go.md +++ b/docs/clients/go.md @@ -200,11 +200,12 @@ if err != nil { Full working examples are available in the repository: -- **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 Switch 2 Pro Controller**: `examples/go/virtual_ns2pro/main.go` -- More examples are always being added! +- **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 diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md new file mode 100644 index 00000000..800de82f --- /dev/null +++ b/docs/devices/dualsense.md @@ -0,0 +1,210 @@ +# 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: bool (5 bytes) + - Touch2: Touch2X, Touch2Y: uint16 each, Touch2Active: bool (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) + + - 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 + + See `/device/dualsense/state.go` for the `OutputState` wire definition. + + ## 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 | 0x00080000 | + | LFn button | 0x00100000 | + | R4 back paddle | 0x00200000 | + | 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 an explicit boolean `Touch{1,2}Active`. + + 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/index.md b/docs/index.md index 9d65cde5..9d1b462d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,6 +47,15 @@ For why you should pick one over the other see the [FAQ](#why-choose-the-the-sta 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 @@ -103,7 +112,8 @@ VIIPER uses it because it's already built into Linux and available for Windows, - 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 - - **_future versions_**: Users can enhance VIIPER with device plugins, sharing a common wire-protocol, which can be dynamically incorporated. + - 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? @@ -128,7 +138,6 @@ 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. -In the future there will be a plugin system to load and expose device types dynamically. ### What about the proxy mode? diff --git a/docs/libviiper/overview.md b/docs/libviiper/overview.md index 827ca5df..37bacef4 100644 --- a/docs/libviiper/overview.md +++ b/docs/libviiper/overview.md @@ -102,6 +102,7 @@ Full working examples are in [`examples/libVIIPER/`](https://github.com/Alia5/VI - [Xbox 360 Controller](../devices/xbox360.md) - [DualShock 4](../devices/dualshock4.md) +- [DualSense (and Edge)](../devices/dualsense.md) - [Keyboard](../devices/keyboard.md) - [Mouse](../devices/mouse.md) diff --git a/mkdocs.yml b/mkdocs.yml index 0e9f9205..de1233ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,13 +78,15 @@ nav: - Overview: libviiper/overview.md - Xbox 360 Controller: devices/xbox360.md - DualShock 4: devices/dualshock4.md + - DualSense: devices/dualsense.md - Keyboard: devices/keyboard.md - Mouse: devices/mouse.md - Devices: - - Xbox 360 Controller: devices/xbox360.md - - DualShock 4 Controller: devices/dualshock4.md - - Switch 2 Pro Controller: devices/ns2pro.md - - Keyboard: devices/keyboard.md - - Mouse: devices/mouse.md + - 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/ From c1f5abce0f8e816eaac9235575c07fef9f72789e Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 18:21:01 +0200 Subject: [PATCH 198/298] Fix colored log output when log file is enabled --- internal/log/logging.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/log/logging.go b/internal/log/logging.go index 54cd47ae..a6c2a959 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -39,15 +39,11 @@ func SetupLogger(logLevel, logFile string) (*slog.Logger, []io.Closer, error) { level := ParseLevel(logLevel) var handlers []slog.Handler - if logFile == "" { - 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}) - } else { - handlers = append(handlers, slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) - } + 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) From 21fb4eb3b059d489e5377c076680073759a4a6a7 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 18:22:57 +0200 Subject: [PATCH 199/298] color log keys --- internal/log/logging.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/log/logging.go b/internal/log/logging.go index a6c2a959..13f69dce 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -157,9 +157,9 @@ func (h *colorHandler) Handle(_ context.Context, r slog.Record) error { buf.WriteString(r.Message) r.Attrs(func(a slog.Attr) bool { - buf.WriteString(" ") + buf.WriteString(" \033[90m") buf.WriteString(a.Key) - buf.WriteString("=") + buf.WriteString("=\033[0m") buf.WriteString(a.Value.String()) return true }) From a1abbc5f49a397a52798ddde9481a211daa0a93f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 19:20:36 +0200 Subject: [PATCH 200/298] Update docs --- docs/devices/ns2pro.md | 102 +++++++++++++++++++++++++++++++++++++ docs/libviiper/overview.md | 1 + mkdocs.yml | 1 + 3 files changed, 104 insertions(+) diff --git a/docs/devices/ns2pro.md b/docs/devices/ns2pro.md index f918bc82..c3aee466 100644 --- a/docs/devices/ns2pro.md +++ b/docs/devices/ns2pro.md @@ -61,3 +61,105 @@ gyro/accelerometer data, and HD rumble output. | 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/libviiper/overview.md b/docs/libviiper/overview.md index 37bacef4..78956c3c 100644 --- a/docs/libviiper/overview.md +++ b/docs/libviiper/overview.md @@ -103,6 +103,7 @@ Full working examples are in [`examples/libVIIPER/`](https://github.com/Alia5/VI - [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) diff --git a/mkdocs.yml b/mkdocs.yml index de1233ae..a5a108bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ nav: - 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: From 44c465b9eeabf22800d0479f79b2f9099b03bf8f Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 20:31:12 +0200 Subject: [PATCH 201/298] Fix DualSenseEdge Back button consts --- device/dualsense/const.go | 4 ++-- docs/devices/dualsense.md | 4 ++-- lib/viiper/dualsense.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index e4c8db8f..530b4579 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -64,10 +64,10 @@ const ( ButtonTouchpad uint32 = 0x00020000 ButtonMicMute uint32 = 0x00040000 - ButtonEdgeRFn uint32 = 0x00080000 ButtonEdgeLFn uint32 = 0x00100000 - ButtonEdgeR4 uint32 = 0x00200000 + ButtonEdgeRFn uint32 = 0x00200000 ButtonEdgeL4 uint32 = 0x00400000 + ButtonEdgeR4 uint32 = 0x00800000 ) const ( diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index 800de82f..ccea23f9 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -83,9 +83,9 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. | Touchpad click | 0x00020000 | | Mic mute button | 0x00040000 | | Edge Variant only | --- | - | RFn button | 0x00080000 | + | RFn button | 0x00200000 | | LFn button | 0x00100000 | - | R4 back paddle | 0x00200000 | + | R4 back paddle | 0x00800000 | | L4 back paddle | 0x00400000 | ### D-Pad Constants diff --git a/lib/viiper/dualsense.go b/lib/viiper/dualsense.go index 6991b489..05a78497 100644 --- a/lib/viiper/dualsense.go +++ b/lib/viiper/dualsense.go @@ -23,9 +23,9 @@ typedef uintptr_t DSDeviceHandle; #define DS_BUTTON_PS 0x00010000u #define DS_BUTTON_TOUCHPAD 0x00020000u #define DS_BUTTON_MIC_MUTE 0x00040000u -#define DS_BUTTON_RFN 0x00080000u +#define DS_BUTTON_RFN 0x00200000u #define DS_BUTTON_LFN 0x00100000u -#define DS_BUTTON_R4 0x00200000u +#define DS_BUTTON_R4 0x00800000u #define DS_BUTTON_L4 0x00400000u #define DS_DPAD_UP 0x01u From 6b71b148a2243fab77ee1a46f4e22e00bd7d5a04 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Mon, 1 Jun 2026 21:13:57 +0200 Subject: [PATCH 202/298] Fix dualsense example --- examples/go/virtual_ds_and_edge_cli/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/go/virtual_ds_and_edge_cli/main.go b/examples/go/virtual_ds_and_edge_cli/main.go index 46a95aa5..4d52e985 100644 --- a/examples/go/virtual_ds_and_edge_cli/main.go +++ b/examples/go/virtual_ds_and_edge_cli/main.go @@ -109,7 +109,7 @@ func main() { }() feedbackCh, errCh := stream.StartReading(ctx, 10, func(r *bufio.Reader) (encoding.BinaryUnmarshaler, error) { - var b [7]byte + var b [dualsense.OutputStateSize]byte if _, err := io.ReadFull(r, b[:]); err != nil { return nil, err } From 19bc99faf53632a29e850e2b8dd1414a6455f4aa Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Thu, 4 Jun 2026 15:06:03 +0200 Subject: [PATCH 203/298] Add (neutral) InputState constructors to devices --- device/dualsense/device.go | 6 +----- device/dualsense/state.go | 10 ++++++++++ device/dualshock4/device.go | 6 +----- device/dualshock4/state.go | 10 ++++++++++ device/keyboard/device.go | 1 + device/keyboard/inputstate.go | 3 +++ device/mouse/device.go | 1 + device/mouse/inputstate.go | 3 +++ device/ns2pro/inputstate.go | 5 ++++- device/xbox360/device.go | 1 + device/xbox360/inputstate.go | 3 +++ 11 files changed, 38 insertions(+), 11 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 5dd1d2ca..cd5acdaa 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -108,11 +108,7 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { "pid", d.descriptor.Device.IDProduct, "interfaces", len(d.descriptor.Interfaces)) - d.inputState = &InputState{ - AccelX: DefaultAccelXRaw, - AccelY: DefaultAccelYRaw, - AccelZ: DefaultAccelZRaw, - } + d.inputState = NewInputState() return d, nil } diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 33245439..c3e140bc 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -26,6 +26,16 @@ type InputState struct { 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) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 1267259e..d5d44200 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -86,11 +86,7 @@ func New(o *device.CreateOptions) (*DualShock4, error) { "pid", d.descriptor.Device.IDProduct, "interfaces", len(d.descriptor.Interfaces)) - d.inputState = &InputState{ - AccelX: DefaultAccelXRaw, - AccelY: DefaultAccelYRaw, - AccelZ: DefaultAccelZRaw, - } + d.inputState = NewInputState() return d, nil } diff --git a/device/dualshock4/state.go b/device/dualshock4/state.go index 063ee622..ab58151f 100644 --- a/device/dualshock4/state.go +++ b/device/dualshock4/state.go @@ -26,6 +26,16 @@ type InputState struct { 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) diff --git a/device/keyboard/device.go b/device/keyboard/device.go index e5720c7b..6890800c 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -34,6 +34,7 @@ func New(o *device.CreateOptions) (*Keyboard, error) { d.descriptor.Device.IDProduct = *o.IDProduct } } + d.inputState = NewInputState() return d, nil } diff --git a/device/keyboard/inputstate.go b/device/keyboard/inputstate.go index aed801c5..aa6d118e 100644 --- a/device/keyboard/inputstate.go +++ b/device/keyboard/inputstate.go @@ -12,6 +12,9 @@ type InputState struct { KeyBitmap [32]uint8 // 256 bits for HID usage codes 0x00-0xFF } +// NewInputState returns a keyboard input state in its neutral/resting state. +func NewInputState() *InputState { return &InputState{} } + // LEDState represents the state of keyboard LEDs controlled by the host. // viiper:wire keyboard s2c leds:u8 type LEDState struct { diff --git a/device/mouse/device.go b/device/mouse/device.go index 241f5b45..a20ac9c3 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -33,6 +33,7 @@ func New(o *device.CreateOptions) (*Mouse, error) { d.descriptor.Device.IDProduct = *o.IDProduct } } + d.inputState = NewInputState() return d, nil } diff --git a/device/mouse/inputstate.go b/device/mouse/inputstate.go index 10aa648b..85c0c7ee 100644 --- a/device/mouse/inputstate.go +++ b/device/mouse/inputstate.go @@ -17,6 +17,9 @@ type InputState struct { Pan int16 } +// NewInputState returns a mouse input state in its neutral/resting state. +func NewInputState() *InputState { return &InputState{} } + // BuildReport encodes an InputState into the 9-byte HID mouse report. // // Report layout (9 bytes): diff --git a/device/ns2pro/inputstate.go b/device/ns2pro/inputstate.go index 0d4da4b6..62436134 100644 --- a/device/ns2pro/inputstate.go +++ b/device/ns2pro/inputstate.go @@ -17,7 +17,8 @@ type InputState struct { GyroX, GyroY, GyroZ int16 } -func defaultInputState() *InputState { +// NewInputState returns an NS2 Pro input state in its neutral/resting state. +func NewInputState() *InputState { return &InputState{ LX: StickCenter, LY: StickCenter, @@ -26,6 +27,8 @@ func defaultInputState() *InputState { } } +func defaultInputState() *InputState { return NewInputState() } + type MetaState struct { SerialNumber string `json:"serial_number"` BatteryLevel uint8 `json:"battery_level"` diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 2ace97ce..065424a1 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -47,6 +47,7 @@ func New(o *device.CreateOptions) (*Xbox360, error) { } } } + d.inputState = NewInputState() return d, nil } diff --git a/device/xbox360/inputstate.go b/device/xbox360/inputstate.go index 1865f1af..9d78c0ee 100644 --- a/device/xbox360/inputstate.go +++ b/device/xbox360/inputstate.go @@ -19,6 +19,9 @@ type InputState struct { 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 { From 7e33d2d3e6a30041ce12b5d30b444827985fd171 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 16:20:39 +0200 Subject: [PATCH 204/298] Improve device emulation efficiency - Default to data-driven, immediate, updates - Turn off write-batching by default Changelog(misc) --- _testing/e2e/bench_test.go | 5 +- _testing/usbip_client.go | 35 ++-- device/dualsense/device.go | 35 +++- device/dualshock4/device.go | 35 +++- device/dualshock4/dualshock4_test.go | 2 +- device/keyboard/device.go | 28 +-- device/mouse/device.go | 46 ++--- device/ns2pro/device.go | 24 ++- device/ns2pro/ns2pro_test.go | 56 +++--- device/xbox360/device.go | 30 ++-- internal/server/api/device_registry_test.go | 3 +- internal/server/usb/config.go | 2 +- internal/server/usb/server.go | 189 ++++++++++++++++---- lib/viiper/server.go | 3 - usb/device.go | 7 +- 15 files changed, 345 insertions(+), 155 deletions(-) diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index 6d374092..bdc9acfa 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -164,9 +164,8 @@ func Benchmark_Xbox360_Delay(b *testing.B) { s := cmd.Server{ USBServerConfig: usb.ServerConfig{ - Addr: ":3244", - BusCleanupTimeout: 1 * time.Second, - WriteBatchFlushInterval: 0, + Addr: ":3244", + BusCleanupTimeout: 1 * time.Second, }, APIServerConfig: api.ServerConfig{ Addr: ":3245", diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go index 17a73b61..9f38e7d7 100644 --- a/_testing/usbip_client.go +++ b/_testing/usbip_client.go @@ -322,26 +322,39 @@ func (c *TestUsbIpClient) ReadInputReportWithTimeout(conn net.Conn, timeout time } 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 { + return c.ReadInputReportWithTimeout(conn, timeout) + } + deadline := time.Now().Add(timeout) var last []byte for { - got, err := c.ReadInputReport(conn) + 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) { - eq := true - for i := range want { - if want[i] != got[i] { - eq = false - break - } - } - if eq { - return got, nil + allZero := true + for _, b := range got { + if b != 0 { + allZero = false + break } } + if !allZero { + return got, nil + } if time.Now().After(deadline) { return last, nil } diff --git a/device/dualsense/device.go b/device/dualsense/device.go index cd5acdaa..70271759 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -1,8 +1,10 @@ package dualsense import ( + "context" "encoding/binary" "encoding/json" + "errors" "fmt" "log/slog" "math" @@ -16,6 +18,7 @@ import ( ) type DualSense struct { + inputCh chan *InputState inputState *InputState metaState *MetaState @@ -109,6 +112,8 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = NewInputState() + d.inputCh = make(chan *InputState, 1) + d.inputCh <- d.inputState return d, nil } @@ -125,8 +130,13 @@ func (d *DualSense) SetOutputCallback(f func(OutputState)) { func (d *DualSense) UpdateInputState(state *InputState) { d.mtx.Lock() - defer d.mtx.Unlock() d.inputState = state + d.mtx.Unlock() + select { + case <-d.inputCh: + default: + } + d.inputCh <- state } func (d *DualSense) GetDescriptor() *usb.Descriptor { @@ -149,15 +159,26 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { return res } -func (d *DualSense) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { if dir == usbip.DirIn { switch ep { case 4: - d.mtx.Lock() - is := *d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) + 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) + } default: return nil } diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index d5d44200..be2c2b26 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -1,8 +1,10 @@ package dualshock4 import ( + "context" "encoding/binary" "encoding/json" + "errors" "fmt" "log/slog" "sync" @@ -15,6 +17,7 @@ import ( ) type DualShock4 struct { + inputCh chan *InputState inputState *InputState metaState *MetaState @@ -87,6 +90,8 @@ func New(o *device.CreateOptions) (*DualShock4, error) { "interfaces", len(d.descriptor.Interfaces)) d.inputState = NewInputState() + d.inputCh = make(chan *InputState, 1) + d.inputCh <- d.inputState return d, nil } @@ -103,8 +108,13 @@ func (d *DualShock4) SetOutputCallback(f func(OutputState)) { func (d *DualShock4) UpdateInputState(state *InputState) { d.mtx.Lock() - defer d.mtx.Unlock() d.inputState = state + d.mtx.Unlock() + select { + case <-d.inputCh: + default: + } + d.inputCh <- state } func (d *DualShock4) GetDescriptor() *usb.Descriptor { @@ -127,15 +137,26 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { return res } -func (d *DualShock4) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { if dir == usbip.DirIn { switch ep { case 4: - d.mtx.Lock() - is := *d.inputState - ms := *d.metaState - d.mtx.Unlock() - return d.buildUSBInputReport(&is, &ms) + 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) + } default: return nil } diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 235d5d37..30fe2fcc 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -359,7 +359,7 @@ func TestInputReports(t *testing.T) { return } dev.UpdateInputState(&tc.inputState) - built := dev.HandleTransfer(4, usbip.DirIn, nil) + built := dev.HandleTransfer(context.Background(), 4, usbip.DirIn, nil) bb := append([]byte(nil), built...) exp := append([]byte(nil), tc.expectedReport...) bb[7] &= 0x03 diff --git a/device/keyboard/device.go b/device/keyboard/device.go index 6890800c..4b12b9a4 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -2,6 +2,7 @@ package keyboard import ( + "context" "sync" "sync/atomic" @@ -14,7 +15,7 @@ import ( // Keyboard implements the Device interface for a full HID keyboard with LED support. type Keyboard struct { tick uint64 - inputState *InputState + inputCh chan InputState stateMu sync.Mutex ledState uint8 ledCallback func(LEDState) @@ -34,7 +35,8 @@ func New(o *device.CreateOptions) (*Keyboard, error) { d.descriptor.Device.IDProduct = *o.IDProduct } } - d.inputState = NewInputState() + d.inputCh = make(chan InputState, 1) + d.inputCh <- *NewInputState() return d, nil } @@ -58,25 +60,25 @@ func (k *Keyboard) GetLEDState() LEDState { // 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 + select { + case <-k.inputCh: + default: + } + k.inputCh <- state } // HandleTransfer implements interrupt IN/OUT for Keyboard. -func (k *Keyboard) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +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) - - k.stateMu.Lock() - var st InputState - if k.inputState != nil { - st = *k.inputState + select { + case <-ctx.Done(): + return nil + case st := <-k.inputCh: + return st.BuildReport() } - k.stateMu.Unlock() - return st.BuildReport() default: return nil } diff --git a/device/mouse/device.go b/device/mouse/device.go index a20ac9c3..5890a8f6 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -2,7 +2,7 @@ package mouse import ( - "sync" + "context" "sync/atomic" "github.com/Alia5/VIIPER/device" @@ -15,8 +15,7 @@ import ( // with vertical and horizontal wheels. type Mouse struct { tick uint64 - inputState *InputState - stateMu sync.Mutex + inputCh chan InputState descriptor usb.Descriptor } @@ -33,38 +32,41 @@ func New(o *device.CreateOptions) (*Mouse, error) { d.descriptor.Device.IDProduct = *o.IDProduct } } - d.inputState = NewInputState() + d.inputCh = make(chan InputState, 1) + d.inputCh <- *NewInputState() return d, nil } // 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 + select { + case <-m.inputCh: + default: + } + m.inputCh <- state } // HandleTransfer implements interrupt IN for Mouse. -func (m *Mouse) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +func (m *Mouse) 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(&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 + select { + case <-ctx.Done(): + return nil + case st := <-m.inputCh: + // Re-queue a zero-delta state (buttons preserved) so the server's + // keepalive cache reflects no movement after delivering this event. + if st.DX != 0 || st.DY != 0 || st.Wheel != 0 || st.Pan != 0 { + zeroed := InputState{Buttons: st.Buttons} + select { + case m.inputCh <- zeroed: + default: + } + } + return st.BuildReport() } - m.stateMu.Unlock() - return st.BuildReport() default: return nil } diff --git a/device/ns2pro/device.go b/device/ns2pro/device.go index 4d51aa0a..78c65921 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -2,7 +2,9 @@ package ns2pro import ( + "context" "encoding/json" + "errors" "fmt" "sync" @@ -12,6 +14,7 @@ import ( ) type NS2Pro struct { + inputCh chan struct{} stateMu sync.Mutex inputState *InputState metaState *MetaState @@ -55,7 +58,10 @@ func New(o *device.CreateOptions) (*NS2Pro, error) { } } + inputCh := make(chan struct{}, 1) + inputCh <- struct{}{} d := &NS2Pro{ + inputCh: inputCh, inputState: defaultInputState(), metaState: metaState, descriptor: MakeDescriptor(), @@ -97,8 +103,12 @@ func (d *NS2Pro) SetOutputCallback(f func(OutputState)) func() { func (d *NS2Pro) UpdateInputState(state InputState) { d.stateMu.Lock() - defer d.stateMu.Unlock() d.inputState = &state + d.stateMu.Unlock() + select { + case d.inputCh <- struct{}{}: + default: + } } func (d *NS2Pro) SetMetaState(meta MetaState) { @@ -114,10 +124,18 @@ func (d *NS2Pro) SetMetaState(meta MetaState) { } } -func (d *NS2Pro) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { switch { case dir == usbip.DirIn && ep == 1: - return d.nextInputReport() + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return d.nextInputReport() + } + return nil + case <-d.inputCh: + return d.nextInputReport() + } case dir == usbip.DirIn && ep == 2: return d.popBulkIn() case dir == usbip.DirOut && ep == 1: diff --git a/device/ns2pro/ns2pro_test.go b/device/ns2pro/ns2pro_test.go index e4d9bc70..ba19fb98 100644 --- a/device/ns2pro/ns2pro_test.go +++ b/device/ns2pro/ns2pro_test.go @@ -48,11 +48,11 @@ func TestBuildReport05(t *testing.T) { BatteryVolts: DefaultBatteryVolts, }) - dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) - dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) - dev.HandleTransfer(2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + 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)) - report := dev.HandleTransfer(1, usbip.DirIn, nil) + 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])) @@ -75,7 +75,9 @@ func TestBuildReport05(t *testing.T) { assert.Equal(t, uint16(state.GyroY), binary.LittleEndian.Uint16(report[0x39:0x3B])) assert.Equal(t, uint16(state.GyroZ), binary.LittleEndian.Uint16(report[0x3B:0x3D])) - next := dev.HandleTransfer(1, usbip.DirIn, nil) + 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.Equal(t, uint32(8000), binary.LittleEndian.Uint32(next[0x2B:0x2F])) @@ -169,10 +171,10 @@ func TestBuildReport09(t *testing.T) { ExternalPower: true, BatteryVolts: DefaultBatteryVolts, }) - dev.HandleTransfer(2, usbip.DirOut, selectReportCommand(ReportIDPro)) - assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, selectReportCommand(ReportIDPro)) + assert.NotEmpty(t, dev.HandleTransfer(context.Background(), 2, usbip.DirIn, nil)) - report := dev.HandleTransfer(1, 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]) @@ -192,19 +194,19 @@ func TestBulkCommands(t *testing.T) { dev, err := New(nil) require.NoError(t, err) - dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x02, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) - assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) - dev.HandleTransfer(2, usbip.DirOut, featureCommand(0x04, FeatureButtons|FeatureSticks|FeatureIMU|FeatureRumble)) - assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) + 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(2, usbip.DirOut, selectReportCommand(ReportIDCommon)) - assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil)) - report := dev.HandleTransfer(1, usbip.DirIn, nil) + dev.HandleTransfer(context.Background(), 2, usbip.DirOut, selectReportCommand(ReportIDCommon)) + 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(2, usbip.DirOut, flashReadCommand(0x13000)) - resp := dev.HandleTransfer(2, usbip.DirIn, nil) + 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]) @@ -225,8 +227,8 @@ func TestFlashSerialUsesMetaStateSerial(t *testing.T) { BatteryVolts: DefaultBatteryVolts, }) - dev.HandleTransfer(2, usbip.DirOut, flashReadCommand(0x13000)) - resp := dev.HandleTransfer(2, usbip.DirIn, nil) + 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:] @@ -239,8 +241,8 @@ func TestSDLUSBInitializationSequence(t *testing.T) { require.NoError(t, err) for _, address := range []uint32{0x13000, 0x13040, 0x13080, 0x130C0, 0x13100, 0x1FC040, 0x1FC080} { - dev.HandleTransfer(2, usbip.DirOut, flashReadCommand(address)) - resp := dev.HandleTransfer(2, usbip.DirIn, nil) + 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]) @@ -260,8 +262,8 @@ func TestSDLUSBInitializationSequence(t *testing.T) { {0x03, 0x91, 0x00, 0x0D, 0x00, 0x08, 0x00, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, } for _, cmd := range initSequence { - dev.HandleTransfer(2, usbip.DirOut, cmd) - assert.NotEmpty(t, dev.HandleTransfer(2, usbip.DirIn, nil), "cmd %#x sub %#x", cmd[0], cmd[3]) + 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{ @@ -271,7 +273,7 @@ func TestSDLUSBInitializationSequence(t *testing.T) { BatteryVolts: DefaultBatteryVolts, }) dev.UpdateInputState(InputState{}) - report := dev.HandleTransfer(1, usbip.DirIn, nil) + 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])) @@ -295,7 +297,7 @@ func TestRumbleOutput(t *testing.T) { packet[1+i] = byte(i) packet[17+i] = byte(0x80 + i) } - dev.HandleTransfer(1, usbip.DirOut, packet) + dev.HandleTransfer(context.Background(), 1, usbip.DirOut, packet) require.True(t, called) assert.Equal(t, byte(OutputFlagRumble), got.Flags) @@ -329,8 +331,8 @@ func TestPlayerLEDOutput(t *testing.T) { called = true }) - dev.HandleTransfer(2, usbip.DirOut, []byte{0x09, 0x91, 0x12, 0x07, 0x00, 0x08, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00}) - resp := dev.HandleTransfer(2, usbip.DirIn, nil) + 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) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 065424a1..22728060 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -2,9 +2,9 @@ package xbox360 import ( + "context" "encoding/json" "fmt" - "sync" "sync/atomic" "github.com/Alia5/VIIPER/device" @@ -14,8 +14,7 @@ import ( type Xbox360 struct { tick uint64 - inputState *InputState - stateMu sync.Mutex + inputCh chan InputState rumbleFunc func(XRumbleState) descriptor usb.Descriptor } @@ -47,7 +46,8 @@ func New(o *device.CreateOptions) (*Xbox360, error) { } } } - d.inputState = NewInputState() + d.inputCh = make(chan InputState, 1) + d.inputCh <- *NewInputState() return d, nil } @@ -58,25 +58,25 @@ func (x *Xbox360) SetRumbleCallback(f func(XRumbleState)) { // 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 + select { + case <-x.inputCh: + default: + } + x.inputCh <- state } // HandleTransfer implements interrupt IN/OUT for Xbox360. -func (x *Xbox360) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +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) - - x.stateMu.Lock() - var st InputState - if x.inputState != nil { - st = *x.inputState + select { + case <-ctx.Done(): + return nil + case st := <-x.inputCh: + return st.BuildReport() } - x.stateMu.Unlock() - return st.BuildReport() default: return nil } diff --git a/internal/server/api/device_registry_test.go b/internal/server/api/device_registry_test.go index dc59a586..0a13dac6 100644 --- a/internal/server/api/device_registry_test.go +++ b/internal/server/api/device_registry_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "log/slog" "net" "testing" @@ -17,7 +18,7 @@ type mockDevice struct { name string } -func (m *mockDevice) HandleTransfer(ep uint32, dir uint32, out []byte) []byte { +func (m *mockDevice) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { return nil } func (m *mockDevice) GetDescriptor() *usb.Descriptor { diff --git a/internal/server/usb/config.go b/internal/server/usb/config.go index 5c0cc814..1a417a83 100644 --- a/internal/server/usb/config.go +++ b/internal/server/usb/config.go @@ -7,5 +7,5 @@ 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 `help:"Interval to flush write batches to clients; 0 to disable" default:"1ms" env:"VIIPER_USB_WRITE_BATCH_FLUSH_INTERVAL"` + 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/server.go b/internal/server/usb/server.go index fe0802be..ba6c08da 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -3,12 +3,14 @@ package usb import ( "bufio" "bytes" + "context" "encoding/binary" "errors" "fmt" "io" "log/slog" "net" + "slices" "strconv" "strings" "sync" @@ -576,11 +578,8 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var owningBus *virtualbus.VirtualBus for _, b := range s.busses { devices := b.Devices() - for _, d := range devices { - if d == dev { - owningBus = b - break - } + if slices.Contains(devices, dev) { + owningBus = b } if owningBus != nil { break @@ -595,6 +594,55 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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, flush bool) error { + writeMu.Lock() + defer writeMu.Unlock() + ret := usbip.RetSubmit{ + Basic: usbip.HeaderBasic{Command: usbip.RetSubmitCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, + Status: 0, + ActualLength: actualLen, + StartFrame: 0, + NumberOfPackets: 0, + 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) + } + } + 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 + for { select { case <-ctx.Done(): @@ -637,68 +685,131 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } 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} + 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) } - 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 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) } } - respData := s.processSubmit(dev, ep, dir, setup, outPayload) + if dir == usbip.DirIn && ep != 0 { + urbCtx, urbCancel := context.WithCancel(ctx) + pendingMu.Lock() + pending[seq] = urbCancel + pendingMu.Unlock() + interval := endpointInterval(dev.GetDescriptor(), ep) + + go func(seq, ep, dir uint32) { + defer urbCancel() + var respData []byte + 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() + + if err := writeRet(seq, uint32(len(respData)), respData, 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) + continue + } + + // EP0 and OUT transfers never block and are handled in order. + respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) actualLen := uint32(len(respData)) if dir == usbip.DirOut { actualLen = uint32(len(outPayload)) } - - ret := usbip.RetSubmit{ - Basic: usbip.HeaderBasic{Command: usbip.RetSubmitCode, Seqnum: seq, Devid: 0, Dir: 0, Ep: 0}, - Status: 0, - ActualLength: actualLen, - StartFrame: 0, - NumberOfPackets: 0, - ErrorCount: 0, - } - var out bytes.Buffer - out.Grow(retSubmitHeaderSize) - if err := ret.Write(&out); err != nil { - return fmt.Errorf("build RET_SUBMIT header: %w", err) - } - if _, err := writer.Write(out.Bytes()); err != nil { - return fmt.Errorf("write RET_SUBMIT: %w", err) + if err := writeRet(seq, actualLen, respData, ep == 0); err != nil { + return err } - if len(respData) > 0 { - if _, err := writer.Write(respData); err != nil { - return fmt.Errorf("write RET_SUBMIT payload: %w", err) - } - } - if bw != nil && ep == 0 { - if err := bw.Flush(); err != nil { - return fmt.Errorf("flush EP0 response: %w", 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 time.Duration(epDesc.BInterval) * time.Millisecond } - _ = xferFlags - _ = devid } + return 0 } // isClientDisconnect tests whether an error represents a normal client @@ -728,9 +839,9 @@ func isClientDisconnect(err error) bool { return false } -func (s *Server) processSubmit(dev usb.Device, ep uint32, dir uint32, setup []byte, out []byte) []byte { +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(ep, dir, out) + 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) diff --git a/lib/viiper/server.go b/lib/viiper/server.go index 0924794f..a7f24203 100644 --- a/lib/viiper/server.go +++ b/lib/viiper/server.go @@ -59,9 +59,6 @@ func NewUSBServer(config *C.USBServerConfig, outHandle *C.USBServerHandle, logCa if busCleanupTimeout == 0 { busCleanupTimeout = 5 * time.Second } - if writeBatchFlushInterval == 0 { - writeBatchFlushInterval = 1 * time.Millisecond - } var logger *slog.Logger if logCallback != nil { diff --git a/usb/device.go b/usb/device.go index bfa4742b..2248dd28 100644 --- a/usb/device.go +++ b/usb/device.go @@ -1,12 +1,15 @@ 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, return the payload to send; for OUT, consume 'out' and return nil. - HandleTransfer(ep uint32, dir uint32, out []byte) []byte + // 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 } From 209cffbc924b1931662f1125799e5bc4a65c3cad Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 17:13:07 +0200 Subject: [PATCH 205/298] Unmagic ns2pro consts / fix interval --- device/ns2pro/commands.go | 73 +++++++++++++--------- device/ns2pro/const.go | 118 +++++++++++++++++++++++++---------- device/ns2pro/device.go | 78 +++++++++++++---------- device/ns2pro/ns2pro_test.go | 19 ++++-- 4 files changed, 190 insertions(+), 98 deletions(-) diff --git a/device/ns2pro/commands.go b/device/ns2pro/commands.go index 0353b8c3..0a99477b 100644 --- a/device/ns2pro/commands.go +++ b/device/ns2pro/commands.go @@ -11,13 +11,13 @@ func (d *NS2Pro) handleBulkOut(out []byte) { sub := out[3] switch cmd { - case 0x02: + case cmdFlash: d.handleFlashCommand(seq, sub, out) - case 0x03: + case cmdUSB: d.handleUSBCommand(seq, sub, out) - case 0x0C: + case cmdFeature: d.handleFeatureCommand(seq, sub, out) - case 0x09: + case cmdPlayerLED: d.handlePlayerLEDCommand(seq, sub, out) default: d.enqueueResponse(commandHeader(cmd, seq, sub)) @@ -25,25 +25,25 @@ func (d *NS2Pro) handleBulkOut(out []byte) { } func (d *NS2Pro) handlePlayerLEDCommand(seq, sub uint8, out []byte) { - if sub == 0x07 && len(out) >= 9 { + if sub == subPlayerLEDSet && len(out) >= 9 { d.emitOutput(OutputState{ Flags: OutputFlagLED, PlayerLedMask: out[8], }) } - d.enqueueResponse(commandHeader(0x09, seq, sub)) + d.enqueueResponse(commandHeader(cmdPlayerLED, seq, sub)) } func (d *NS2Pro) handleFlashCommand(seq, sub uint8, out []byte) { - if sub != 0x01 || len(out) < 16 { - d.enqueueResponse(commandHeader(0x02, seq, sub)) + if sub != subFlashRead || len(out) < 16 { + d.enqueueResponse(commandHeader(cmdFlash, seq, sub)) return } address := binary.LittleEndian.Uint32(out[12:16]) - resp := make([]byte, 0x50) - copy(resp[0:8], commandHeader(0x02, seq, sub)) - resp[8] = 0x40 + 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) @@ -51,24 +51,30 @@ func (d *NS2Pro) handleFlashCommand(seq, sub uint8, out []byte) { func (d *NS2Pro) handleUSBCommand(seq, sub uint8, out []byte) { switch sub { - case 0x03: + case subUSBEnableReports: if len(out) >= 9 { + d.protoMu.Lock() d.usbReportsEnabled = out[8] != 0 + d.protoMu.Unlock() } - d.enqueueResponse(append(commandHeader(0x03, seq, sub), 0x01, 0x00, 0x00, 0x00)) - case 0x0A: + 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(0x03, seq, sub)) - case 0x0D: + d.enqueueResponse(commandHeader(cmdUSB, seq, sub)) + case subUSBStartReports: + d.protoMu.Lock() d.usbReportsEnabled = true - d.enqueueResponse(append(commandHeader(0x03, seq, sub), 0x01, 0x00, 0x00, 0x00)) + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdUSB, seq, sub), 0x01, 0x00, 0x00, 0x00)) default: - d.enqueueResponse(commandHeader(0x03, seq, sub)) + d.enqueueResponse(commandHeader(cmdUSB, seq, sub)) } } @@ -79,28 +85,37 @@ func (d *NS2Pro) handleFeatureCommand(seq, sub uint8, out []byte) { } switch sub { - case 0x01: + case subFeatureInfo: payload := make([]byte, 12) copy(payload[4:], featureInfo(flags)) - d.enqueueResponse(append(commandHeader(0x0C, seq, sub), payload...)) - case 0x02: + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), payload...)) + case subFeatureSetMask: + d.protoMu.Lock() d.featureMask = flags - d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) - case 0x03: + 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.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) - case 0x04: + 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.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) - case 0x05: + 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.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + d.protoMu.Unlock() + d.enqueueResponse(append(commandHeader(cmdFeature, seq, sub), 0x00, 0x00, 0x00, 0x00)) default: - d.enqueueResponse(append(commandHeader(0x0C, seq, sub), 0x00, 0x00, 0x00, 0x00)) + 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 diff --git a/device/ns2pro/const.go b/device/ns2pro/const.go index d9af9108..04700249 100644 --- a/device/ns2pro/const.go +++ b/device/ns2pro/const.go @@ -8,6 +8,43 @@ const ( 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 @@ -30,46 +67,61 @@ const ( ) const ( - OutputFlagRumble = 0x01 - OutputFlagLED = 0x02 + FeatureButtons = 0x01 + FeatureSticks = 0x02 + FeatureIMU = 0x04 + FeatureMouse = 0x10 + FeatureRumble = 0x20 ) const ( - StickMin uint16 = 0 - StickCenter uint16 = 0x0800 - StickMax uint16 = 0x0FFF - BatteryMax uint8 = 9 + hidClassRequestIn = 0xA1 + hidClassRequestOut = 0x21 + + hidGetReport = 0x01 + hidSetReport = 0x09 + + reportTypeInput = 0x01 + reportTypeOutput = 0x02 ) const ( - FeatureButtons = 0x01 - FeatureSticks = 0x02 - FeatureIMU = 0x04 - FeatureMouse = 0x10 - FeatureRumble = 0x20 + audioSetCur = 0x01 + audioGetCur = 0x81 + audioGetMin = 0x82 + audioGetMax = 0x83 + audioGetRes = 0x84 ) 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 + 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/device.go b/device/ns2pro/device.go index 78c65921..3ad1b5bc 100644 --- a/device/ns2pro/device.go +++ b/device/ns2pro/device.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -15,6 +16,7 @@ import ( type NS2Pro struct { inputCh chan struct{} + bulkCh chan struct{} stateMu sync.Mutex inputState *InputState metaState *MetaState @@ -30,7 +32,8 @@ type NS2Pro struct { usbReportsEnabled bool reportCounter32 uint32 reportCounter8 uint8 - motionTimestamp uint32 + motionStart time.Time + lastMotionTS uint32 bulkInQueue [][]byte } @@ -62,11 +65,13 @@ func New(o *device.CreateOptions) (*NS2Pro, error) { 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 { @@ -127,17 +132,30 @@ func (d *NS2Pro) SetMetaState(meta MetaState) { func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { switch { case dir == usbip.DirIn && ep == 1: - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return d.nextInputReport() + 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() + } } - return nil - case <-d.inputCh: - return d.nextInputReport() } case dir == usbip.DirIn && ep == 2: - return d.popBulkIn() + 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: @@ -147,35 +165,26 @@ func (d *NS2Pro) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out } func (d *NS2Pro) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex uint16, wLength uint16, data []byte) ([]byte, bool) { - const ( - hidGetReport = 0x01 - hidSetReport = 0x09 - ) - const ( - reportTypeInput = 0x01 - reportTypeOutput = 0x02 - ) - reportType := uint8(wValue >> 8) reportID := uint8(wValue) - if bmRequestType == 0xA1 && bRequest == hidGetReport && reportType == reportTypeInput { + if bmRequestType == hidClassRequestIn && bRequest == hidGetReport && reportType == reportTypeInput { switch reportID { case ReportIDCommon, ReportIDPro, 0: return d.inputReportForID(reportID), true } } - if bmRequestType == 0x21 && bRequest == hidSetReport && reportType == reportTypeOutput && reportID == ReportIDOutput { + if bmRequestType == hidClassRequestOut && bRequest == hidSetReport && reportType == reportTypeOutput && reportID == ReportIDOutput { d.handleOutputReport(data) return nil, true } if isAudioClassRequest(bmRequestType) { switch bRequest { - case 0x01: // SET_CUR + case audioSetCur: return nil, true - case 0x81, 0x82, 0x83, 0x84: // GET_CUR/MIN/MAX/RES + case audioGetCur, audioGetMin, audioGetMax, audioGetRes: return make([]byte, wLength), true } } @@ -184,13 +193,6 @@ func (d *NS2Pro) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex uin } func isAudioClassRequest(bmRequestType uint8) bool { - const ( - requestTypeMask = 0x60 - requestClass = 0x20 - recipientMask = 0x1F - recipientIface = 0x01 - recipientEndpoint = 0x02 - ) return bmRequestType&requestTypeMask == requestClass && (bmRequestType&recipientMask == recipientIface || bmRequestType&recipientMask == recipientEndpoint) } @@ -216,6 +218,12 @@ func (d *NS2Pro) GetDeviceSpecificArgs() 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 @@ -238,10 +246,12 @@ func (d *NS2Pro) inputReportForID(reportID uint8) []byte { switch reportID { case ReportIDCommon: d.reportCounter32++ + var motionTS uint32 if features&FeatureIMU != 0 { - d.motionTimestamp += 4000 + motionTS = uint32(time.Since(d.motionStart).Microseconds()) + d.lastMotionTS = motionTS } - report = st.buildCommonReport(d.reportCounter32, d.motionTimestamp, features, meta) + report = st.buildCommonReport(d.reportCounter32, motionTS, features, meta) default: d.reportCounter8++ report = st.buildProReport(d.reportCounter8, features, meta) @@ -292,8 +302,12 @@ func (d *NS2Pro) emitOutput(feedback OutputState) { func (d *NS2Pro) enqueueResponse(resp []byte) { d.protoMu.Lock() - defer d.protoMu.Unlock() d.bulkInQueue = append(d.bulkInQueue, append([]byte(nil), resp...)) + d.protoMu.Unlock() + select { + case d.bulkCh <- struct{}{}: + default: + } } func (d *NS2Pro) popBulkIn() []byte { diff --git a/device/ns2pro/ns2pro_test.go b/device/ns2pro/ns2pro_test.go index ba19fb98..5eef95e6 100644 --- a/device/ns2pro/ns2pro_test.go +++ b/device/ns2pro/ns2pro_test.go @@ -51,6 +51,7 @@ func TestBuildReport05(t *testing.T) { 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) @@ -67,7 +68,7 @@ func TestBuildReport05(t *testing.T) { assert.Equal(t, DefaultBatteryVolts, binary.LittleEndian.Uint16(report[0x20:0x22])) assert.Equal(t, byte(0x34), report[0x22]) assert.Equal(t, byte(0x01), report[0x2A]) - assert.Equal(t, uint32(4000), binary.LittleEndian.Uint32(report[0x2B:0x2F])) + 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])) @@ -80,7 +81,7 @@ func TestBuildReport05(t *testing.T) { 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.Equal(t, uint32(8000), binary.LittleEndian.Uint32(next[0x2B:0x2F])) + assert.Greater(t, binary.LittleEndian.Uint32(next[0x2B:0x2F]), ts1) } func TestHIDReportDescriptorMatchesCapture(t *testing.T) { @@ -173,6 +174,8 @@ func TestBuildReport09(t *testing.T) { }) 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) @@ -201,6 +204,8 @@ func TestBulkCommands(t *testing.T) { 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]) @@ -273,11 +278,12 @@ func TestSDLUSBInitializationSequence(t *testing.T) { 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.Equal(t, uint32(4000), binary.LittleEndian.Uint32(report[0x2B:0x2F])) + assert.NotZero(t, binary.LittleEndian.Uint32(report[0x2B:0x2F])) } func TestRumbleOutput(t *testing.T) { @@ -433,6 +439,7 @@ func TestStreamInputAndRumble(t *testing.T) { 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, @@ -473,7 +480,11 @@ func featureCommand(sub, flags uint8) []byte { } func selectReportCommand(reportID uint8) []byte { - return []byte{0x03, 0x91, 0x00, 0x0A, 0x00, 0x04, 0x00, 0x00, reportID, 0x00, 0x00, 0x00} + 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 { From cfb8ca147c3061656193b3c4ee27b95ac4c68027 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 18:57:58 +0200 Subject: [PATCH 206/298] Use own SDL cgo wrapper instead of wasm/purego-based for e2e tests --- .gitmodules | 4 + .vscode/settings.json | 3 +- _testing/e2e/bench_test.go | 19 +- _testing/e2e/deps/SDL | 1 + _testing/e2e/sdl/error.go | 30 + _testing/e2e/sdl/gamepad.go | 881 ++++++++++++++++++++++++++ _testing/e2e/sdl/joystick.go | 590 +++++++++++++++++ _testing/e2e/sdl/sdl.go | 80 +++ docs/testing/e2e_latency.md | 38 +- internal/server/api/config.go | 2 +- internal/server/api/config_unix.go | 2 +- internal/server/api/config_windows.go | 4 +- 12 files changed, 1622 insertions(+), 32 deletions(-) create mode 100644 .gitmodules create mode 160000 _testing/e2e/deps/SDL create mode 100644 _testing/e2e/sdl/error.go create mode 100644 _testing/e2e/sdl/gamepad.go create mode 100644 _testing/e2e/sdl/joystick.go create mode 100644 _testing/e2e/sdl/sdl.go 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/.vscode/settings.json b/.vscode/settings.json index 79baf32e..1afb0c7a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "go.toolsEnvVars": { - "CGO_ENABLED": "1" + "CGO_ENABLED": "1", + "PATH": "${env:PATH};${workspaceFolder}/_testing/e2e/deps/SDL/build/Debug" } } diff --git a/_testing/e2e/bench_test.go b/_testing/e2e/bench_test.go index bdc9acfa..83a2b66c 100644 --- a/_testing/e2e/bench_test.go +++ b/_testing/e2e/bench_test.go @@ -18,8 +18,7 @@ import ( _ "github.com/Alia5/VIIPER/internal/registry" // Register all device handlers - "github.com/Zyko0/go-sdl3/bin/binsdl" - "github.com/Zyko0/go-sdl3/sdl" + "github.com/Alia5/VIIPER/_testing/e2e/sdl" ) type TimeWhat int @@ -151,13 +150,14 @@ func Benchmark_Xbox360_Delay(b *testing.B) { b.SetParallelism(1) - defer binsdl.Load().Unload() defer sdl.Quit() - sdl.Init(sdl.INIT_GAMEPAD) + if err := sdl.Init(sdl.InitFlagGamepad); err != nil { + b.Fatalf("SDL init failed: %v", err) + } sdl.UpdateGamepads() existingGamepads, _ := sdl.GetGamepads() - existingGamepadSet := make(map[sdl.JoystickID]bool) + existingGamepadSet := make(map[sdl.GamepadID]bool) for _, id := range existingGamepads { existingGamepadSet[id] = true } @@ -172,6 +172,9 @@ func Benchmark_Xbox360_Delay(b *testing.B) { AutoAttachLocalClient: true, DeviceHandlerConnectTimeout: time.Second * 5, Password: "testpassword1234", + PlatformOpts: api.PlatformOpts{ + AutoAttachWindowsNative: true, + }, }, ConnectionTimeout: 5 * time.Second, } @@ -219,11 +222,11 @@ func Benchmark_Xbox360_Delay(b *testing.B) { gIDs, _ := sdl.GetGamepads() for _, id := range gIDs { if !existingGamepadSet[id] { - gamepad, err = id.OpenGamepad() + gamepad, err = sdl.OpenGamepad(id) if err != nil { b.Fatalf("OpenGamepad failed: %v", err) } - defer gamepad.Close() //nolint:errcheck + defer gamepad.Close() break } } @@ -246,7 +249,7 @@ func Benchmark_Xbox360_Delay(b *testing.B) { default: } sdl.UpdateGamepads() - pressed := gamepad.Button(sdl.GAMEPAD_BUTTON_SOUTH) + pressed := gamepad.GetButton(sdl.GamepadButtonSouth) if pressed != prevPadPressed { padChann <- pressed prevPadPressed = pressed 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/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/docs/testing/e2e_latency.md b/docs/testing/e2e_latency.md index 033247d6..5fc090af 100644 --- a/docs/testing/e2e_latency.md +++ b/docs/testing/e2e_latency.md @@ -6,13 +6,13 @@ It groups repeated cycles when `-count > 1` and uses the single press E2E measur ## 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) | +| 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`. @@ -42,21 +42,21 @@ 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 | +| 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 | +| 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. diff --git a/internal/server/api/config.go b/internal/server/api/config.go index cd44d1ef..3c446c47 100644 --- a/internal/server/api/config.go +++ b/internal/server/api/config.go @@ -9,7 +9,7 @@ type ServerConfig struct { 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:""` + 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 index 54d528ce..1da00bfe 100644 --- a/internal/server/api/config_unix.go +++ b/internal/server/api/config_unix.go @@ -2,6 +2,6 @@ package api -type platformOpts struct { +type PlatformOpts struct { AutoAttachWindowsNative bool `kong:"-"` } diff --git a/internal/server/api/config_windows.go b/internal/server/api/config_windows.go index 43ea9ad0..1252290b 100644 --- a/internal/server/api/config_windows.go +++ b/internal/server/api/config_windows.go @@ -2,6 +2,6 @@ package api -type platformOpts struct { - AutoAttachWindowsNative bool `help:"Use native IOCTL instead of usbip.exe for auto-attach" default:"true" env:"VIIPER_API_AUTO_ATTACH_WINDOWS_NATIVE"` +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"` } From 88d5125e56e1686066336f8ebd3a92e19d61b734 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 19:08:54 +0200 Subject: [PATCH 207/298] Move to wall-clock based report timestamps Changelog(fix) --- device/dualsense/device.go | 9 +++++---- device/dualshock4/device.go | 18 +++--------------- device/mouse/device.go | 4 ---- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 70271759..20cd2c82 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -10,7 +10,7 @@ import ( "math" "net" "sync" - "sync/atomic" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/usb" @@ -27,8 +27,8 @@ type DualSense struct { subcommand [2]byte - seqCounter uint8 - usbReportTimestamp uint32 + seqCounter uint8 + timestampBase time.Time mtx sync.Mutex } @@ -114,6 +114,7 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { d.inputState = NewInputState() d.inputCh = make(chan *InputState, 1) d.inputCh <- d.inputState + d.timestampBase = time.Now() return d, nil } @@ -439,7 +440,7 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { binary.LittleEndian.PutUint16(b[24:26], uint16(s.AccelY)) binary.LittleEndian.PutUint16(b[26:28], uint16(s.AccelZ)) - ts := atomic.AddUint32(&d.usbReportTimestamp, 1) + ts := uint32(time.Since(d.timestampBase).Microseconds() * 3) binary.LittleEndian.PutUint32(b[28:32], ts) touch1 := uint8(0) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index be2c2b26..b48934df 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -28,7 +28,7 @@ type DualShock4 struct { telemetrySubcommand byte usbPacketCounter uint32 - lastUSBReportAt time.Time + timestampBase time.Time mtx sync.Mutex } @@ -92,6 +92,7 @@ func New(o *device.CreateOptions) (*DualShock4, error) { d.inputState = NewInputState() d.inputCh = make(chan *InputState, 1) d.inputCh <- d.inputState + d.timestampBase = time.Now() return d, nil } @@ -511,18 +512,5 @@ func (d *DualShock4) buildUSBInputReport(s *InputState, m *MetaState) []byte { } func (d *DualShock4) nextReportTimestamp() uint32 { - d.mtx.Lock() - defer d.mtx.Unlock() - now := time.Now() - if d.lastUSBReportAt.IsZero() { - d.lastUSBReportAt = now - return 188 - } - elapsed := now.Sub(d.lastUSBReportAt).Nanoseconds() - d.lastUSBReportAt = now - ts := uint32(elapsed * 3 / 16000) - if ts == 0 { - ts = 1 - } - return ts + return uint32(time.Since(d.timestampBase).Nanoseconds() * 3 / 16000) } diff --git a/device/mouse/device.go b/device/mouse/device.go index 5890a8f6..89f79e6a 100644 --- a/device/mouse/device.go +++ b/device/mouse/device.go @@ -37,7 +37,6 @@ func New(o *device.CreateOptions) (*Mouse, error) { return d, nil } -// UpdateInputState updates the device's current input state (thread-safe). func (m *Mouse) UpdateInputState(state InputState) { select { case <-m.inputCh: @@ -46,7 +45,6 @@ func (m *Mouse) UpdateInputState(state InputState) { m.inputCh <- state } -// HandleTransfer implements interrupt IN for Mouse. func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { if dir == usbip.DirIn { switch ep { @@ -56,8 +54,6 @@ func (m *Mouse) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out [ case <-ctx.Done(): return nil case st := <-m.inputCh: - // Re-queue a zero-delta state (buttons preserved) so the server's - // keepalive cache reflects no movement after delivering this event. if st.DX != 0 || st.DY != 0 || st.Wheel != 0 || st.Pan != 0 { zeroed := InputState{Buttons: st.Buttons} select { From 0f70ea897e5fb40852e0f433f4a61ca30439f22c Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 19:19:55 +0200 Subject: [PATCH 208/298] Fix tests --- _testing/usbip_client.go | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go index 9f38e7d7..ea898431 100644 --- a/_testing/usbip_client.go +++ b/_testing/usbip_client.go @@ -330,7 +330,26 @@ func (c *TestUsbIpClient) PollInputReport(conn net.Conn, want []byte, timeout ti } } if wantAllZero { - return c.ReadInputReportWithTimeout(conn, timeout) + 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) @@ -345,6 +364,9 @@ func (c *TestUsbIpClient) PollInputReport(conn net.Conn, want []byte, timeout ti 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 { @@ -353,7 +375,11 @@ func (c *TestUsbIpClient) PollInputReport(conn net.Conn, want []byte, timeout ti } } if !allZero { - return got, nil + if time.Now().After(deadline) { + return last, nil + } + time.Sleep(1 * time.Millisecond) + continue } if time.Now().After(deadline) { return last, nil From 88f66f1ed0c3716c78f810d92b1924112093f896 Mon Sep 17 00:00:00 2001 From: Peter Repukat Date: Fri, 12 Jun 2026 20:11:43 +0200 Subject: [PATCH 209/298] Fix pipeline --- .github/workflows/build_base.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_base.yml b/.github/workflows/build_base.yml index ccc09f4a..d16886bb 100644 --- a/.github/workflows/build_base.yml +++ b/.github/workflows/build_base.yml @@ -206,6 +206,7 @@ jobs: 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 From ac10bb414b1d42c7b5e0cf47739d748e6f4fbf67 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 12 Jun 2026 21:09:46 -0500 Subject: [PATCH 210/298] Expose DualSense adaptive trigger feedback --- device/dualsense/const.go | 9 ++-- device/dualsense/device.go | 19 +++++++++ device/dualsense/ds_handler.go | 13 +++++- device/dualsense/dsedge_handler.go | 13 +++++- device/dualsense/state.go | 66 +++++++++++++++++++++++++++++- docs/devices/dualsense.md | 29 +++++++++---- 6 files changed, 132 insertions(+), 17 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 530b4579..5264236f 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -39,10 +39,11 @@ const ( ) const ( - InputReportSize = 64 - OutputReportSize = 64 - InputStateSize = 33 - OutputStateSize = 6 + InputReportSize = 64 + OutputReportSize = 64 + InputStateSize = 33 + OutputStateSize = 6 + OutputStateExtSize = 22 ) const ( diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 20cd2c82..b6f77c94 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -282,6 +282,25 @@ func parseOutputReport(out []byte) OutputState { feedback.PlayerLeds = out[44] } } + if len(out) > 31 { + 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] + + 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] + } return feedback } diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 3a02c100..b9d00879 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -15,9 +15,12 @@ import ( func init() { api.RegisterDevice("dualsense", &dshandler{}) + api.RegisterDevice("dualsenseext", &dshandler{extendedFeedback: true}) } -type dshandler struct{} +type dshandler struct { + extendedFeedback bool +} func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { if o == nil { @@ -110,7 +113,13 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } dse.SetOutputCallback(func(feedback OutputState) { - data, err := feedback.MarshalBinary() + var data []byte + var err error + if h.extendedFeedback { + data, err = feedback.MarshalExtendedBinary() + } else { + data, err = feedback.MarshalBinary() + } if err != nil { logger.Error("failed to marshal feedback", "error", err) return diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index aa455220..57fadb5f 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -15,9 +15,12 @@ import ( func init() { api.RegisterDevice("dualsenseedge", &dsedgehandler{}) + api.RegisterDevice("dualsenseedgeext", &dsedgehandler{extendedFeedback: true}) } -type dsedgehandler struct{} +type dsedgehandler struct { + extendedFeedback bool +} func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { if o == nil { @@ -110,7 +113,13 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } dse.SetOutputCallback(func(feedback OutputState) { - data, err := feedback.MarshalBinary() + var data []byte + var err error + if h.extendedFeedback { + data, err = feedback.MarshalExtendedBinary() + } else { + data, err = feedback.MarshalBinary() + } if err != nil { logger.Error("failed to marshal feedback", "error", err) return diff --git a/device/dualsense/state.go b/device/dualsense/state.go index c3e140bc..d4e63790 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -93,7 +93,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { } // nolint -// viiper:wire dualsense s2c rumbleSmall:u8 rumbleLarge:u8 ledRed:u8 ledGreen:u8 ledBlue:u8 playerLeds:u8 +// 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 triggerR2Frequency:u8 triggerL2Mode:u8 triggerL2StartResistance:u8 triggerL2EffectForce:u8 triggerL2RangeForce:u8 triggerL2NearReleaseStrength:u8 triggerL2NearMiddleStrength:u8 triggerL2PressedStrength:u8 triggerL2Frequency:u8 type OutputState struct { RumbleSmall uint8 RumbleLarge uint8 @@ -101,6 +101,23 @@ type OutputState struct { 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 } func (f *OutputState) MarshalBinary() ([]byte, error) { @@ -114,6 +131,33 @@ func (f *OutputState) MarshalBinary() ([]byte, error) { }, nil } +func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { + return []byte{ + f.RumbleSmall, + f.RumbleLarge, + f.LedRed, + f.LedGreen, + f.LedBlue, + f.PlayerLeds, + f.TriggerR2Mode, + f.TriggerR2StartResistance, + f.TriggerR2EffectForce, + f.TriggerR2RangeForce, + f.TriggerR2NearReleaseStrength, + f.TriggerR2NearMiddleStrength, + f.TriggerR2PressedStrength, + f.TriggerR2Frequency, + f.TriggerL2Mode, + f.TriggerL2StartResistance, + f.TriggerL2EffectForce, + f.TriggerL2RangeForce, + f.TriggerL2NearReleaseStrength, + f.TriggerL2NearMiddleStrength, + f.TriggerL2PressedStrength, + f.TriggerL2Frequency, + }, nil +} + func (f *OutputState) UnmarshalBinary(data []byte) error { if len(data) < OutputStateSize { return io.ErrUnexpectedEOF @@ -124,6 +168,26 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.LedGreen = data[3] f.LedBlue = data[4] f.PlayerLeds = data[5] + if len(data) < OutputStateExtSize { + 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[13] + f.TriggerL2Mode = data[14] + f.TriggerL2StartResistance = data[15] + f.TriggerL2EffectForce = data[16] + f.TriggerL2RangeForce = data[17] + f.TriggerL2NearReleaseStrength = data[18] + f.TriggerL2NearMiddleStrength = data[19] + f.TriggerL2PressedStrength = data[20] + f.TriggerL2Frequency = data[21] return nil } diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index ccea23f9..11ff3cbc 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -52,14 +52,27 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. ### Feedback (Rumble & LED) - - 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 - - See `/device/dualsense/state.go` for the `OutputState` wire definition. + - 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 22-byte packets. + Bytes 0..5 preserve the original rumble/LED layout above. Bytes 6..13 + contain the raw R2 adaptive-trigger effect block copied from USB output + report 0x02. Bytes 14..21 contain the raw L2 adaptive-trigger effect + block. Each trigger block is: + - Mode + - StartResistance + - EffectForce + - RangeForce + - NearReleaseStrength + - NearMiddleStrength + - PressedStrength + - Frequency + + See `/device/dualsense/state.go` for the `OutputState` wire definition. ## Reference From 2285d19f3c22512b5e841474e8c836f33672414f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 12 Jun 2026 21:11:27 -0500 Subject: [PATCH 211/298] Trigger VIIPER CI build From 09daa00dbe4517215cf02e47a4110a057de107d1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 12 Jun 2026 21:17:26 -0500 Subject: [PATCH 212/298] Allow hash-only dev versions in Windows resources --- scripts/inject-version.ps1 | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/inject-version.ps1 b/scripts/inject-version.ps1 index d4749117..5c84463a 100644 --- a/scripts/inject-version.ps1 +++ b/scripts/inject-version.ps1 @@ -4,18 +4,24 @@ param( [string]$OutputJson ) -$ver = $Version.TrimStart('v') -$parts = $ver -split '[.-]' -$major = [int]$parts[0] -$minor = if ($parts.Length -gt 1) { [int]$parts[1] } else { 0 } -$patch = if ($parts.Length -gt 2) { [int]$parts[2] } else { 0 } -$build = 0 -if ($parts.Length -gt 3) { - $buildStr = $parts[3] - if ($buildStr -match '^\d+$') { - $build = [int]$buildStr - } -} +$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 From 03317f67954baa742391b754d12f08bef3e9df9b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 12 Jun 2026 23:31:39 -0500 Subject: [PATCH 213/298] Keep DualSense extended feedback on stream dispatch --- device/dualsense/device.go | 5 +++-- device/dualsense/ds_handler.go | 9 +++++++-- device/dualsense/dsedge_handler.go | 9 +++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index b6f77c94..c6ea9d52 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -22,8 +22,9 @@ type DualSense struct { inputState *InputState metaState *MetaState - outputFunc func(OutputState) - descriptor usb.Descriptor + outputFunc func(OutputState) + descriptor usb.Descriptor + extendedFeedback bool subcommand [2]byte diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index b9d00879..76f621ac 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -81,7 +81,12 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { } o.DeviceSpecific = string(b) - return new(o, false) + dse, err := new(o, false) + if err != nil { + return nil, err + } + dse.extendedFeedback = h.extendedFeedback + return dse, nil } func (h *dshandler) StreamHandler() api.StreamHandlerFunc { @@ -115,7 +120,7 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { dse.SetOutputCallback(func(feedback OutputState) { var data []byte var err error - if h.extendedFeedback { + if h.extendedFeedback || dse.extendedFeedback { data, err = feedback.MarshalExtendedBinary() } else { data, err = feedback.MarshalBinary() diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index 57fadb5f..675fba0a 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -81,7 +81,12 @@ func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error } o.DeviceSpecific = string(b) - return new(o, true) + dse, err := new(o, true) + if err != nil { + return nil, err + } + dse.extendedFeedback = h.extendedFeedback + return dse, nil } func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { @@ -115,7 +120,7 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { dse.SetOutputCallback(func(feedback OutputState) { var data []byte var err error - if h.extendedFeedback { + if h.extendedFeedback || dse.extendedFeedback { data, err = feedback.MarshalExtendedBinary() } else { data, err = feedback.MarshalBinary() From fe394f8b2e71bb912708fad311a015d2148975c8 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 00:14:43 -0500 Subject: [PATCH 214/298] Accept DualSense output reports for feedback --- device/dualsense/device.go | 46 +++++++++++--- device/dualsense/device_output_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 device/dualsense/device_output_test.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index c6ea9d52..fba9a2c7 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -186,11 +186,9 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o } } - if dir == usbip.DirOut && ep == 3 { - if len(out) >= 48 && out[0] == ReportIDOutput { - if d.outputFunc != nil { - d.outputFunc(parseOutputReport(out)) - } + if dir == usbip.DirOut && ep == EndpointOut { + if d.handleOutputReport(out) { + return nil } } @@ -239,10 +237,8 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, return nil, true case reportType == reportTypeFeature: return nil, true - case reportType == reportTypeOutput && reportID == ReportIDOutput && len(data) >= 48: - if d.outputFunc != nil { - d.outputFunc(parseOutputReport(data)) - } + case reportType == reportTypeOutput && reportID == ReportIDOutput: + d.handleOutputReport(data) return nil, true } } @@ -260,6 +256,38 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, return nil, false } +func (d *DualSense) handleOutputReport(out []byte) bool { + report, ok := normalizeOutputReport(out) + if !ok { + return false + } + if d.outputFunc != nil { + d.outputFunc(parseOutputReport(report)) + } + return true +} + +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, diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go new file mode 100644 index 00000000..104d7c81 --- /dev/null +++ b/device/dualsense/device_output_test.go @@ -0,0 +1,84 @@ +package dualsense + +import ( + "context" + "testing" + + "github.com/Alia5/VIIPER/usbip" +) + +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[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) + } +} + +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[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) + } +} From 960c0e69ddaf7c3f94b0cf6fbfdb60bb71c9851b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 01:14:31 -0500 Subject: [PATCH 215/298] Log raw DualSense host output reports --- device/dualsense/device.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index fba9a2c7..d9eaca85 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -3,12 +3,14 @@ package dualsense import ( "context" "encoding/binary" + "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "math" "net" + "os" "sync" "time" @@ -17,6 +19,8 @@ import ( "github.com/Alia5/VIIPER/usbip" ) +var rawOutputLogEnabled = os.Getenv("VIIPER_DUALSENSE_RAW_OUTPUT_LOG") == "1" + type DualSense struct { inputCh chan *InputState inputState *InputState @@ -261,12 +265,41 @@ func (d *DualSense) handleOutputReport(out []byte) bool { if !ok { return false } + logRawOutputReport(report) if d.outputFunc != nil { d.outputFunc(parseOutputReport(report)) } 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 normalizeOutputReport(out []byte) ([]byte, bool) { if len(out) == 0 { return nil, false From 80effcac5623304b2ad3b0ef7f15c1661d826bba Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 01:38:00 -0500 Subject: [PATCH 216/298] Preserve DualSense touch tracking bytes --- device/dualsense/device.go | 22 +++++++------ device/dualsense/device_output_test.go | 37 ++++++++++++++++++++++ device/dualsense/state.go | 44 +++++++++++++++++++++----- docs/devices/dualsense.md | 10 +++--- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d9eaca85..0dddce96 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -524,18 +524,10 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { ts := uint32(time.Since(d.timestampBase).Microseconds() * 3) binary.LittleEndian.PutUint32(b[28:32], ts) - touch1 := uint8(0) - if !s.Touch1Active { - touch1 |= TouchInactiveMask - } - b[33] = touch1 + b[33] = normalizeTouchTracking(s.Touch1Active, s.Touch1Tracking) encodeTouchCoords(b[34:37], s.Touch1X, s.Touch1Y) - touch2 := uint8(0) - if !s.Touch2Active { - touch2 |= TouchInactiveMask - } - b[37] = touch2 + b[37] = normalizeTouchTracking(s.Touch2Active, s.Touch2Tracking) encodeTouchCoords(b[38:41], s.Touch2X, s.Touch2Y) b[49] = 0x10 @@ -543,3 +535,13 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { return b } + +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 index 104d7c81..4252d32e 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -82,3 +82,40 @@ func TestDualSenseOutputSetReportWithoutReportId(t *testing.T) { t.Fatalf("unexpected rumble: small=%#x large=%#x", got.RumbleSmall, got.RumbleLarge) } } + +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]) + } +} diff --git a/device/dualsense/state.go b/device/dualsense/state.go index d4e63790..66f3a3e9 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -17,10 +17,12 @@ type InputState struct { DPad uint8 L2, R2 uint8 - Touch1X, Touch1Y uint16 - Touch1Active bool - Touch2X, Touch2Y uint16 - Touch2Active bool + Touch1X, Touch1Y uint16 + Touch1Active bool + Touch1Tracking uint8 + Touch2X, Touch2Y uint16 + Touch2Active bool + Touch2Tracking uint8 GyroX, GyroY, GyroZ int16 AccelX, AccelY, AccelZ int16 @@ -48,12 +50,14 @@ func (s *InputState) MarshalBinary() ([]byte, error) { b[10] = s.R2 binary.LittleEndian.PutUint16(b[11:13], s.Touch1X) binary.LittleEndian.PutUint16(b[13:15], s.Touch1Y) - if s.Touch1Active { + 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) - if s.Touch2Active { + 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)) @@ -79,10 +83,10 @@ func (s *InputState) UnmarshalBinary(data []byte) error { s.R2 = data[10] s.Touch1X = binary.LittleEndian.Uint16(data[11:13]) s.Touch1Y = binary.LittleEndian.Uint16(data[13:15]) - s.Touch1Active = data[15] != 0 + 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 = data[20] != 0 + 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])) @@ -191,6 +195,30 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { return nil } +func encodeTouchStatus(active bool, tracking uint8) uint8 { + if tracking != 0 { + if active { + return tracking &^ 0x80 + } + return tracking | 0x80 + } + if active { + return 1 + } + return 0 +} + +func decodeTouchStatus(status uint8) (bool, uint8) { + switch status { + case 0: + return false, 0x80 + case 1: + return true, 0 + default: + return status&0x80 == 0, status + } +} + type MetaState struct { SerialNumber string `json:"serial_number"` MACAddress string `json:"mac_address"` // "XX:XX:XX:XX:XX:XX" diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index 11ff3cbc..6d14519f 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -41,8 +41,8 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. - 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) + - 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 @@ -112,8 +112,10 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. ### 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`. + 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. VIIPER clamps touch coordinates to the DualSense range: From b6a59c1e921d112f326588b6752b6bcdc6fe60e3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 02:01:28 -0500 Subject: [PATCH 217/298] Stabilize DualSense input report status bytes --- device/dualsense/device.go | 3 ++- device/dualsense/device_output_test.go | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 0dddce96..693597b2 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -530,7 +530,8 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[37] = normalizeTouchTracking(s.Touch2Active, s.Touch2Tracking) encodeTouchCoords(b[38:41], s.Touch2X, s.Touch2Y) - b[49] = 0x10 + b[41] = d.seqCounter + binary.LittleEndian.PutUint32(b[49:53], ts) b[53] = m.BatteryStatus return b diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 4252d32e..628221ae 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -118,4 +118,10 @@ func TestDualSenseTouchTrackingBytes(t *testing.T) { 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") + } } From 1fcbbf2570eae1f46c7359de072d89a7d6055e48 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 08:39:48 -0500 Subject: [PATCH 218/298] Fix DualSense touch and feedback packet layout --- device/dualsense/const.go | 2 +- device/dualsense/device_output_test.go | 67 ++++++++++++++++++++++++++ device/dualsense/state.go | 60 +++++++++++++++-------- 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 5264236f..9be49d72 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -43,7 +43,7 @@ const ( OutputReportSize = 64 InputStateSize = 33 OutputStateSize = 6 - OutputStateExtSize = 22 + OutputStateExtSize = 28 ) const ( diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 628221ae..1f673e09 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -125,3 +125,70 @@ func TestDualSenseTouchTrackingBytes(t *testing.T) { t.Fatal("unexpected legacy hard-coded status byte in report timestamp area") } } + +func TestDualSenseTouchTrackingZeroIsActive(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 != 0 { + t.Fatalf("tracking zero should be active: 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 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, + } + + 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) + } +} diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 66f3a3e9..88a1e3f5 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -136,6 +136,35 @@ func (f *OutputState) MarshalBinary() ([]byte, error) { } 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 + return b, nil +} + +func (f *OutputState) marshalLegacyExtendedBinary() ([]byte, error) { return []byte{ f.RumbleSmall, f.RumbleLarge, @@ -183,15 +212,15 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.TriggerR2NearReleaseStrength = data[10] f.TriggerR2NearMiddleStrength = data[11] f.TriggerR2PressedStrength = data[12] - f.TriggerR2Frequency = data[13] - f.TriggerL2Mode = data[14] - f.TriggerL2StartResistance = data[15] - f.TriggerL2EffectForce = data[16] - f.TriggerL2RangeForce = data[17] - f.TriggerL2NearReleaseStrength = data[18] - f.TriggerL2NearMiddleStrength = data[19] - f.TriggerL2PressedStrength = data[20] - f.TriggerL2Frequency = data[21] + 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] return nil } @@ -203,20 +232,13 @@ func encodeTouchStatus(active bool, tracking uint8) uint8 { return tracking | 0x80 } if active { - return 1 + return 0 } - return 0 + return TouchInactiveMask } func decodeTouchStatus(status uint8) (bool, uint8) { - switch status { - case 0: - return false, 0x80 - case 1: - return true, 0 - default: - return status&0x80 == 0, status - } + return status&0x80 == 0, status } type MetaState struct { From 0f391249e6239cd1a66c900266c24f53da7325ab Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 08:50:58 -0500 Subject: [PATCH 219/298] Fix DualSense feedback lint issues --- device/dualsense/device_output_test.go | 20 +++++++++---------- device/dualsense/state.go | 27 -------------------------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 1f673e09..fdcfa44a 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -157,16 +157,16 @@ func TestDualSenseTouchTrackingZeroIsActive(t *testing.T) { 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, + RumbleSmall: 0x11, + RumbleLarge: 0x22, + TriggerR2Mode: 0x21, + TriggerR2StartResistance: 0x33, + TriggerR2PressedStrength: 0x44, + TriggerR2Frequency: 0x55, + TriggerL2Mode: 0x25, + TriggerL2StartResistance: 0x66, + TriggerL2PressedStrength: 0x77, + TriggerL2Frequency: 0x88, } data, err := out.MarshalExtendedBinary() diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 88a1e3f5..883feae8 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -164,33 +164,6 @@ func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { return b, nil } -func (f *OutputState) marshalLegacyExtendedBinary() ([]byte, error) { - return []byte{ - f.RumbleSmall, - f.RumbleLarge, - f.LedRed, - f.LedGreen, - f.LedBlue, - f.PlayerLeds, - f.TriggerR2Mode, - f.TriggerR2StartResistance, - f.TriggerR2EffectForce, - f.TriggerR2RangeForce, - f.TriggerR2NearReleaseStrength, - f.TriggerR2NearMiddleStrength, - f.TriggerR2PressedStrength, - f.TriggerR2Frequency, - f.TriggerL2Mode, - f.TriggerL2StartResistance, - f.TriggerL2EffectForce, - f.TriggerL2RangeForce, - f.TriggerL2NearReleaseStrength, - f.TriggerL2NearMiddleStrength, - f.TriggerL2PressedStrength, - f.TriggerL2Frequency, - }, nil -} - func (f *OutputState) UnmarshalBinary(data []byte) error { if len(data) < OutputStateSize { return io.ErrUnexpectedEOF From a36338ab53cf33a6bc35da30048b16192f402b1a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 08:53:47 -0500 Subject: [PATCH 220/298] Allow DualSense active touch tracking zero --- device/dualsense/state.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 883feae8..976cd7b2 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -51,15 +51,9 @@ func (s *InputState) MarshalBinary() ([]byte, error) { 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)) From 3f73ccbe4c8640d23182d2cb43caf67c9bcf306d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 08:55:28 -0500 Subject: [PATCH 221/298] Document native DualSense feedback wire gaps --- device/dualsense/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 976cd7b2..73f5ce27 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -91,7 +91,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { } // 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 triggerR2Frequency:u8 triggerL2Mode:u8 triggerL2StartResistance:u8 triggerL2EffectForce:u8 triggerL2RangeForce:u8 triggerL2NearReleaseStrength:u8 triggerL2NearMiddleStrength:u8 triggerL2PressedStrength:u8 triggerL2Frequency:u8 +// 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 _:u8*2 triggerR2Frequency:u8 _:u8 triggerL2Mode:u8 triggerL2StartResistance:u8 triggerL2EffectForce:u8 triggerL2RangeForce:u8 triggerL2NearReleaseStrength:u8 triggerL2NearMiddleStrength:u8 triggerL2PressedStrength:u8 _:u8*2 triggerL2Frequency:u8 _:u8 type OutputState struct { RumbleSmall uint8 RumbleLarge uint8 From d81027662a8d6f2a5c028f7bc0d1a454def09737 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 08:57:19 -0500 Subject: [PATCH 222/298] Clarify DualSense compact feedback size --- device/dualsense/const.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 9be49d72..d497e5f0 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -43,6 +43,9 @@ const ( OutputReportSize = 64 InputStateSize = 33 OutputStateSize = 6 + // OutputStateExtSize is VIIPER's compact server-to-client feedback packet: + // 6 base bytes plus two 11-byte DualSense trigger effect blocks. + // It is not the full native USB output report size. OutputStateExtSize = 28 ) From 1312a945202a27ccfe844bbf5ac55cdb3da8bb4e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 09:01:23 -0500 Subject: [PATCH 223/298] Match DualSense USB output report size --- device/dualsense/const.go | 2 +- device/dualsense/descriptor.go | 2 +- device/dualsense/device_output_test.go | 17 +++++++++++++++++ docs/devices/dualsense.md | 21 ++++++++++++++++----- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index d497e5f0..98f4e4ed 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -40,7 +40,7 @@ const ( const ( InputReportSize = 64 - OutputReportSize = 64 + OutputReportSize = 48 InputStateSize = 33 OutputStateSize = 6 // OutputStateExtSize is VIIPER's compact server-to-client feedback packet: diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 9e46721b..e7d77e84 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -101,7 +101,7 @@ var defaultDescriptor = usb.Descriptor{ hid.ReportID{ID: ReportIDOutput}, hid.Usage{Usage: 0x23}, - hid.ReportCount{Count: 63}, + 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}, diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index fdcfa44a..94f21dcb 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -1,12 +1,29 @@ package dualsense import ( + "bytes" "context" + "encoding/hex" "testing" "github.com/Alia5/VIIPER/usbip" ) +func TestDualSenseUSBOutputReportDescriptorMatchesCapture(t *testing.T) { + report, err := defaultDescriptor.Interfaces[0].HID.ReportBytes() + if err != nil { + t.Fatalf("ReportBytes returned error: %v", err) + } + + capturedOutputReport, err := hex.DecodeString("85020923952f9102") + if err != nil { + t.Fatalf("DecodeString returned error: %v", err) + } + if !bytes.Contains(report, capturedOutputReport) { + t.Fatalf("USB output report descriptor does not match captured DualSense report count: % x", report) + } +} + func TestDualSenseOutputReportFromEndpoint(t *testing.T) { dev, err := New(nil) if err != nil { diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index 6d14519f..989aa955 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -58,11 +58,13 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. - 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 22-byte packets. - Bytes 0..5 preserve the original rumble/LED layout above. Bytes 6..13 - contain the raw R2 adaptive-trigger effect block copied from USB output - report 0x02. Bytes 14..21 contain the raw L2 adaptive-trigger effect - block. Each trigger block is: + - Extended `dualsenseext` / `dualsenseedgeext` streams send 28-byte + VIIPER feedback packets. This is not the full native HID output report. + 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 @@ -70,7 +72,16 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. - 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 VIIPER feedback stream stays compact because it only forwards + the fields DS4Windows needs to apply rumble, LED state, and trigger effects + back to a physical controller. See `/device/dualsense/state.go` for the `OutputState` wire definition. From 6eb1aaec45ecc08177f274d921363d54d5a0bde2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 14:09:44 -0500 Subject: [PATCH 224/298] Respect DualSense output report update flags --- device/dualsense/device.go | 57 ++++++++++++++++---------- device/dualsense/device_output_test.go | 38 +++++++++++++++++ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 693597b2..85681dbc 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -27,6 +27,7 @@ type DualSense struct { metaState *MetaState outputFunc func(OutputState) + outputState OutputState descriptor usb.Descriptor extendedFeedback bool @@ -267,7 +268,10 @@ func (d *DualSense) handleOutputReport(out []byte) bool { } logRawOutputReport(report) if d.outputFunc != nil { - d.outputFunc(parseOutputReport(report)) + d.mtx.Lock() + feedback := d.mergeOutputReport(report) + d.mtx.Unlock() + d.outputFunc(feedback) } return true } @@ -328,10 +332,14 @@ var featureGetHandlers = map[byte]func(*DualSense) []byte{ featureIDCommandResponse: (*DualSense).featureReportCommandResponse, } -func parseOutputReport(out []byte) OutputState { - feedback := OutputState{ - RumbleSmall: out[3], - RumbleLarge: out[4], +func (d *DualSense) mergeOutputReport(out []byte) OutputState { + feedback := d.outputState + if len(out) > 4 { + flag0 := out[1] + if flag0&0x03 != 0 { + feedback.RumbleSmall = out[3] + feedback.RumbleLarge = out[4] + } } if len(out) > 2 { flag1 := out[2] @@ -345,24 +353,29 @@ func parseOutputReport(out []byte) OutputState { } } if len(out) > 31 { - 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] - - 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] + 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 } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 94f21dcb..9d2c43f0 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -39,6 +39,7 @@ func TestDualSenseOutputReportFromEndpoint(t *testing.T) { report := make([]byte, OutputReportSize) report[0] = ReportIDOutput + report[1] = 0x0F report[3] = 0x22 report[4] = 0x88 report[11] = 0x21 @@ -82,6 +83,7 @@ func TestDualSenseOutputSetReportWithoutReportId(t *testing.T) { }) payload := make([]byte, OutputReportSize-1) + payload[0] = 0x03 payload[2] = 0x33 payload[3] = 0x99 @@ -100,6 +102,42 @@ func TestDualSenseOutputSetReportWithoutReportId(t *testing.T) { } } +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() From e03a35ddcfe3ff085617ba87ed3adf66d3769260 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 14:19:53 -0500 Subject: [PATCH 225/298] Match normal DualSense USB identity --- device/dualsense/descriptor.go | 75 +++++++++++++++++++++++++- device/dualsense/device.go | 7 +-- device/dualsense/device_output_test.go | 70 ++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index e7d77e84..2d252b40 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -121,7 +121,7 @@ var defaultDescriptor = usb.Descriptor{ 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: 52}, 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}, @@ -166,6 +166,77 @@ var defaultDescriptor = usb.Descriptor{ Strings: map[uint8]string{ 0: "\u0409", // LangID: en-US (0x0409) 1: "Sony Interactive Entertainment", - 2: "DualSense Wireless Controller", + 2: "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 + } + + if len(desc.Interfaces) > 0 && desc.Interfaces[0].HID != nil { + hidFunction := *desc.Interfaces[0].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[0].HID = &hidFunction + } + + desc.Device.IDProduct = DefaultPIDDS + if edge { + desc.Device.IDProduct = DefaultPIDDSEdge + desc.Strings[2] = "DualSense Edge Wireless Controller" + } + + 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 index 85681dbc..0e7b22e7 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -93,14 +93,9 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { } d := &DualSense{ - descriptor: defaultDescriptor, + descriptor: makeDescriptor(edge), metaState: metaState, } - d.descriptor.Device.IDProduct = DefaultPIDDS - if edge { - d.descriptor.Device.IDProduct = DefaultPIDDSEdge - d.descriptor.Strings[2] = "DualSense Edge Wireless Controller" - } if o != nil { if o.IDVendor != nil { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 9d2c43f0..a149a9b0 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -10,17 +10,79 @@ import ( ) func TestDualSenseUSBOutputReportDescriptorMatchesCapture(t *testing.T) { - report, err := defaultDescriptor.Interfaces[0].HID.ReportBytes() + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + report, err := dev.GetDescriptor().Interfaces[0].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 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] != "Wireless Controller" { + t.Fatalf("unexpected DualSense product string: %q", desc.Strings[2]) + } + + report, err := desc.Interfaces[0].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 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[0].HID.ReportBytes() if err != nil { t.Fatalf("ReportBytes returned error: %v", err) } - capturedOutputReport, err := hex.DecodeString("85020923952f9102") + edgeFeatureReport, err := hex.DecodeString("85600941953fb102") if err != nil { t.Fatalf("DecodeString returned error: %v", err) } - if !bytes.Contains(report, capturedOutputReport) { - t.Fatalf("USB output report descriptor does not match captured DualSense report count: % x", report) + if !bytes.Contains(report, edgeFeatureReport) { + t.Fatalf("Edge descriptor does not advertise feature report 0x60: % x", report) } } From 15b1bfc04745e5f6929391812b7e82b8443d535e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 15:01:42 -0500 Subject: [PATCH 226/298] Gofmt DualSense constants --- device/dualsense/const.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 98f4e4ed..8048a969 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -39,10 +39,11 @@ const ( ) const ( - InputReportSize = 64 - OutputReportSize = 48 - InputStateSize = 33 - OutputStateSize = 6 + InputReportSize = 64 + OutputReportSize = 48 + InputStateSize = 33 + OutputStateSize = 6 + // OutputStateExtSize is VIIPER's compact server-to-client feedback packet: // 6 base bytes plus two 11-byte DualSense trigger effect blocks. // It is not the full native USB output report size. From 772d1d59c8e93b62d4479ce98f704ecc4e21b913 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 20:43:16 -0500 Subject: [PATCH 227/298] Fix DualSense descriptor and generated output names --- device/dualsense/descriptor.go | 1 - device/dualsense/state.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 2d252b40..91ee90f8 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -47,7 +47,6 @@ var defaultDescriptor = usb.Descriptor{ 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}, diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 73f5ce27..728b9a51 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -91,7 +91,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { } // 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 _:u8*2 triggerR2Frequency:u8 _:u8 triggerL2Mode:u8 triggerL2StartResistance:u8 triggerL2EffectForce:u8 triggerL2RangeForce:u8 triggerL2NearReleaseStrength:u8 triggerL2NearMiddleStrength:u8 triggerL2PressedStrength:u8 _:u8*2 triggerL2Frequency:u8 _:u8 +// 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 type OutputState struct { RumbleSmall uint8 RumbleLarge uint8 From b67f3f1500d92f3fc0049cdf538f3db55c19dc4e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 20:44:27 -0500 Subject: [PATCH 228/298] Revert "Allow DualSense active touch tracking zero" This reverts commit a36338ab53cf33a6bc35da30048b16192f402b1a. --- device/dualsense/state.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 728b9a51..f773b4d1 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -51,9 +51,15 @@ func (s *InputState) MarshalBinary() ([]byte, error) { 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)) From 01859957ef1d849328ffb90f3f4a2c73be0f6d15 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 20:46:47 -0500 Subject: [PATCH 229/298] Align DualSense touch fallback docs and tests --- device/dualsense/device_output_test.go | 6 +++--- docs/devices/dualsense.md | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index a149a9b0..d6e14dda 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -243,7 +243,7 @@ func TestDualSenseTouchTrackingBytes(t *testing.T) { } } -func TestDualSenseTouchTrackingZeroIsActive(t *testing.T) { +func TestDualSenseTouchTrackingZeroUsesActiveFallback(t *testing.T) { state := &InputState{ Touch1Active: true, Touch1Tracking: 0, @@ -258,8 +258,8 @@ func TestDualSenseTouchTrackingZeroIsActive(t *testing.T) { t.Fatalf("UnmarshalBinary returned error: %v", err) } - if !decoded.Touch1Active || decoded.Touch1Tracking != 0 { - t.Fatalf("tracking zero should be active: active=%v tracking=%#x", decoded.Touch1Active, decoded.Touch1Tracking) + 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 diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index 989aa955..1e74fcd3 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -127,8 +127,10 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. 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. - - VIIPER clamps touch coordinates to the DualSense range: + 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** From 8a5894f6608328f2bee609ff83c25d2d1fdb0616 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 20:47:16 -0500 Subject: [PATCH 230/298] Gofmt DualSense touch state fields --- device/dualsense/state.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/device/dualsense/state.go b/device/dualsense/state.go index f773b4d1..a597fae0 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -17,12 +17,12 @@ type InputState struct { DPad uint8 L2, R2 uint8 - Touch1X, Touch1Y uint16 - Touch1Active bool - Touch1Tracking uint8 - Touch2X, Touch2Y uint16 - Touch2Active bool - Touch2Tracking uint8 + Touch1X, Touch1Y uint16 + Touch1Active bool + Touch1Tracking uint8 + Touch2X, Touch2Y uint16 + Touch2Active bool + Touch2Tracking uint8 GyroX, GyroY, GyroZ int16 AccelX, AccelY, AccelZ int16 From 3e2bf74be648e25204511e25a7196c48970b2168 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 20:56:44 -0500 Subject: [PATCH 231/298] Add DualSense host traffic diagnostics --- device/dualsense/device.go | 44 +++++ device/dualsense/diagnostics.go | 166 ++++++++++++++++++ internal/cmd/server.go | 3 + .../server/api/handler/dualsense_traffic.go | 69 ++++++++ 4 files changed, 282 insertions(+) create mode 100644 device/dualsense/diagnostics.go create mode 100644 internal/server/api/handler/dualsense_traffic.go diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 0e7b22e7..503fea4e 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -187,6 +187,9 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o } if dir == usbip.DirOut && ep == EndpointOut { + recordTrafficBytes("host->device", "interrupt-out", + out, + "summary", fmt.Sprintf("ep=%d", ep)) if d.handleOutputReport(out) { return nil } @@ -212,6 +215,14 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, 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 { @@ -220,6 +231,14 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, 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 } } @@ -230,6 +249,13 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, } 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] @@ -266,6 +292,11 @@ func (d *DualSense) handleOutputReport(out []byte) bool { d.mtx.Lock() 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)) d.outputFunc(feedback) } return true @@ -299,6 +330,19 @@ func logRawOutputReport(report []byte) { 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 diff --git a/device/dualsense/diagnostics.go b/device/dualsense/diagnostics.go new file mode 100644 index 00000000..5b54265a --- /dev/null +++ b/device/dualsense/diagnostics.go @@ -0,0 +1,166 @@ +package dualsense + +import ( + "encoding/hex" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" +) + +const trafficEventLimit = 768 + +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), + } + 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/internal/cmd/server.go b/internal/cmd/server.go index 4be3ff5c..0d445f19 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -102,6 +102,9 @@ func (s *Server) StartServer(ctx context.Context, logger *slog.Logger, rawLogger 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 { diff --git a/internal/server/api/handler/dualsense_traffic.go b/internal/server/api/handler/dualsense_traffic.go new file mode 100644 index 00000000..7cf3bb0e --- /dev/null +++ b/internal/server/api/handler/dualsense_traffic.go @@ -0,0 +1,69 @@ +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" +) + +type dualSenseTrafficSetRequest struct { + Enabled bool `json:"enabled"` + Clear bool `json:"clear"` +} + +type dualSenseTrafficResponse struct { + Enabled bool `json:"enabled"` + Count int `json:"count"` + Events []dualsense.TrafficEvent `json:"events,omitempty"` +} + +func DualSenseTrafficSet() api.HandlerFunc { + return func(req *api.Request, res *api.Response, logger *slog.Logger) error { + var payload 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) + return writeDualSenseTrafficResponse(res, false) + } +} + +func DualSenseTrafficGet() api.HandlerFunc { + return func(_ *api.Request, res *api.Response, _ *slog.Logger) error { + return writeDualSenseTrafficResponse(res, true) + } +} + +func DualSenseTrafficClear() api.HandlerFunc { + return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { + dualsense.ClearTrafficDiagnostics() + logger.Info("DualSense traffic diagnostics cleared") + return writeDualSenseTrafficResponse(res, false) + } +} + +func writeDualSenseTrafficResponse(res *api.Response, includeEvents bool) error { + events := dualsense.TrafficDiagnosticsSnapshot() + response := dualSenseTrafficResponse{ + Enabled: dualsense.TrafficDiagnosticsEnabled(), + Count: len(events), + } + if includeEvents { + response.Events = events + } + + out, err := json.Marshal(response) + if err != nil { + return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) + } + res.JSON = string(out) + return nil +} From 85e5887adf392f22e47ad42eab44d3c9fdfc5ba6 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 21:11:34 -0500 Subject: [PATCH 232/298] Fix DualSense traffic client codegen --- cmd/viiper/startup_windows.go | 2 +- .../server/api/handler/dualsense_traffic.go | 80 ++++++++++++------- viipertypes/structs.go | 26 ++++++ 3 files changed, 78 insertions(+), 30 deletions(-) diff --git a/cmd/viiper/startup_windows.go b/cmd/viiper/startup_windows.go index 08781a36..ad23c22f 100644 --- a/cmd/viiper/startup_windows.go +++ b/cmd/viiper/startup_windows.go @@ -12,7 +12,7 @@ import ( func init() { if util.IsRunFromGUI() { args := os.Args - if len(args) < 2 || args[1] != "server" { + 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) diff --git a/internal/server/api/handler/dualsense_traffic.go b/internal/server/api/handler/dualsense_traffic.go index 7cf3bb0e..6d126527 100644 --- a/internal/server/api/handler/dualsense_traffic.go +++ b/internal/server/api/handler/dualsense_traffic.go @@ -8,22 +8,12 @@ import ( "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" ) -type dualSenseTrafficSetRequest struct { - Enabled bool `json:"enabled"` - Clear bool `json:"clear"` -} - -type dualSenseTrafficResponse struct { - Enabled bool `json:"enabled"` - Count int `json:"count"` - Events []dualsense.TrafficEvent `json:"events,omitempty"` -} - func DualSenseTrafficSet() api.HandlerFunc { return func(req *api.Request, res *api.Response, logger *slog.Logger) error { - var payload dualSenseTrafficSetRequest + 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)) @@ -32,13 +22,32 @@ func DualSenseTrafficSet() api.HandlerFunc { dualsense.SetTrafficDiagnosticsEnabled(payload.Enabled, payload.Clear) logger.Info("DualSense traffic diagnostics set", "enabled", payload.Enabled, "clear", payload.Clear) - return writeDualSenseTrafficResponse(res, false) + 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 { - return writeDualSenseTrafficResponse(res, true) + 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 } } @@ -46,24 +55,37 @@ func DualSenseTrafficClear() api.HandlerFunc { return func(_ *api.Request, res *api.Response, logger *slog.Logger) error { dualsense.ClearTrafficDiagnostics() logger.Info("DualSense traffic diagnostics cleared") - return writeDualSenseTrafficResponse(res, false) + 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 writeDualSenseTrafficResponse(res *api.Response, includeEvents bool) error { - events := dualsense.TrafficDiagnosticsSnapshot() - response := dualSenseTrafficResponse{ - Enabled: dualsense.TrafficDiagnosticsEnabled(), - Count: len(events), - } - if includeEvents { - response.Events = events +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, + } } - out, err := json.Marshal(response) - if err != nil { - return apierror.ErrInternal(fmt.Sprintf("failed to marshal response: %v", err)) - } - res.JSON = string(out) - return nil + return out } diff --git a/viipertypes/structs.go b/viipertypes/structs.go index 6269e5f4..d6decafd 100644 --- a/viipertypes/structs.go +++ b/viipertypes/structs.go @@ -74,6 +74,32 @@ type DeviceCreateRequest struct { 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 { From 079f61cf628f090218b65634a142cdd4a7e0cb72 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 21:16:16 -0500 Subject: [PATCH 233/298] Preserve exported DTO names in codegen --- internal/codegen/common/naming.go | 19 +++++++++++++++++++ internal/codegen/generator/cpp/helpers.go | 8 ++++---- .../generator/csharp/device_specific.go | 3 ++- internal/codegen/generator/csharp/helpers.go | 2 +- internal/codegen/generator/csharp/types.go | 3 ++- internal/codegen/generator/rust/helpers.go | 2 +- .../generator/typescript/device_specific.go | 2 +- .../codegen/generator/typescript/helpers.go | 2 +- .../codegen/generator/typescript/types.go | 2 +- 9 files changed, 32 insertions(+), 11 deletions(-) diff --git a/internal/codegen/common/naming.go b/internal/codegen/common/naming.go index 4437ebf7..aa371f7d 100644 --- a/internal/codegen/common/naming.go +++ b/internal/codegen/common/naming.go @@ -27,6 +27,25 @@ func ToPascalCase(s string) string { 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 { diff --git a/internal/codegen/generator/cpp/helpers.go b/internal/codegen/generator/cpp/helpers.go index 52276977..e7da2e34 100644 --- a/internal/codegen/generator/cpp/helpers.go +++ b/internal/codegen/generator/cpp/helpers.go @@ -13,7 +13,7 @@ import ( func tplFuncs(md *meta.Metadata) template.FuncMap { return template.FuncMap{ - "pascalcase": common.ToPascalCase, + "pascalcase": common.ToTypeName, "camelcase": common.ToCamelCase, "snakecase": common.ToSnakeCase, "toScreamingSnakeCase": func(s string) string { return strings.ToUpper(common.ToSnakeCase(s)) }, @@ -71,7 +71,7 @@ func tplFuncs(md *meta.Metadata) template.FuncMap { "pathParamType": pathParamType, "formatPathParamValue": formatPathParamValue, "payloadCppType": func(pi scanner.PayloadInfo) string { return payloadCppType(pi) }, - "responseCppType": func(name string) string { return common.ToPascalCase(name) }, + "responseCppType": func(name string) string { return common.ToTypeName(name) }, "isByteKeyMap": isByteKeyMap, "hasCharLiteralKeys": hasCharLiteralKeys, "isNumericMapVal": func(vt string) bool { return vt != "string" && vt != "bool" }, @@ -143,7 +143,7 @@ func goBaseToCpp(base string) string { case "string": return "std::string" default: - return common.ToPascalCase(base) + return common.ToTypeName(base) } } @@ -171,7 +171,7 @@ func payloadCppType(pi scanner.PayloadInfo) string { switch pi.Kind { case scanner.PayloadJSON: if pi.RawType != "" { - return "const " + common.ToPascalCase(pi.RawType) + "&" + return "const " + common.ToTypeName(pi.RawType) + "&" } return "const std::string&" case scanner.PayloadNumeric: diff --git a/internal/codegen/generator/csharp/device_specific.go b/internal/codegen/generator/csharp/device_specific.go index 8317a667..5870f1c5 100644 --- a/internal/codegen/generator/csharp/device_specific.go +++ b/internal/codegen/generator/csharp/device_specific.go @@ -8,6 +8,7 @@ import ( "strings" "text/template" + "github.com/Alia5/VIIPER/internal/codegen/common" "github.com/Alia5/VIIPER/internal/codegen/meta" "github.com/Alia5/VIIPER/internal/codegen/scanner" ) @@ -138,7 +139,7 @@ func fieldTypeToCSharpForDeviceSpecific(field scanner.FieldInfo) string { } if typeKind == "struct" { - return toPascalCase(typeStr) + return common.ToTypeName(typeStr) } return goTypeToCSharp(typeStr) diff --git a/internal/codegen/generator/csharp/helpers.go b/internal/codegen/generator/csharp/helpers.go index 0353ba68..168b2171 100644 --- a/internal/codegen/generator/csharp/helpers.go +++ b/internal/codegen/generator/csharp/helpers.go @@ -48,7 +48,7 @@ func goTypeToCSharp(goType string) string { case "byte": return "byte" default: - return toPascalCase(base) + return common.ToTypeName(base) } } diff --git a/internal/codegen/generator/csharp/types.go b/internal/codegen/generator/csharp/types.go index c2719944..5d42fabf 100644 --- a/internal/codegen/generator/csharp/types.go +++ b/internal/codegen/generator/csharp/types.go @@ -9,6 +9,7 @@ import ( "strings" "text/template" + "github.com/Alia5/VIIPER/internal/codegen/common" "github.com/Alia5/VIIPER/internal/codegen/meta" ) @@ -92,7 +93,7 @@ func fieldTypeToCSharp(field interface{}) string { } if typeKind == "struct" { - return toPascalCase(typeStr) + return common.ToTypeName(typeStr) } return goTypeToCSharp(typeStr) diff --git a/internal/codegen/generator/rust/helpers.go b/internal/codegen/generator/rust/helpers.go index 4aa4d89d..a82dd304 100644 --- a/internal/codegen/generator/rust/helpers.go +++ b/internal/codegen/generator/rust/helpers.go @@ -41,7 +41,7 @@ func goTypeToRust(goType string) string { case "float64": rustType = "f64" default: - rustType = common.ToPascalCase(base) + rustType = common.ToTypeName(base) } if isSlice { diff --git a/internal/codegen/generator/typescript/device_specific.go b/internal/codegen/generator/typescript/device_specific.go index 243a7f62..3ad08b7d 100644 --- a/internal/codegen/generator/typescript/device_specific.go +++ b/internal/codegen/generator/typescript/device_specific.go @@ -139,7 +139,7 @@ func fieldTypeToTSForDeviceSpecific(field scanner.FieldInfo) string { } if typeKind == "struct" { - return common.ToPascalCase(typeStr) + return common.ToTypeName(typeStr) } return goTypeToTS(typeStr) diff --git a/internal/codegen/generator/typescript/helpers.go b/internal/codegen/generator/typescript/helpers.go index 0ce9ccf6..9ec0909c 100644 --- a/internal/codegen/generator/typescript/helpers.go +++ b/internal/codegen/generator/typescript/helpers.go @@ -18,7 +18,7 @@ func goTypeToTS(goType string) string { case "any", "interface{}": return "unknown" default: - return common.ToPascalCase(base) + return common.ToTypeName(base) } } diff --git a/internal/codegen/generator/typescript/types.go b/internal/codegen/generator/typescript/types.go index 651eb92a..837ad46c 100644 --- a/internal/codegen/generator/typescript/types.go +++ b/internal/codegen/generator/typescript/types.go @@ -70,7 +70,7 @@ func fieldTypeToTS(field interface{}) string { return goTypeToTS(elem) + "[]" } if typeKind == "struct" { - return common.ToPascalCase(typeStr) + return common.ToTypeName(typeStr) } return goTypeToTS(typeStr) } From 17587a400c4c25ab6cd7f9c26241810f2e3c04ac Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 21:20:01 -0500 Subject: [PATCH 234/298] Fix C++ DTO codegen for custom arrays --- internal/codegen/generator/cpp/client.go | 2 +- internal/codegen/generator/cpp/types.go | 30 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/codegen/generator/cpp/client.go b/internal/codegen/generator/cpp/client.go index 298c23fa..4a993208 100644 --- a/internal/codegen/generator/cpp/client.go +++ b/internal/codegen/generator/cpp/client.go @@ -97,7 +97,7 @@ public: /// Create a device and connect to its stream in one step [[nodiscard]] Result>> addDeviceAndConnect( std::uint32_t bus_id, - const Devicecreaterequest& request + const DeviceCreateRequest& request ) { auto device_result = busdeviceadd(bus_id, request); if (device_result.is_error()) return device_result.error(); diff --git a/internal/codegen/generator/cpp/types.go b/internal/codegen/generator/cpp/types.go index a8039033..ccbdfaf3 100644 --- a/internal/codegen/generator/cpp/types.go +++ b/internal/codegen/generator/cpp/types.go @@ -47,6 +47,18 @@ struct {{pascalcase .Name}} { } 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"}} @@ -69,9 +81,25 @@ struct {{pascalcase .Name}} { [[nodiscard]] json_type to_json() const { json_type j; {{- range .Fields}} -{{- if .Optional}} +{{- 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"}} { From 38759b0cab30772d198fd753f6944f848dc6d2c3 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 13 Jun 2026 21:41:52 -0500 Subject: [PATCH 235/298] Preserve DualSense native output reports --- device/dualsense/const.go | 11 ++++--- device/dualsense/device.go | 10 +++++- device/dualsense/device_output_test.go | 42 ++++++++++++++++++++++++++ device/dualsense/state.go | 10 ++++-- docs/devices/dualsense.md | 14 ++++++--- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 8048a969..6366b750 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -44,10 +44,13 @@ const ( InputStateSize = 33 OutputStateSize = 6 - // OutputStateExtSize is VIIPER's compact server-to-client feedback packet: - // 6 base bytes plus two 11-byte DualSense trigger effect blocks. - // It is not the full native USB output report size. - OutputStateExtSize = 28 + // 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 so + // clients can forward DualSense haptics/control flags without reducing + // them to generic rumble. + OutputStateCompatExtSize = 28 + OutputStateExtSize = OutputStateCompatExtSize + OutputReportSize ) const ( diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 503fea4e..797a6c76 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -373,9 +373,17 @@ var featureGetHandlers = map[byte]func(*DualSense) []byte{ func (d *DualSense) mergeOutputReport(out []byte) OutputState { feedback := d.outputState + if len(out) >= OutputReportSize { + copy(feedback.RawOutputReport[:], out[:OutputReportSize]) + } + if len(out) > 4 { flag0 := out[1] - if flag0&0x03 != 0 { + compatibleVibration := flag0&0x01 != 0 + if len(out) > 39 { + compatibleVibration = compatibleVibration || out[39]&0x04 != 0 + } + if compatibleVibration { feedback.RumbleSmall = out[3] feedback.RumbleLarge = out[4] } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index d6e14dda..6864d39c 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -129,6 +129,42 @@ func TestDualSenseOutputReportFromEndpoint(t *testing.T) { 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 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) { @@ -285,6 +321,9 @@ func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { TriggerL2PressedStrength: 0x77, TriggerL2Frequency: 0x88, } + out.RawOutputReport[0] = ReportIDOutput + out.RawOutputReport[1] = 0x02 + out.RawOutputReport[3] = 0x99 data, err := out.MarshalExtendedBinary() if err != nil { @@ -308,4 +347,7 @@ func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { 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) + } } diff --git a/device/dualsense/state.go b/device/dualsense/state.go index a597fae0..a80fef28 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -97,7 +97,7 @@ func (s *InputState) UnmarshalBinary(data []byte) error { } // 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 +// 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 @@ -122,6 +122,8 @@ type OutputState struct { TriggerL2NearMiddleStrength uint8 TriggerL2PressedStrength uint8 TriggerL2Frequency uint8 + + RawOutputReport [OutputReportSize]byte } func (f *OutputState) MarshalBinary() ([]byte, error) { @@ -161,6 +163,7 @@ func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { b[22] = f.TriggerL2NearMiddleStrength b[23] = f.TriggerL2PressedStrength b[26] = f.TriggerL2Frequency + copy(b[OutputStateCompatExtSize:], f.RawOutputReport[:]) return b, nil } @@ -174,7 +177,7 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.LedGreen = data[3] f.LedBlue = data[4] f.PlayerLeds = data[5] - if len(data) < OutputStateExtSize { + if len(data) < OutputStateCompatExtSize { return nil } @@ -194,6 +197,9 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.TriggerL2NearMiddleStrength = data[22] f.TriggerL2PressedStrength = data[23] f.TriggerL2Frequency = data[26] + if len(data) >= OutputStateExtSize { + copy(f.RawOutputReport[:], data[OutputStateCompatExtSize:OutputStateExtSize]) + } return nil } diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index 1e74fcd3..d29de312 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -58,8 +58,12 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. - 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 28-byte - VIIPER feedback packets. This is not the full native HID output report. + - Extended `dualsenseext` / `dualsenseedgeext` streams send 76-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 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. @@ -79,9 +83,9 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. 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 VIIPER feedback stream stays compact because it only forwards - the fields DS4Windows needs to apply rumble, LED state, and trigger effects - back to a physical controller. + 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. From 8a2aa5745ba98ea6b3cc8d0d079c7e5808d14dc1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 09:27:25 -0500 Subject: [PATCH 236/298] Fix Rust codegen defaults for large arrays --- .../codegen/generator/rust/device_types.go | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/codegen/generator/rust/device_types.go b/internal/codegen/generator/rust/device_types.go index 0d70a7a2..1be95815 100644 --- a/internal/codegen/generator/rust/device_types.go +++ b/internal/codegen/generator/rust/device_types.go @@ -17,11 +17,19 @@ import ( const deviceInputTemplate = `{{.Header}} use crate::wire::DeviceInput; -#[derive(Debug, Clone, Default)] +#[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(); @@ -38,11 +46,19 @@ impl DeviceInput for {{.StructName}} { const deviceOutputTemplate = `{{.Header}} use crate::wire::DeviceOutput; -#[derive(Debug, Clone, Default)] +#[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; @@ -182,7 +198,9 @@ func generateDeviceWireStruct(outputPath, deviceName, className string, tag *sca Fields: fields, } - funcMap := template.FuncMap{} + funcMap := template.FuncMap{ + "defaultExpr": rustDefaultExpr, + } tmpl, err := template.New("devicewire").Funcs(funcMap).Parse(tmplStr) if err != nil { return fmt.Errorf("parse template: %w", err) @@ -200,3 +218,15 @@ func generateDeviceWireStruct(outputPath, deviceName, className string, tag *sca 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) +} From 66a7414faf3a6ae52db66b55010afafb7931d3a5 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 10:27:30 -0500 Subject: [PATCH 237/298] Add DualSense Bluetooth haptics mapping notes --- device/dualsense/bthaptics.go | 90 ++++++++++++++ device/dualsense/bthaptics_test.go | 103 ++++++++++++++++ device/dualsense/diagnostics.go | 2 +- docs/research/dualsense-bluetooth-haptics.md | 123 +++++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 device/dualsense/bthaptics.go create mode 100644 device/dualsense/bthaptics_test.go create mode 100644 docs/research/dualsense-bluetooth-haptics.md diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go new file mode 100644 index 00000000..7c2f7acc --- /dev/null +++ b/device/dualsense/bthaptics.go @@ -0,0 +1,90 @@ +package dualsense + +import ( + "encoding/binary" + "errors" +) + +const ( + BluetoothHapticsReportID = 0x32 + BluetoothHapticsReportSize = 141 + BluetoothHapticsSampleSize = 64 + BluetoothHapticsSampleRate = 3000 + + 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") + +// 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 +} + +// 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..3ce49270 --- /dev/null +++ b/device/dualsense/bthaptics_test.go @@ -0,0 +1,103 @@ +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 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/diagnostics.go b/device/dualsense/diagnostics.go index 5b54265a..1dffa42b 100644 --- a/device/dualsense/diagnostics.go +++ b/device/dualsense/diagnostics.go @@ -9,7 +9,7 @@ import ( "time" ) -const trafficEventLimit = 768 +const trafficEventLimit = 65536 type TrafficEvent struct { TimeUTC string `json:"timeUtc"` diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md new file mode 100644 index 00000000..a9b94d52 --- /dev/null +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -0,0 +1,123 @@ +# 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. + +Credit: SAxense research by egormanga/Sdore should be credited anywhere this +Bluetooth haptics packet path is surfaced to users or shipped. From 716fb00cf32b41536f26cea88a8befe0f100c64b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 10:40:52 -0500 Subject: [PATCH 238/298] Document Ghost DualSense bow capture --- docs/research/dualsense-bluetooth-haptics.md | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index a9b94d52..5d74e582 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -119,5 +119,58 @@ cannot recover them. 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. + Credit: SAxense research by egormanga/Sdore should be credited anywhere this Bluetooth haptics packet path is surfaced to users or shipped. From cab8a24aa01992144f611c0cccc979b5d0560a15 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 10:56:50 -0500 Subject: [PATCH 239/298] Add experimental DualSense haptics audio bridge --- device/dualsense/const.go | 17 ++-- device/dualsense/descriptor.go | 90 ++++++++++++++++++ device/dualsense/device.go | 50 +++++++++- device/dualsense/device_output_test.go | 98 ++++++++++++++++++++ device/dualsense/state.go | 11 ++- docs/devices/dualsense.md | 7 +- docs/research/dualsense-bluetooth-haptics.md | 47 ++++++++++ 7 files changed, 306 insertions(+), 14 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 6366b750..d1e25a6c 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -29,8 +29,9 @@ const ( var DefaultBuildTime = time.Date(2025, time.July, 4, 10, 10, 32, 0, time.UTC) const ( - EndpointIn = 0x84 - EndpointOut = 0x03 + EndpointIn = 0x84 + EndpointOut = 0x03 + EndpointHapticsAudioOut = 0x05 ) const ( @@ -46,11 +47,13 @@ const ( // 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 so - // clients can forward DualSense haptics/control flags without reducing - // them to generic rumble. - OutputStateCompatExtSize = 28 - OutputStateExtSize = OutputStateCompatExtSize + OutputReportSize + // 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 ) const ( diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 91ee90f8..371c56c0 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -21,6 +21,16 @@ var defaultDescriptor = usb.Descriptor{ BNumConfigurations: 0x01, Speed: 2, // Full speed }, + Associations: []usb.InterfaceAssociationDescriptor{ + { + BFirstInterface: 0x01, + BInterfaceCount: 0x02, + BFunctionClass: 0x01, // Audio + BFunctionSubClass: 0x01, // AudioControl + BFunctionProtocol: 0x00, + IFunction: 0x00, + }, + }, Interfaces: []usb.InterfaceConfig{ { Descriptor: usb.InterfaceDescriptor{ @@ -161,6 +171,86 @@ var defaultDescriptor = usb.Descriptor{ }, }, }, + { + 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, 0x1F, 0x00, 0x01, 0x02}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Input Terminal: USB streaming source, 2 channels. + Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00}, + }, + { + DescriptorType: 0x24, // CS_INTERFACE + // Output Terminal: speaker/haptics sink, source terminal 1. + Payload: usb.Data{0x03, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00, 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: stereo, 1-byte subframe, 8-bit samples, + // one discrete sample rate = 3000 Hz. This mirrors the + // SAxense haptics PCM contract. + Payload: usb.Data{0x02, 0x01, 0x02, 0x01, 0x08, 0x01, 0xB8, 0x0B, 0x00}, + }, + }, + Endpoints: []usb.EndpointDescriptor{ + { + BEndpointAddress: EndpointHapticsAudioOut, + BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. + WMaxPacketSize: BluetoothHapticsSampleSize, + BInterval: 1, + 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) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 797a6c76..cfe09fa2 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -33,8 +33,10 @@ type DualSense struct { subcommand [2]byte - seqCounter uint8 - timestampBase time.Time + seqCounter uint8 + hapticsSeq uint8 + hapticsInterval uint8 + timestampBase time.Time mtx sync.Mutex } @@ -194,10 +196,53 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o return nil } } + if dir == usbip.DirOut && ep == EndpointHapticsAudioOut { + d.handleHapticsAudioOut(out) + return nil + } return nil } +func (d *DualSense) handleHapticsAudioOut(out []byte) { + if len(out) == 0 { + return + } + + recordTrafficBytes("host->device", "audio-haptics-out", + out, + "summary", fmt.Sprintf("ep=%d bytes=%d", EndpointHapticsAudioOut, len(out))) + + for offset := 0; offset+BluetoothHapticsSampleSize <= len(out); offset += BluetoothHapticsSampleSize { + d.mtx.Lock() + seq := d.hapticsSeq + interval := d.hapticsInterval + d.hapticsSeq++ + d.hapticsInterval++ + d.mtx.Unlock() + + report, err := BuildBluetoothHapticsReport(seq, interval, out[offset:offset+BluetoothHapticsSampleSize]) + if err != nil { + slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err) + continue + } + + recordTrafficBytes("device->physical", "saxense-hid-0x32", + report, + "reportType", "output", + "reportID", fmt.Sprintf("0x%02X", report[0]), + "summary", fmt.Sprintf("from audio ep=%d offset=%d bytes=%d", EndpointHapticsAudioOut, offset, BluetoothHapticsSampleSize)) + + if d.outputFunc != nil { + d.mtx.Lock() + feedback := d.outputState + copy(feedback.BluetoothHapticsOutputReport[:], report) + d.mtx.Unlock() + d.outputFunc(feedback) + } + } +} + func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { reportType := uint8(wValue >> 8) reportID := uint8(wValue & 0xFF) @@ -373,6 +418,7 @@ var featureGetHandlers = map[byte]func(*DualSense) []byte{ func (d *DualSense) mergeOutputReport(out []byte) OutputState { feedback := d.outputState + clear(feedback.BluetoothHapticsOutputReport[:]) if len(out) >= OutputReportSize { copy(feedback.RawOutputReport[:], out[:OutputReportSize]) } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 6864d39c..b6b94a7d 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -58,6 +58,50 @@ func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { } } +func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + desc := dev.GetDescriptor() + if desc.NumInterfaces() != 3 { + t.Fatalf("unexpected interface count: got %d want 3", desc.NumInterfaces()) + } + if len(desc.Associations) != 1 { + t.Fatalf("unexpected interface association count: got %d want 1", len(desc.Associations)) + } + audioIAD := desc.Associations[0] + if audioIAD.BFirstInterface != 1 || audioIAD.BInterfaceCount != 2 || + audioIAD.BFunctionClass != 0x01 || audioIAD.BFunctionSubClass != 0x01 { + t.Fatalf("unexpected audio IAD: %#v", audioIAD) + } + + var foundAlt bool + var foundEndpoint bool + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber == 2 && + 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 == BluetoothHapticsSampleSize { + 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") + } +} + func TestDualSenseEdgeDescriptorAdvertisesEdgeFeatureReports(t *testing.T) { dev, err := NewEdge(nil) if err != nil { @@ -134,6 +178,60 @@ func TestDualSenseOutputReportFromEndpoint(t *testing.T) { } } +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 + }) + + SetTrafficDiagnosticsEnabled(true, true) + defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true) + + sample := make([]byte, BluetoothHapticsSampleSize) + for i := range sample { + sample[i] = byte(i) + } + + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, sample) + events := TrafficDiagnosticsSnapshot() + + var sawAudioOut bool + var sawSAxense bool + for _, event := range events { + switch event.Source { + case "audio-haptics-out": + sawAudioOut = true + if event.Length != BluetoothHapticsSampleSize { + t.Fatalf("unexpected audio event length: got %d want %d", event.Length, BluetoothHapticsSampleSize) + } + 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 TestDualSenseHapticsSelectDoesNotUpdateCompatibleRumble(t *testing.T) { dev, err := New(nil) if err != nil { diff --git a/device/dualsense/state.go b/device/dualsense/state.go index a80fef28..1c63efca 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -123,7 +123,8 @@ type OutputState struct { TriggerL2PressedStrength uint8 TriggerL2Frequency uint8 - RawOutputReport [OutputReportSize]byte + RawOutputReport [OutputReportSize]byte + BluetoothHapticsOutputReport [BluetoothHapticsReportSize]byte } func (f *OutputState) MarshalBinary() ([]byte, error) { @@ -163,7 +164,8 @@ func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { b[22] = f.TriggerL2NearMiddleStrength b[23] = f.TriggerL2PressedStrength b[26] = f.TriggerL2Frequency - copy(b[OutputStateCompatExtSize:], f.RawOutputReport[:]) + copy(b[OutputStateRawReportOffset:], f.RawOutputReport[:]) + copy(b[OutputStateBluetoothHapticsOffset:], f.BluetoothHapticsOutputReport[:]) return b, nil } @@ -198,7 +200,10 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.TriggerL2PressedStrength = data[23] f.TriggerL2Frequency = data[26] if len(data) >= OutputStateExtSize { - copy(f.RawOutputReport[:], data[OutputStateCompatExtSize: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 } diff --git a/docs/devices/dualsense.md b/docs/devices/dualsense.md index d29de312..e20c5ff6 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -58,12 +58,15 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. - 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 76-byte + - 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. + 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. diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index 5d74e582..3a27486d 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -172,5 +172,52 @@ Interpretation: 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: stereo, 8-bit, 3000 Hz + +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`. + Credit: SAxense research by egormanga/Sdore should be credited anywhere this Bluetooth haptics packet path is surfaced to users or shipped. From d6c3e37f56f97cda3f360ecfe30dece7cd63124f Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 14:33:55 -0500 Subject: [PATCH 240/298] Fix DualSense audio interface startup --- device/dualsense/descriptor.go | 4 ++-- device/dualsense/device_output_test.go | 14 ++++++++++++++ docs/research/dualsense-bluetooth-haptics.md | 7 +++++++ internal/server/usb/server.go | 19 ++++++++++++++----- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 371c56c0..a9d17f9f 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -186,7 +186,7 @@ var defaultDescriptor = usb.Descriptor{ 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, 0x1F, 0x00, 0x01, 0x02}, + Payload: usb.Data{0x01, 0x00, 0x01, 0x1E, 0x00, 0x01, 0x02}, }, { DescriptorType: 0x24, // CS_INTERFACE @@ -196,7 +196,7 @@ var defaultDescriptor = usb.Descriptor{ { DescriptorType: 0x24, // CS_INTERFACE // Output Terminal: speaker/haptics sink, source terminal 1. - Payload: usb.Data{0x03, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00}, + Payload: usb.Data{0x03, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00}, }, }, }, diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index b6b94a7d..9bb7c6d2 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -100,6 +100,20 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin if !foundEndpoint { t.Fatal("experimental haptics audio OUT endpoint was not found") } + + var audioControlClassLength int + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != 1 || iface.Descriptor.BAlternateSetting != 0 { + continue + } + + for _, classDescriptor := range iface.ClassDescriptors { + audioControlClassLength += len(classDescriptor.Bytes()) + } + } + if audioControlClassLength != 0x1E { + t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x1e", audioControlClassLength) + } } func TestDualSenseEdgeDescriptorAdvertisesEdgeFeatureReports(t *testing.T) { diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index 3a27486d..8aa7750f 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -219,5 +219,12 @@ 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 first fix corrected the UAC1 AudioControl header total length to `0x001E`, +removed an extra byte from the Output Terminal descriptor, and added generic +standard `GET_INTERFACE` / `SET_INTERFACE` handling so the audio streaming +interface can switch from alternate setting 0 to alternate setting 1. + Credit: SAxense research by egormanga/Sdore should be credited anywhere this Bluetooth haptics packet path is surfaced to users or shipped. diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index ba6c08da..d38f3b53 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -127,6 +127,8 @@ const ( usbReqSetDescriptor = 0x07 usbReqGetConfiguration = 0x08 usbReqSetConfiguration = 0x09 + usbReqGetInterface = 0x0A + usbReqSetInterface = 0x0B // USB descriptor types usbDescTypeDevice = 0x01 @@ -136,11 +138,12 @@ const ( usbDescTypeHIDReport = 0x22 // USB request types (bmRequestType) - usbReqTypeStandardToDevice = 0x00 - usbReqTypeStandardToInterface = 0x81 - usbReqTypeStandardFromDevice = 0x80 - usbReqTypeMask = 0x60 - usbReqTypeClass = 0x20 + usbReqTypeStandardToDevice = 0x00 + usbReqTypeStandardFromInterface = 0x01 + usbReqTypeStandardToInterface = 0x81 + usbReqTypeStandardFromDevice = 0x80 + usbReqTypeMask = 0x60 + usbReqTypeClass = 0x20 // USB interface classes usbInterfaceClassHID = 0x03 @@ -865,6 +868,12 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d if breq == usbReqGetConfiguration && bm == usbReqTypeStandardFromDevice { return []byte{0x01} } + if breq == usbReqGetInterface && bm == usbReqTypeStandardToInterface { + return []byte{0x00} + } + if breq == usbReqSetInterface && bm == usbReqTypeStandardFromInterface { + return nil + } desc := dev.GetDescriptor() From 2d233dfaac43b84d7083d06292ce0adc5437152e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 14:58:23 -0500 Subject: [PATCH 241/298] Fix DualSense haptics audio endpoint format --- device/dualsense/bthaptics.go | 8 +++ device/dualsense/descriptor.go | 16 +++--- device/dualsense/device.go | 73 ++++++++++++++++++++++---- device/dualsense/device_output_test.go | 42 ++++++++++++--- internal/server/usb/descriptor_test.go | 46 ++++++++++++++++ internal/server/usb/server.go | 52 +++++++++++++++++- 6 files changed, 211 insertions(+), 26 deletions(-) diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go index 7c2f7acc..891b68b8 100644 --- a/device/dualsense/bthaptics.go +++ b/device/dualsense/bthaptics.go @@ -11,6 +11,14 @@ const ( BluetoothHapticsSampleSize = 64 BluetoothHapticsSampleRate = 3000 + USBHapticsAudioSampleRate = 48000 + USBHapticsAudioChannels = 4 + USBHapticsAudioBytesPerSample = 2 + USBHapticsAudioFrameSize = USBHapticsAudioChannels * USBHapticsAudioBytesPerSample + USBHapticsAudioPacketFrames = USBHapticsAudioSampleRate / 1000 + USBHapticsAudioPacketSize = USBHapticsAudioPacketFrames * USBHapticsAudioFrameSize + USBHapticsAudioDownsample = USBHapticsAudioSampleRate / BluetoothHapticsSampleRate + BluetoothOutputReportID = 0x31 BluetoothOutputReportSize = 78 diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index a9d17f9f..0c267085 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -190,8 +190,8 @@ var defaultDescriptor = usb.Descriptor{ }, { DescriptorType: 0x24, // CS_INTERFACE - // Input Terminal: USB streaming source, 2 channels. - Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00}, + // Input Terminal: USB streaming source, 4 channels. + Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x00, USBHapticsAudioChannels, 0x0F, 0x00, 0x00, 0x00}, }, { DescriptorType: 0x24, // CS_INTERFACE @@ -229,17 +229,19 @@ var defaultDescriptor = usb.Descriptor{ }, { DescriptorType: 0x24, // CS_INTERFACE - // Format Type I: stereo, 1-byte subframe, 8-bit samples, - // one discrete sample rate = 3000 Hz. This mirrors the - // SAxense haptics PCM contract. - Payload: usb.Data{0x02, 0x01, 0x02, 0x01, 0x08, 0x01, 0xB8, 0x0B, 0x00}, + // 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: BluetoothHapticsSampleSize, + WMaxPacketSize: USBHapticsAudioPacketSize, BInterval: 1, ClassDescriptors: []usb.ClassSpecificDescriptor{ { diff --git a/device/dualsense/device.go b/device/dualsense/device.go index cfe09fa2..1e71703e 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -36,6 +36,7 @@ type DualSense struct { seqCounter uint8 hapticsSeq uint8 hapticsInterval uint8 + hapticsPCM []byte timestampBase time.Time mtx sync.Mutex @@ -213,17 +214,13 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { out, "summary", fmt.Sprintf("ep=%d bytes=%d", EndpointHapticsAudioOut, len(out))) - for offset := 0; offset+BluetoothHapticsSampleSize <= len(out); offset += BluetoothHapticsSampleSize { - d.mtx.Lock() - seq := d.hapticsSeq - interval := d.hapticsInterval - d.hapticsSeq++ - d.hapticsInterval++ - d.mtx.Unlock() + d.mtx.Lock() + d.hapticsPCM = append(d.hapticsPCM, out...) + reports := d.drainBluetoothHapticsReportsLocked() + d.mtx.Unlock() - report, err := BuildBluetoothHapticsReport(seq, interval, out[offset:offset+BluetoothHapticsSampleSize]) - if err != nil { - slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err) + for _, report := range reports { + if len(report) == 0 { continue } @@ -231,7 +228,7 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { report, "reportType", "output", "reportID", fmt.Sprintf("0x%02X", report[0]), - "summary", fmt.Sprintf("from audio ep=%d offset=%d bytes=%d", EndpointHapticsAudioOut, offset, BluetoothHapticsSampleSize)) + "summary", fmt.Sprintf("from audio ep=%d bytes=%d", EndpointHapticsAudioOut, BluetoothHapticsSampleSize)) if d.outputFunc != nil { d.mtx.Lock() @@ -243,6 +240,60 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { } } +func (d *DualSense) drainBluetoothHapticsReportsLocked() [][]byte { + const inputBytesPerReport = (BluetoothHapticsSampleSize / 2) * + USBHapticsAudioDownsample * + USBHapticsAudioFrameSize + + if len(d.hapticsPCM) < inputBytesPerReport { + return nil + } + + reports := make([][]byte, 0, len(d.hapticsPCM)/inputBytesPerReport) + for len(d.hapticsPCM) >= inputBytesPerReport { + sample := make([]byte, BluetoothHapticsSampleSize) + copyUSBHapticsChannelsToBluetoothSample(sample, d.hapticsPCM[:inputBytesPerReport]) + + seq := d.hapticsSeq + interval := d.hapticsInterval + d.hapticsSeq++ + d.hapticsInterval++ + + report, err := BuildBluetoothHapticsReport(seq, interval, sample) + if err != nil { + slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err) + } else { + reports = append(reports, report) + } + + copy(d.hapticsPCM, d.hapticsPCM[inputBytesPerReport:]) + d.hapticsPCM = d.hapticsPCM[:len(d.hapticsPCM)-inputBytesPerReport] + } + + 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) { reportType := uint8(wValue >> 8) reportID := uint8(wValue & 0xFF) diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 9bb7c6d2..0fcb8553 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -3,6 +3,7 @@ package dualsense import ( "bytes" "context" + "encoding/binary" "encoding/hex" "testing" @@ -88,7 +89,7 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin for _, ep := range iface.Endpoints { if ep.BEndpointAddress == EndpointHapticsAudioOut && ep.BMAttributes&0x03 == 0x01 && - ep.WMaxPacketSize == BluetoothHapticsSampleSize { + ep.WMaxPacketSize == USBHapticsAudioPacketSize { foundEndpoint = true } } @@ -114,6 +115,29 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin if audioControlClassLength != 0x1E { t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x1e", audioControlClassLength) } + + var foundFormat bool + for _, iface := range desc.Interfaces { + if iface.Descriptor.BInterfaceNumber != 2 || 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) { @@ -206,12 +230,16 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { SetTrafficDiagnosticsEnabled(true, true) defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true) - sample := make([]byte, BluetoothHapticsSampleSize) - for i := range sample { - sample[i] = byte(i) + 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, sample) + dev.HandleTransfer(context.Background(), EndpointHapticsAudioOut, usbip.DirOut, pcm) events := TrafficDiagnosticsSnapshot() var sawAudioOut bool @@ -220,8 +248,8 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { switch event.Source { case "audio-haptics-out": sawAudioOut = true - if event.Length != BluetoothHapticsSampleSize { - t.Fatalf("unexpected audio event length: got %d want %d", event.Length, BluetoothHapticsSampleSize) + 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 diff --git a/internal/server/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go index 19f8449e..97d12a82 100644 --- a/internal/server/usb/descriptor_test.go +++ b/internal/server/usb/descriptor_test.go @@ -2,6 +2,7 @@ package usb import ( "bytes" + "context" "encoding/binary" "testing" @@ -10,6 +11,22 @@ import ( "github.com/stretchr/testify/require" ) +type altSettingTestDevice struct { + desc *usbdesc.Descriptor +} + +func (d altSettingTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + return nil +} + +func (d altSettingTestDevice) GetDescriptor() *usbdesc.Descriptor { + return d.desc +} + +func (d altSettingTestDevice) GetDeviceSpecificArgs() map[string]any { + return nil +} + func TestBuildConfigDescriptorSupportsIADAndAlternateSettings(t *testing.T) { desc := &usbdesc.Descriptor{ Configuration: usbdesc.ConfigurationDescriptor{ @@ -68,3 +85,32 @@ func TestBuildConfigDescriptorSupportsIADAndAlternateSettings(t *testing.T) { assert.Equal(t, []byte{0x08, 0x0B, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00}, got[9:17]) 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)) + server.processSubmit(context.Background(), dev, 0, 0, setConfig, nil) + assert.Equal(t, []byte{0x00}, server.processSubmit(context.Background(), dev, 0, 0, getAlt, nil)) +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index d38f3b53..c64803e8 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -196,6 +196,8 @@ type Server struct { 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 @@ -207,6 +209,7 @@ func New(config ServerConfig, logger *slog.Logger, rawLogger log.RawLogger) *Ser logger: logger, rawLogger: rawLogger, busses: make(map[uint32]*virtualbus.VirtualBus), + alts: make(map[usb.Device]map[uint8]uint8), ready: make(chan struct{}), } } @@ -863,15 +866,20 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d return nil } if breq == usbReqSetConfiguration && bm == usbReqTypeStandardToDevice { + s.clearInterfaceAlt(dev) return nil } if breq == usbReqGetConfiguration && bm == usbReqTypeStandardFromDevice { return []byte{0x01} } if breq == usbReqGetInterface && bm == usbReqTypeStandardToInterface { - return []byte{0x00} + return []byte{s.getInterfaceAlt(dev, uint8(wIndex&usbIfaceIndexMask))} } if breq == usbReqSetInterface && bm == usbReqTypeStandardFromInterface { + desc := dev.GetDescriptor() + if descriptorHasInterfaceAlt(desc, uint8(wIndex&usbIfaceIndexMask), uint8(wValue&0xff)) { + s.setInterfaceAlt(dev, uint8(wIndex&usbIfaceIndexMask), uint8(wValue&0xff)) + } return nil } @@ -1067,3 +1075,45 @@ func descriptorListInterfaces(desc *usb.Descriptor) []usb.InterfaceConfig { } 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 (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) +} From d7c2fff65235b858cc968a5550944bdab80faacd Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 15:00:26 -0500 Subject: [PATCH 242/298] Bump DualSense descriptor revision for audio fix --- device/dualsense/descriptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 0c267085..7dc067fa 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -14,7 +14,7 @@ var defaultDescriptor = usb.Descriptor{ BMaxPacketSize0: 0x40, IDVendor: DefaultVID, IDProduct: DefaultPIDDS, - BcdDevice: 0x0100, + BcdDevice: 0x0101, IManufacturer: 0x01, IProduct: 0x02, ISerialNumber: 0x00, From a983de68082705f6211065deaba781f1a38c34ff Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 15:13:16 -0500 Subject: [PATCH 243/298] Add DualSense audio feature unit --- device/dualsense/descriptor.go | 21 ++++++++++---- device/dualsense/device_output_test.go | 40 ++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 7dc067fa..3c1af107 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -14,7 +14,7 @@ var defaultDescriptor = usb.Descriptor{ BMaxPacketSize0: 0x40, IDVendor: DefaultVID, IDProduct: DefaultPIDDS, - BcdDevice: 0x0101, + BcdDevice: 0x0102, IManufacturer: 0x01, IProduct: 0x02, ISerialNumber: 0x00, @@ -186,17 +186,26 @@ var defaultDescriptor = usb.Descriptor{ 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, 0x1E, 0x00, 0x01, 0x02}, + Payload: usb.Data{0x01, 0x00, 0x01, 0x2A, 0x00, 0x01, 0x02}, }, { DescriptorType: 0x24, // CS_INTERFACE - // Input Terminal: USB streaming source, 4 channels. - Payload: usb.Data{0x02, 0x01, 0x01, 0x01, 0x00, USBHapticsAudioChannels, 0x0F, 0x00, 0x00, 0x00}, + // 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 - // Output Terminal: speaker/haptics sink, source terminal 1. - Payload: usb.Data{0x03, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00}, + // 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}, }, }, }, diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 0fcb8553..b6237840 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -103,6 +103,10 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin } var audioControlClassLength int + var foundHeader bool + var foundInputTerminal bool + var foundFeatureUnit bool + var foundOutputTerminal bool for _, iface := range desc.Interfaces { if iface.Descriptor.BInterfaceNumber != 1 || iface.Descriptor.BAlternateSetting != 0 { continue @@ -110,10 +114,42 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin 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{0x2A, 0x00}) { + t.Fatalf("unexpected AudioControl header descriptor: % x", raw) + } + case 0x02: + 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: + foundFeatureUnit = true + if len(raw) < 6 || raw[3] != 0x02 || raw[4] != 0x01 { + t.Fatalf("unexpected haptics audio feature unit descriptor: % x", raw) + } + case 0x03: + foundOutputTerminal = true + if len(raw) < 8 || raw[3] != 0x03 || raw[7] != 0x02 { + t.Fatalf("unexpected haptics audio output terminal descriptor: % x", raw) + } + } } } - if audioControlClassLength != 0x1E { - t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x1e", audioControlClassLength) + if audioControlClassLength != 0x2A { + t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x2a", 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 From e41051ced6765402cf7a80099daa289e0dc3b598 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 14 Jun 2026 16:15:28 -0500 Subject: [PATCH 244/298] Emit UAC audio endpoint trailing fields --- device/dualsense/descriptor.go | 3 ++- device/dualsense/device_output_test.go | 3 ++- internal/server/usb/descriptor_test.go | 2 ++ usb/usbdesc.go | 8 ++++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 3c1af107..84406315 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -14,7 +14,7 @@ var defaultDescriptor = usb.Descriptor{ BMaxPacketSize0: 0x40, IDVendor: DefaultVID, IDProduct: DefaultPIDDS, - BcdDevice: 0x0102, + BcdDevice: 0x0103, IManufacturer: 0x01, IProduct: 0x02, ISerialNumber: 0x00, @@ -252,6 +252,7 @@ var defaultDescriptor = usb.Descriptor{ BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. WMaxPacketSize: USBHapticsAudioPacketSize, BInterval: 1, + Trailing: usb.Data{0x00, 0x00}, // bRefresh, bSynchAddress for UAC1. ClassDescriptors: []usb.ClassSpecificDescriptor{ { DescriptorType: 0x25, // CS_ENDPOINT diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index b6237840..0182cefc 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -89,7 +89,8 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin for _, ep := range iface.Endpoints { if ep.BEndpointAddress == EndpointHapticsAudioOut && ep.BMAttributes&0x03 == 0x01 && - ep.WMaxPacketSize == USBHapticsAudioPacketSize { + ep.WMaxPacketSize == USBHapticsAudioPacketSize && + bytes.Equal(ep.Trailing, []byte{0x00, 0x00}) { foundEndpoint = true } } diff --git a/internal/server/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go index 97d12a82..03da589b 100644 --- a/internal/server/usb/descriptor_test.go +++ b/internal/server/usb/descriptor_test.go @@ -60,6 +60,7 @@ func TestBuildConfigDescriptorSupportsIADAndAlternateSettings(t *testing.T) { BMAttributes: 0x0D, WMaxPacketSize: 0x00C0, BInterval: 1, + Trailing: usbdesc.Data{0x00, 0x00}, ClassDescriptors: []usbdesc.ClassSpecificDescriptor{ {DescriptorType: 0x25, Payload: usbdesc.Data{0x01, 0x00, 0x00, 0x00, 0x00}}, }, @@ -83,6 +84,7 @@ func TestBuildConfigDescriptorSupportsIADAndAlternateSettings(t *testing.T) { 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})) } diff --git a/usb/usbdesc.go b/usb/usbdesc.go index e45b71ba..84e34ff2 100644 --- a/usb/usbdesc.go +++ b/usb/usbdesc.go @@ -330,12 +330,15 @@ func (i InterfaceDescriptor) Write(b *bytes.Buffer) { } -// EndpointDescriptor (7 bytes) for each endpoint. +// 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. @@ -343,12 +346,13 @@ type EndpointDescriptor struct { } func (e EndpointDescriptor) Write(b *bytes.Buffer) { - b.WriteByte(EndpointDescLen) + 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) } From 90f640422fb94883bdc6224d53094c55c26d5c34 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 20 Jun 2026 17:12:21 -0500 Subject: [PATCH 245/298] Clarify DualSense virtual audio channel layout --- docs/research/dualsense-bluetooth-haptics.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index 8aa7750f..4674f384 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -202,7 +202,9 @@ VIIPER now has an experimental DualSense haptics/audio OUT endpoint: - endpoint: `0x05` - type: isochronous OUT -- format advertised in the descriptor: stereo, 8-bit, 3000 Hz +- 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: From a38594c9db634c47daebc61dd6b59dc1d5456a80 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 21 Jun 2026 14:29:22 -0500 Subject: [PATCH 246/298] Fix virtual DualSense UAC endpoint startup --- device/dualsense/descriptor.go | 1 - device/dualsense/device.go | 50 ++++++++++++++++++++ device/dualsense/device_output_test.go | 29 +++++++++++- docs/research/dualsense-bluetooth-haptics.md | 15 ++++-- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 84406315..061bd0d3 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -252,7 +252,6 @@ var defaultDescriptor = usb.Descriptor{ BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. WMaxPacketSize: USBHapticsAudioPacketSize, BInterval: 1, - Trailing: usb.Data{0x00, 0x00}, // bRefresh, bSynchAddress for UAC1. ClassDescriptors: []usb.ClassSpecificDescriptor{ { DescriptorType: 0x25, // CS_ENDPOINT diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 1e71703e..5eb7fdd2 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -295,6 +295,10 @@ func copyUSBHapticsChannelsToBluetoothSample(dst []byte, src []byte) { } func (d *DualSense) 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) @@ -378,6 +382,52 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, return nil, false } +const ( + audioClassRequestSetCurrent = 0x01 + audioClassRequestGetCurrent = 0x81 + audioClassRequestGetMinimum = 0x82 + audioClassRequestGetMaximum = 0x83 + audioClassRequestGetResolution = 0x84 + + audioClassEndpointOut = 0x22 + audioClassEndpointIn = 0xA2 + + audioControlSamplingFrequency = 0x01 +) + +// handleAudioControlRequest implements the UAC1 endpoint sampling-frequency +// controls advertised by the AudioStreaming format descriptor. Windows +// usbaudio validates these requests before it starts the render endpoint. +func handleAudioControlRequest(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16) ([]byte, bool) { + if uint8(wIndex) != EndpointHapticsAudioOut || uint8(wValue>>8) != audioControlSamplingFrequency { + return nil, false + } + + switch bmRequestType { + case audioClassEndpointIn: + switch bRequest { + case audioClassRequestGetCurrent, audioClassRequestGetMinimum, audioClassRequestGetMaximum: + response := []byte{0x80, 0xBB, 0x00} // 48,000 Hz as a 24-bit little-endian value. + if wLength < uint16(len(response)) { + response = response[:wLength] + } + return response, true + case audioClassRequestGetResolution: + response := []byte{0x00, 0x00, 0x00} // One discrete advertised frequency. + if wLength < uint16(len(response)) { + response = response[:wLength] + } + return response, true + } + case audioClassEndpointOut: + if bRequest == audioClassRequestSetCurrent { + return nil, true + } + } + + return nil, false +} + func (d *DualSense) handleOutputReport(out []byte) bool { report, ok := normalizeOutputReport(out) if !ok { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 0182cefc..6d14c406 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -90,7 +90,7 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin if ep.BEndpointAddress == EndpointHapticsAudioOut && ep.BMAttributes&0x03 == 0x01 && ep.WMaxPacketSize == USBHapticsAudioPacketSize && - bytes.Equal(ep.Trailing, []byte{0x00, 0x00}) { + len(ep.Trailing) == 0 { foundEndpoint = true } } @@ -311,6 +311,33 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { } } +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 { diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index 4674f384..bd6b2e25 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -223,10 +223,17 @@ 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 first fix corrected the UAC1 AudioControl header total length to `0x001E`, -removed an extra byte from the Output Terminal descriptor, and added generic -standard `GET_INTERFACE` / `SET_INTERFACE` handling so the audio streaming -interface can switch from alternate setting 0 to alternate setting 1. +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. + +The endpoint must remain a seven-byte UAC1 endpoint descriptor. `bRefresh` and +`bSynchAddress` are UAC2 fields and cannot be appended to a UAC1 descriptor. +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. This is necessary for Windows to activate the render +stream after enumeration. Credit: SAxense research by egormanga/Sdore should be credited anywhere this Bluetooth haptics packet path is surfaced to users or shipped. From 90dd503a7fb9ec70753d6caed7b94d61926f9245 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 21 Jun 2026 14:41:45 -0500 Subject: [PATCH 247/298] Preserve captured DualSense audio endpoint layout --- device/dualsense/descriptor.go | 3 +++ device/dualsense/device_output_test.go | 2 +- docs/research/dualsense-bluetooth-haptics.md | 17 +++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 061bd0d3..b7dc47d7 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -252,6 +252,9 @@ var defaultDescriptor = usb.Descriptor{ BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. WMaxPacketSize: USBHapticsAudioPacketSize, BInterval: 1, + // 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 diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 6d14c406..5810e0fb 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -90,7 +90,7 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin if ep.BEndpointAddress == EndpointHapticsAudioOut && ep.BMAttributes&0x03 == 0x01 && ep.WMaxPacketSize == USBHapticsAudioPacketSize && - len(ep.Trailing) == 0 { + bytes.Equal(ep.Trailing, []byte{0x00, 0x00}) { foundEndpoint = true } } diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index bd6b2e25..fc466f80 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -228,12 +228,17 @@ 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. -The endpoint must remain a seven-byte UAC1 endpoint descriptor. `bRefresh` and -`bSynchAddress` are UAC2 fields and cannot be appended to a UAC1 descriptor. -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. This is necessary for Windows to activate the render -stream after enumeration. +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. Credit: SAxense research by egormanga/Sdore should be credited anywhere this Bluetooth haptics packet path is surfaced to users or shipped. From 9d2f1c1384ae9214f4832b90da2d8420c5039643 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 21 Jun 2026 14:51:00 -0500 Subject: [PATCH 248/298] Implement USBIP isochronous transfer framing --- _testing/usbip_client.go | 4 +- device/dualsense/bthaptics.go | 3 + device/dualsense/const.go | 2 +- device/dualsense/descriptor.go | 12 +-- device/dualsense/device_output_test.go | 6 +- device/dualshock4/dualshock4_test.go | 2 +- docs/research/dualsense-bluetooth-haptics.md | 16 +++ internal/server/usb/iso_test.go | 52 +++++++++ internal/server/usb/server.go | 108 +++++++++++++++++-- usbip/usbip.go | 40 ++++++- usbip/usbip_test.go | 31 ++++++ 11 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 internal/server/usb/iso_test.go create mode 100644 usbip/usbip_test.go diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go index ea898431..a3c4cf46 100644 --- a/_testing/usbip_client.go +++ b/_testing/usbip_client.go @@ -233,7 +233,7 @@ func (c *TestUsbIpClient) SubmitWithTimeout(conn net.Conn, dir uint32, ep uint32 TransferFlags: 0, TransferBufferLen: uint32(len(outPayload)), StartFrame: 0, - NumberOfPackets: 0, + NumberOfPackets: -1, Interval: 0, Setup: setupBytes, } @@ -290,7 +290,7 @@ func (c *TestUsbIpClient) ReadInputReportWithTimeout(conn net.Conn, timeout time TransferFlags: 0, TransferBufferLen: inMax, StartFrame: 0, - NumberOfPackets: 0, + NumberOfPackets: -1, Interval: 0, Setup: [8]byte{}, } diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go index 891b68b8..29d951b9 100644 --- a/device/dualsense/bthaptics.go +++ b/device/dualsense/bthaptics.go @@ -17,6 +17,9 @@ const ( 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 diff --git a/device/dualsense/const.go b/device/dualsense/const.go index d1e25a6c..b90824c2 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -31,7 +31,7 @@ var DefaultBuildTime = time.Date(2025, time.July, 4, 10, 10, 32, 0, time.UTC) const ( EndpointIn = 0x84 EndpointOut = 0x03 - EndpointHapticsAudioOut = 0x05 + EndpointHapticsAudioOut = 0x01 ) const ( diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index b7dc47d7..b5541a28 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -14,12 +14,12 @@ var defaultDescriptor = usb.Descriptor{ BMaxPacketSize0: 0x40, IDVendor: DefaultVID, IDProduct: DefaultPIDDS, - BcdDevice: 0x0103, + BcdDevice: 0x0100, IManufacturer: 0x01, IProduct: 0x02, ISerialNumber: 0x00, BNumConfigurations: 0x01, - Speed: 2, // Full speed + Speed: 3, // High speed; required for the 48 kHz UAC stream. }, Associations: []usb.InterfaceAssociationDescriptor{ { @@ -161,13 +161,13 @@ var defaultDescriptor = usb.Descriptor{ BEndpointAddress: EndpointIn, BMAttributes: 0x03, // Interrupt WMaxPacketSize: 64, - BInterval: 2, + BInterval: 6, }, { BEndpointAddress: EndpointOut, BMAttributes: 0x03, // Interrupt WMaxPacketSize: 64, - BInterval: 2, + BInterval: 6, }, }, }, @@ -250,8 +250,8 @@ var defaultDescriptor = usb.Descriptor{ { BEndpointAddress: EndpointHapticsAudioOut, BMAttributes: 0x09, // Isochronous, adaptive, data endpoint. - WMaxPacketSize: USBHapticsAudioPacketSize, - BInterval: 1, + 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}, diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 5810e0fb..17a2a512 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -66,6 +66,9 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin } 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.NumInterfaces() != 3 { t.Fatalf("unexpected interface count: got %d want 3", desc.NumInterfaces()) } @@ -89,7 +92,8 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin for _, ep := range iface.Endpoints { if ep.BEndpointAddress == EndpointHapticsAudioOut && ep.BMAttributes&0x03 == 0x01 && - ep.WMaxPacketSize == USBHapticsAudioPacketSize && + ep.WMaxPacketSize == USBHapticsAudioMaxPacketSize && + ep.BInterval == 4 && bytes.Equal(ep.Trailing, []byte{0x00, 0x00}) { foundEndpoint = true } diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 30fe2fcc..3c050086 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -295,7 +295,7 @@ func TestInputReports(t *testing.T) { TransferFlags: 0, TransferBufferLen: 255, StartFrame: 0, - NumberOfPackets: 0, + NumberOfPackets: -1, Interval: 0, Setup: [8]byte{}, } diff --git a/docs/research/dualsense-bluetooth-haptics.md b/docs/research/dualsense-bluetooth-haptics.md index fc466f80..fe3bbc0a 100644 --- a/docs/research/dualsense-bluetooth-haptics.md +++ b/docs/research/dualsense-bluetooth-haptics.md @@ -240,5 +240,21 @@ 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/internal/server/usb/iso_test.go b/internal/server/usb/iso_test.go new file mode 100644 index 00000000..4c2071fb --- /dev/null +++ b/internal/server/usb/iso_test.go @@ -0,0 +1,52 @@ +package usb + +import ( + "testing" + "time" + + usbdesc "github.com/Alia5/VIIPER/usb" + "github.com/Alia5/VIIPER/usbip" +) + +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 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) + } +} diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index c64803e8..3594d00e 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -178,7 +178,9 @@ const ( urbHdrOffsetUnlink = 0x14 urbHdrOffsetFlags = 0x14 urbHdrOffsetLength = 0x18 + urbHdrOffsetPackets = 0x20 urbHdrOffsetSetup = 0x28 + maxIsoPackets = 1024 // Standard header peek size headerPeekSize = 8 @@ -603,15 +605,19 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var writeMu sync.Mutex var retOut bytes.Buffer retOut.Grow(retSubmitHeaderSize) - writeRet := func(seq, actualLen uint32, respData []byte, flush bool) error { + 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: 0, + NumberOfPackets: packetCount, ErrorCount: 0, } retOut.Reset() @@ -626,6 +632,11 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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) @@ -723,6 +734,11 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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])) + isIso := packetCountWire >= 0 + if packetCountWire < -1 || packetCountWire > maxIsoPackets { + return fmt.Errorf("invalid ISO packet count %d", packetCountWire) + } setup := hdr[urbHdrOffsetSetup:urbHdrSize] var outPayload []byte @@ -736,6 +752,16 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } } + 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 { urbCtx, urbCancel := context.WithCancel(ctx) pendingMu.Lock() @@ -743,7 +769,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { pendingMu.Unlock() interval := endpointInterval(dev.GetDescriptor(), ep) - go func(seq, ep, dir uint32) { + go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool) { defer urbCancel() var respData []byte for { @@ -782,24 +808,31 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { delete(pending, seq) pendingMu.Unlock() - if err := writeRet(seq, uint32(len(respData)), respData, true); err != nil { + 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) + }(seq, ep, dir, isoPackets, isIso) continue } - // EP0 and OUT transfers never block and are handled in order. + // 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. respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) actualLen := uint32(len(respData)) if dir == usbip.DirOut { actualLen = uint32(len(outPayload)) } - if err := writeRet(seq, actualLen, respData, ep == 0); err != nil { + completedPackets := completeIsoPackets(isoPackets, actualLen) + if dir == usbip.DirOut && isIso { + time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets))) + } + if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err } } @@ -812,12 +845,71 @@ func endpointInterval(desc *usb.Descriptor, ep uint32) time.Duration { if epDesc.BEndpointAddress != epAddr || epDesc.BMAttributes&0x03 != 0x03 { continue } - return time.Duration(epDesc.BInterval) * time.Millisecond + return usbServiceInterval(desc.Device.Speed, epDesc.BInterval) } } return 0 } +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 +} + +// 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) +} + // 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 diff --git a/usbip/usbip.go b/usbip/usbip.go index 4b6a6ba1..c7b74da7 100644 --- a/usbip/usbip.go +++ b/usbip/usbip.go @@ -188,7 +188,7 @@ type CmdSubmit struct { TransferFlags uint32 TransferBufferLen uint32 StartFrame uint32 - NumberOfPackets uint32 + NumberOfPackets int32 Interval uint32 Setup [8]byte } @@ -234,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) + } +} From 9db722c34dd779ebfcc71058f66722f6e8cfacd1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:04:23 -0500 Subject: [PATCH 249/298] Format DualSense UAC changes --- device/dualsense/bthaptics.go | 4 ++-- internal/server/usb/server.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go index 29d951b9..6f25e4c6 100644 --- a/device/dualsense/bthaptics.go +++ b/device/dualsense/bthaptics.go @@ -19,8 +19,8 @@ const ( 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 + USBHapticsAudioMaxPacketSize = 392 + USBHapticsAudioDownsample = USBHapticsAudioSampleRate / BluetoothHapticsSampleRate BluetoothOutputReportID = 0x31 BluetoothOutputReportSize = 78 diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 3594d00e..3749b5b6 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -180,7 +180,7 @@ const ( urbHdrOffsetLength = 0x18 urbHdrOffsetPackets = 0x20 urbHdrOffsetSetup = 0x28 - maxIsoPackets = 1024 + maxIsoPackets = 1024 // Standard header peek size headerPeekSize = 8 From 58a6c69ab0eb4526fdf9c57b6cd84768082fda6a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:23:33 -0500 Subject: [PATCH 250/298] Match virtual DualSense UAC layout --- device/dualsense/const.go | 1 + device/dualsense/descriptor.go | 234 ++++++++++++++----------- device/dualsense/device.go | 4 +- device/dualsense/device_output_test.go | 39 +++-- 4 files changed, 157 insertions(+), 121 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index b90824c2..aef736b7 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -32,6 +32,7 @@ const ( EndpointIn = 0x84 EndpointOut = 0x03 EndpointHapticsAudioOut = 0x01 + EndpointMicrophoneIn = 0x82 ) const ( diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index b5541a28..aded5198 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -21,27 +21,51 @@ var defaultDescriptor = usb.Descriptor{ BNumConfigurations: 0x01, Speed: 3, // High speed; required for the 48 kHz UAC stream. }, - Associations: []usb.InterfaceAssociationDescriptor{ - { - BFirstInterface: 0x01, - BInterfaceCount: 0x02, - BFunctionClass: 0x01, // Audio - BFunctionSubClass: 0x01, // AudioControl - BFunctionProtocol: 0x00, - IFunction: 0x00, - }, - }, Interfaces: []usb.InterfaceConfig{ { Descriptor: usb.InterfaceDescriptor{ BInterfaceNumber: 0x00, BAlternateSetting: 0x00, - BNumEndpoints: 0x02, - BInterfaceClass: 0x03, // HID - BInterfaceSubClass: 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: 0x00C4, 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, @@ -171,100 +195,103 @@ var defaultDescriptor = usb.Descriptor{ }, }, }, - { - 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}, + /* 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}, + }, + }, }, { - 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}, + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x00, + BNumEndpoints: 0x00, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x02, // AudioStreaming + BInterfaceProtocol: 0x00, + IInterface: 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}, + Descriptor: usb.InterfaceDescriptor{ + BInterfaceNumber: 0x02, + BAlternateSetting: 0x01, + BNumEndpoints: 0x01, + BInterfaceClass: 0x01, // Audio + BInterfaceSubClass: 0x02, // AudioStreaming + BInterfaceProtocol: 0x00, + IInterface: 0x00, + }, ClassDescriptors: []usb.ClassSpecificDescriptor{ { - DescriptorType: 0x25, // CS_ENDPOINT - // EP General: no sampling-frequency or pitch controls. - Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 0x00}, + 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) @@ -281,8 +308,12 @@ func makeDescriptor(edge bool) usb.Descriptor { desc.Strings[k] = v } - if len(desc.Interfaces) > 0 && desc.Interfaces[0].HID != nil { - hidFunction := *desc.Interfaces[0].HID + 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 { @@ -300,7 +331,8 @@ func makeDescriptor(edge bool) usb.Descriptor { } hidFunction.ReportDescriptor = reportDescriptor - desc.Interfaces[0].HID = &hidFunction + desc.Interfaces[i].HID = &hidFunction + break } desc.Device.IDProduct = DefaultPIDDS diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 5eb7fdd2..cb6a65e9 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -399,7 +399,9 @@ const ( // controls advertised by the AudioStreaming format descriptor. Windows // usbaudio validates these requests before it starts the render endpoint. func handleAudioControlRequest(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16) ([]byte, bool) { - if uint8(wIndex) != EndpointHapticsAudioOut || uint8(wValue>>8) != audioControlSamplingFrequency { + endpoint := uint8(wIndex) + if (endpoint != EndpointHapticsAudioOut && endpoint != EndpointMicrophoneIn) || + uint8(wValue>>8) != audioControlSamplingFrequency { return nil, false } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 17a2a512..6380f98a 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -16,7 +16,7 @@ func TestDualSenseUSBOutputReportDescriptorMatchesCapture(t *testing.T) { t.Fatalf("New returned error: %v", err) } - report, err := dev.GetDescriptor().Interfaces[0].HID.ReportBytes() + report, err := dev.GetDescriptor().Interfaces[5].HID.ReportBytes() if err != nil { t.Fatalf("ReportBytes returned error: %v", err) } @@ -45,7 +45,7 @@ func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { t.Fatalf("unexpected DualSense product string: %q", desc.Strings[2]) } - report, err := desc.Interfaces[0].HID.ReportBytes() + report, err := desc.Interfaces[5].HID.ReportBytes() if err != nil { t.Fatalf("ReportBytes returned error: %v", err) } @@ -69,22 +69,14 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin 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.NumInterfaces() != 3 { - t.Fatalf("unexpected interface count: got %d want 3", desc.NumInterfaces()) - } - if len(desc.Associations) != 1 { - t.Fatalf("unexpected interface association count: got %d want 1", len(desc.Associations)) - } - audioIAD := desc.Associations[0] - if audioIAD.BFirstInterface != 1 || audioIAD.BInterfaceCount != 2 || - audioIAD.BFunctionClass != 0x01 || audioIAD.BFunctionSubClass != 0x01 { - t.Fatalf("unexpected audio IAD: %#v", audioIAD) + 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 == 2 && + if iface.Descriptor.BInterfaceNumber == 1 && iface.Descriptor.BAlternateSetting == 1 && iface.Descriptor.BInterfaceClass == 0x01 && iface.Descriptor.BInterfaceSubClass == 0x02 { @@ -113,7 +105,7 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin var foundFeatureUnit bool var foundOutputTerminal bool for _, iface := range desc.Interfaces { - if iface.Descriptor.BInterfaceNumber != 1 || iface.Descriptor.BAlternateSetting != 0 { + if iface.Descriptor.BInterfaceNumber != 0 || iface.Descriptor.BAlternateSetting != 0 { continue } @@ -127,21 +119,30 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin switch raw[2] { case 0x01: foundHeader = true - if len(raw) < 7 || !bytes.Equal(raw[5:7], []byte{0x2A, 0x00}) { + 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) @@ -149,8 +150,8 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin } } } - if audioControlClassLength != 0x2A { - t.Fatalf("unexpected AudioControl class descriptor length: got 0x%02x want 0x2a", audioControlClassLength) + 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", @@ -159,7 +160,7 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin var foundFormat bool for _, iface := range desc.Interfaces { - if iface.Descriptor.BInterfaceNumber != 2 || iface.Descriptor.BAlternateSetting != 1 { + if iface.Descriptor.BInterfaceNumber != 1 || iface.Descriptor.BAlternateSetting != 1 { continue } @@ -195,7 +196,7 @@ func TestDualSenseEdgeDescriptorAdvertisesEdgeFeatureReports(t *testing.T) { t.Fatalf("unexpected Edge product string: %q", desc.Strings[2]) } - report, err := desc.Interfaces[0].HID.ReportBytes() + report, err := desc.Interfaces[5].HID.ReportBytes() if err != nil { t.Fatalf("ReportBytes returned error: %v", err) } From f970356b6d2e2be305b76761b84f8e4316f5c3ef Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:33:35 -0500 Subject: [PATCH 251/298] Pace DualSense audio ISO transfers by PCM duration --- internal/server/usb/iso_test.go | 20 +++++++++++++++++++- internal/server/usb/server.go | 32 +++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/internal/server/usb/iso_test.go b/internal/server/usb/iso_test.go index 4c2071fb..e2da3fd1 100644 --- a/internal/server/usb/iso_test.go +++ b/internal/server/usb/iso_test.go @@ -40,11 +40,29 @@ func TestIsoCompletionDelayUsesHighSpeedMicroframes(t *testing.T) { }}, } - if got := isoCompletionDelay(desc, 1, 8); got != 8*time.Millisecond { + if got := isoCompletionDelay(desc, 1, 8, 0); got != 8*time.Millisecond { t.Fatalf("unexpected high-speed ISO delay: got %s want 8ms", got) } } +func TestIsoCompletionDelayUsesDualSensePCMDuration(t *testing.T) { + desc := &usbdesc.Descriptor{ + Device: usbdesc.DeviceDescriptor{Speed: 3}, + Interfaces: []usbdesc.InterfaceConfig{{ + Endpoints: []usbdesc.EndpointDescriptor{{ + BEndpointAddress: 0x01, + BMAttributes: 0x09, + WMaxPacketSize: 392, + BInterval: 4, + }}, + }}, + } + + if got := isoCompletionDelay(desc, 1, 3, 1176); got != 4*time.Millisecond { + t.Fatalf("unexpected PCM-based ISO delay: got %s want 4ms", 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) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 3749b5b6..80e3be03 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -830,7 +830,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } completedPackets := completeIsoPackets(isoPackets, actualLen) if dir == usbip.DirOut && isIso { - time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets))) + time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets), actualLen)) } if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err @@ -885,7 +885,7 @@ func completeIsoPackets(submitted []usbip.IsoPacketDescriptor, actualLen uint32) // 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 { +func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int, actualLen uint32) time.Duration { if packetCount == 0 { return 0 } @@ -907,7 +907,33 @@ func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int) time.D return 0 } - return min(time.Duration(packetCount)*usbServiceInterval(desc.Device.Speed, bInterval), 100*time.Millisecond) + packetDuration := time.Duration(packetCount) * usbServiceInterval(desc.Device.Speed, bInterval) + + // The captured DualSense UAC render stream is 48 kHz, four-channel S16_LE: + // 384 PCM bytes per millisecond. Its endpoint permits 392-byte packets, so + // descriptor count alone can understate the real audio duration whenever + // Windows submits padded or coalesced packets. Completing those URBs too + // quickly makes the host audio engine burst, then underrun. + pcmDuration := time.Duration(0) + if actualLen > 0 && dualSenseAudioOutEndpoint(desc, ep) { + pcmDuration = time.Duration((actualLen+383)/384) * time.Millisecond + } + + return min(max(packetDuration, pcmDuration), 100*time.Millisecond) +} + +func dualSenseAudioOutEndpoint(desc *usb.Descriptor, ep uint32) bool { + for _, iface := range desc.Interfaces { + for _, endpoint := range iface.Endpoints { + if endpoint.BEndpointAddress == uint8(ep) && + endpoint.BMAttributes&0x03 == 0x01 && + endpoint.WMaxPacketSize == 392 { + return true + } + } + } + + return false } // isClientDisconnect tests whether an error represents a normal client From 6ef572342ca73dac2f2902e6f537c364fd020fd2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:35:35 -0500 Subject: [PATCH 252/298] Keep HID feedback responsive during audio pacing --- internal/server/usb/server.go | 78 ++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 80e3be03..8d5e6349 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -658,6 +658,63 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var respMu sync.Mutex lastInResp := map[uint32][]byte{} + type isoOutJob struct { + seq uint32 + ep uint32 + payload []byte + packets []usbip.IsoPacketDescriptor + } + + streamCtx, cancelStream := context.WithCancel(ctx) + isoOutJobs := make(chan isoOutJob, 64) + var isoOutWG sync.WaitGroup + isoOutWG.Add(1) + go func() { + defer isoOutWG.Done() + + var nextReady time.Time + for { + select { + case <-streamCtx.Done(): + return + case job, ok := <-isoOutJobs: + if !ok { + return + } + + delay := isoCompletionDelay(dev.GetDescriptor(), job.ep, len(job.packets), uint32(len(job.payload))) + now := time.Now() + if nextReady.Before(now) { + nextReady = now + } + nextReady = nextReady.Add(delay) + if wait := time.Until(nextReady); wait > 0 { + timer := time.NewTimer(wait) + select { + case <-streamCtx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + + _ = s.processSubmit(streamCtx, dev, job.ep, usbip.DirOut, nil, job.payload) + completed := completeIsoPackets(job.packets, uint32(len(job.payload))) + if err := writeRet(job.seq, uint32(len(job.payload)), nil, completed, true, true); err != nil { + if !isClientDisconnect(err) { + s.logger.Error("write paced ISO RET_SUBMIT", "seq", job.seq, "error", err) + } + return + } + } + } + }() + defer func() { + cancelStream() + close(isoOutJobs) + isoOutWG.Wait() + }() + var outPayloadScratch []byte for { @@ -762,6 +819,20 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } } + // ISO OUT completion is paced by the stream worker so the reader can + // continue servicing HID feedback while audio packets wait for their + // realtime completion slot. + if dir == usbip.DirOut && isIso && ep != 0 { + payload := append([]byte(nil), outPayload...) + job := isoOutJob{seq: seq, ep: ep, payload: payload, packets: isoPackets} + select { + case isoOutJobs <- job: + case <-streamCtx.Done(): + return nil + } + continue + } + if dir == usbip.DirIn && ep != 0 { urbCtx, urbCancel := context.WithCancel(ctx) pendingMu.Lock() @@ -820,18 +891,13 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { 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. + // EP0 and non-ISO OUT transfers never block and are handled in order. respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) actualLen := uint32(len(respData)) if dir == usbip.DirOut { actualLen = uint32(len(outPayload)) } completedPackets := completeIsoPackets(isoPackets, actualLen) - if dir == usbip.DirOut && isIso { - time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets), actualLen)) - } if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err } From 6a572e5ef2219e22ca5be05fdb9c83b944f6fd76 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:45:04 -0500 Subject: [PATCH 253/298] Revert "Keep HID feedback responsive during audio pacing" This reverts commit 6ef572342ca73dac2f2902e6f537c364fd020fd2. --- internal/server/usb/server.go | 78 +++-------------------------------- 1 file changed, 6 insertions(+), 72 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 8d5e6349..80e3be03 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -658,63 +658,6 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var respMu sync.Mutex lastInResp := map[uint32][]byte{} - type isoOutJob struct { - seq uint32 - ep uint32 - payload []byte - packets []usbip.IsoPacketDescriptor - } - - streamCtx, cancelStream := context.WithCancel(ctx) - isoOutJobs := make(chan isoOutJob, 64) - var isoOutWG sync.WaitGroup - isoOutWG.Add(1) - go func() { - defer isoOutWG.Done() - - var nextReady time.Time - for { - select { - case <-streamCtx.Done(): - return - case job, ok := <-isoOutJobs: - if !ok { - return - } - - delay := isoCompletionDelay(dev.GetDescriptor(), job.ep, len(job.packets), uint32(len(job.payload))) - now := time.Now() - if nextReady.Before(now) { - nextReady = now - } - nextReady = nextReady.Add(delay) - if wait := time.Until(nextReady); wait > 0 { - timer := time.NewTimer(wait) - select { - case <-streamCtx.Done(): - timer.Stop() - return - case <-timer.C: - } - } - - _ = s.processSubmit(streamCtx, dev, job.ep, usbip.DirOut, nil, job.payload) - completed := completeIsoPackets(job.packets, uint32(len(job.payload))) - if err := writeRet(job.seq, uint32(len(job.payload)), nil, completed, true, true); err != nil { - if !isClientDisconnect(err) { - s.logger.Error("write paced ISO RET_SUBMIT", "seq", job.seq, "error", err) - } - return - } - } - } - }() - defer func() { - cancelStream() - close(isoOutJobs) - isoOutWG.Wait() - }() - var outPayloadScratch []byte for { @@ -819,20 +762,6 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } } - // ISO OUT completion is paced by the stream worker so the reader can - // continue servicing HID feedback while audio packets wait for their - // realtime completion slot. - if dir == usbip.DirOut && isIso && ep != 0 { - payload := append([]byte(nil), outPayload...) - job := isoOutJob{seq: seq, ep: ep, payload: payload, packets: isoPackets} - select { - case isoOutJobs <- job: - case <-streamCtx.Done(): - return nil - } - continue - } - if dir == usbip.DirIn && ep != 0 { urbCtx, urbCancel := context.WithCancel(ctx) pendingMu.Lock() @@ -891,13 +820,18 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { continue } - // EP0 and non-ISO OUT transfers never block and are handled in order. + // 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. respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) actualLen := uint32(len(respData)) if dir == usbip.DirOut { actualLen = uint32(len(outPayload)) } completedPackets := completeIsoPackets(isoPackets, actualLen) + if dir == usbip.DirOut && isIso { + time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets), actualLen)) + } if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err } From 286713eefe52ebe4db1fca29434d7ef75943eb96 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 09:45:04 -0500 Subject: [PATCH 254/298] Revert "Pace DualSense audio ISO transfers by PCM duration" This reverts commit f970356b6d2e2be305b76761b84f8e4316f5c3ef. --- internal/server/usb/iso_test.go | 20 +------------------- internal/server/usb/server.go | 32 +++----------------------------- 2 files changed, 4 insertions(+), 48 deletions(-) diff --git a/internal/server/usb/iso_test.go b/internal/server/usb/iso_test.go index e2da3fd1..4c2071fb 100644 --- a/internal/server/usb/iso_test.go +++ b/internal/server/usb/iso_test.go @@ -40,29 +40,11 @@ func TestIsoCompletionDelayUsesHighSpeedMicroframes(t *testing.T) { }}, } - if got := isoCompletionDelay(desc, 1, 8, 0); got != 8*time.Millisecond { + if got := isoCompletionDelay(desc, 1, 8); got != 8*time.Millisecond { t.Fatalf("unexpected high-speed ISO delay: got %s want 8ms", got) } } -func TestIsoCompletionDelayUsesDualSensePCMDuration(t *testing.T) { - desc := &usbdesc.Descriptor{ - Device: usbdesc.DeviceDescriptor{Speed: 3}, - Interfaces: []usbdesc.InterfaceConfig{{ - Endpoints: []usbdesc.EndpointDescriptor{{ - BEndpointAddress: 0x01, - BMAttributes: 0x09, - WMaxPacketSize: 392, - BInterval: 4, - }}, - }}, - } - - if got := isoCompletionDelay(desc, 1, 3, 1176); got != 4*time.Millisecond { - t.Fatalf("unexpected PCM-based ISO delay: got %s want 4ms", 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) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 80e3be03..3749b5b6 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -830,7 +830,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } completedPackets := completeIsoPackets(isoPackets, actualLen) if dir == usbip.DirOut && isIso { - time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets), actualLen)) + time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets))) } if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err @@ -885,7 +885,7 @@ func completeIsoPackets(submitted []usbip.IsoPacketDescriptor, actualLen uint32) // 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, actualLen uint32) time.Duration { +func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int) time.Duration { if packetCount == 0 { return 0 } @@ -907,33 +907,7 @@ func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int, actual return 0 } - packetDuration := time.Duration(packetCount) * usbServiceInterval(desc.Device.Speed, bInterval) - - // The captured DualSense UAC render stream is 48 kHz, four-channel S16_LE: - // 384 PCM bytes per millisecond. Its endpoint permits 392-byte packets, so - // descriptor count alone can understate the real audio duration whenever - // Windows submits padded or coalesced packets. Completing those URBs too - // quickly makes the host audio engine burst, then underrun. - pcmDuration := time.Duration(0) - if actualLen > 0 && dualSenseAudioOutEndpoint(desc, ep) { - pcmDuration = time.Duration((actualLen+383)/384) * time.Millisecond - } - - return min(max(packetDuration, pcmDuration), 100*time.Millisecond) -} - -func dualSenseAudioOutEndpoint(desc *usb.Descriptor, ep uint32) bool { - for _, iface := range desc.Interfaces { - for _, endpoint := range iface.Endpoints { - if endpoint.BEndpointAddress == uint8(ep) && - endpoint.BMAttributes&0x03 == 0x01 && - endpoint.WMaxPacketSize == 392 { - return true - } - } - } - - return false + return min(time.Duration(packetCount)*usbServiceInterval(desc.Device.Speed, bInterval), 100*time.Millisecond) } // isClientDisconnect tests whether an error represents a normal client From 4928cea3ce123cf29c6ca83ee61ba1570bc0e085 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 22 Jun 2026 15:44:15 -0500 Subject: [PATCH 255/298] Add combined DualSense Bluetooth haptics feedback --- device/dualsense/bthaptics.go | 74 ++++++++++++++++++++++++ device/dualsense/bthaptics_test.go | 43 ++++++++++++++ device/dualsense/const.go | 6 ++ device/dualsense/device.go | 31 +++++++--- device/dualsense/device_output_test.go | 78 ++++++++++++++++++++++++++ device/dualsense/ds_handler.go | 9 ++- device/dualsense/dsedge_handler.go | 9 ++- device/dualsense/state.go | 44 ++++++++++++++- 8 files changed, 280 insertions(+), 14 deletions(-) diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go index 6f25e4c6..31bfaf95 100644 --- a/device/dualsense/bthaptics.go +++ b/device/dualsense/bthaptics.go @@ -11,6 +11,16 @@ const ( 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 + USBHapticsAudioSampleRate = 48000 USBHapticsAudioChannels = 4 USBHapticsAudioBytesPerSample = 2 @@ -31,6 +41,16 @@ const ( 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) { @@ -59,6 +79,60 @@ func BuildBluetoothHapticsReport(sequence uint8, intervalIndex uint8, sample []b 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] = BluetoothHapticsSampleSize + report[6] = BluetoothHapticsSampleSize + report[7] = BluetoothHapticsSampleSize + report[8] = BluetoothHapticsSampleSize + report[9] = BluetoothHapticsSampleSize + 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. // diff --git a/device/dualsense/bthaptics_test.go b/device/dualsense/bthaptics_test.go index 3ce49270..c7ed6a21 100644 --- a/device/dualsense/bthaptics_test.go +++ b/device/dualsense/bthaptics_test.go @@ -53,6 +53,49 @@ func TestBuildBluetoothHapticsReportRejectsWrongSampleSize(t *testing.T) { } } +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 !bytes.Equal(report[2:11], []byte{0x91, 0x07, 0xFE, 64, 64, 64, 64, 64, 0x37}) { + t.Fatalf("unexpected packet 0x11 body: % x", report[2:11]) + } + 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 diff --git a/device/dualsense/const.go b/device/dualsense/const.go index aef736b7..ce38579d 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -55,6 +55,12 @@ const ( 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 ( diff --git a/device/dualsense/device.go b/device/dualsense/device.go index cb6a65e9..bb94740d 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -26,10 +26,11 @@ type DualSense struct { inputState *InputState metaState *MetaState - outputFunc func(OutputState) - outputState OutputState - descriptor usb.Descriptor - extendedFeedback bool + outputFunc func(OutputState) + outputState OutputState + descriptor usb.Descriptor + extendedFeedback bool + combinedBluetoothFeedback bool subcommand [2]byte @@ -224,7 +225,12 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { continue } - recordTrafficBytes("device->physical", "saxense-hid-0x32", + 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]), @@ -233,7 +239,11 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { if d.outputFunc != nil { d.mtx.Lock() feedback := d.outputState - copy(feedback.BluetoothHapticsOutputReport[:], report) + if d.combinedBluetoothFeedback { + copy(feedback.BluetoothCombinedOutputReport[:], report) + } else { + copy(feedback.BluetoothHapticsOutputReport[:], report) + } d.mtx.Unlock() d.outputFunc(feedback) } @@ -259,7 +269,13 @@ func (d *DualSense) drainBluetoothHapticsReportsLocked() [][]byte { d.hapticsSeq++ d.hapticsInterval++ - report, err := BuildBluetoothHapticsReport(seq, interval, sample) + 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 { @@ -522,6 +538,7 @@ var featureGetHandlers = map[byte]func(*DualSense) []byte{ 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]) } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 6380f98a..613945a1 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -316,6 +316,56 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { } } +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 + }) + + 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 { @@ -560,3 +610,31 @@ func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { 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/ds_handler.go b/device/dualsense/ds_handler.go index 76f621ac..4cc7db58 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -16,10 +16,12 @@ import ( func init() { api.RegisterDevice("dualsense", &dshandler{}) api.RegisterDevice("dualsenseext", &dshandler{extendedFeedback: true}) + api.RegisterDevice("dualsensecombinedext", &dshandler{combinedBluetoothFeedback: true}) } type dshandler struct { - extendedFeedback bool + extendedFeedback bool + combinedBluetoothFeedback bool } func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -86,6 +88,7 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return nil, err } dse.extendedFeedback = h.extendedFeedback + dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback return dse, nil } @@ -120,7 +123,9 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { dse.SetOutputCallback(func(feedback OutputState) { var data []byte var err error - if h.extendedFeedback || dse.extendedFeedback { + if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback { + data, err = feedback.MarshalCombinedExtendedBinary() + } else if h.extendedFeedback || dse.extendedFeedback { data, err = feedback.MarshalExtendedBinary() } else { data, err = feedback.MarshalBinary() diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index 675fba0a..6ad5d4ca 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -16,10 +16,12 @@ import ( func init() { api.RegisterDevice("dualsenseedge", &dsedgehandler{}) api.RegisterDevice("dualsenseedgeext", &dsedgehandler{extendedFeedback: true}) + api.RegisterDevice("dualsenseedgecombinedext", &dsedgehandler{combinedBluetoothFeedback: true}) } type dsedgehandler struct { - extendedFeedback bool + extendedFeedback bool + combinedBluetoothFeedback bool } func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -86,6 +88,7 @@ func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error return nil, err } dse.extendedFeedback = h.extendedFeedback + dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback return dse, nil } @@ -120,7 +123,9 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { dse.SetOutputCallback(func(feedback OutputState) { var data []byte var err error - if h.extendedFeedback || dse.extendedFeedback { + if h.combinedBluetoothFeedback || dse.combinedBluetoothFeedback { + data, err = feedback.MarshalCombinedExtendedBinary() + } else if h.extendedFeedback || dse.extendedFeedback { data, err = feedback.MarshalExtendedBinary() } else { data, err = feedback.MarshalBinary() diff --git a/device/dualsense/state.go b/device/dualsense/state.go index 1c63efca..ce2d9fb2 100644 --- a/device/dualsense/state.go +++ b/device/dualsense/state.go @@ -123,8 +123,9 @@ type OutputState struct { TriggerL2PressedStrength uint8 TriggerL2Frequency uint8 - RawOutputReport [OutputReportSize]byte - BluetoothHapticsOutputReport [BluetoothHapticsReportSize]byte + RawOutputReport [OutputReportSize]byte + BluetoothHapticsOutputReport [BluetoothHapticsReportSize]byte + BluetoothCombinedOutputReport [BluetoothCombinedHapticsReportSize]byte } func (f *OutputState) MarshalBinary() ([]byte, error) { @@ -169,6 +170,40 @@ func (f *OutputState) MarshalExtendedBinary() ([]byte, error) { 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 @@ -199,7 +234,10 @@ func (f *OutputState) UnmarshalBinary(data []byte) error { f.TriggerL2NearMiddleStrength = data[22] f.TriggerL2PressedStrength = data[23] f.TriggerL2Frequency = data[26] - if len(data) >= OutputStateExtSize { + 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 { From 2534f6925253cd2ff3fb5fc3a4a8deb4f52a6033 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 23 Jun 2026 13:58:21 -0500 Subject: [PATCH 256/298] Match virtual DualSense USB product identity --- device/dualsense/descriptor.go | 6 +++++- device/dualsense/device_output_test.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index aded5198..1013d369 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -296,7 +296,11 @@ var defaultDescriptor = usb.Descriptor{ Strings: map[uint8]string{ 0: "\u0409", // LangID: en-US (0x0409) 1: "Sony Interactive Entertainment", - 2: "Wireless Controller", + // 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", }, } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 613945a1..8226b413 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -41,7 +41,7 @@ func TestDualSenseDescriptorDoesNotAdvertiseEdgeFeatureReports(t *testing.T) { if desc.Device.IDProduct != DefaultPIDDS { t.Fatalf("unexpected DualSense PID: %#x", desc.Device.IDProduct) } - if desc.Strings[2] != "Wireless Controller" { + if desc.Strings[2] != "DualSense Wireless Controller" { t.Fatalf("unexpected DualSense product string: %q", desc.Strings[2]) } From 425481b4e009f4cf892ff2e083697cd7ebe25dc2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 23 Jun 2026 14:04:07 -0500 Subject: [PATCH 257/298] Match DualSense USB configuration identity --- device/dualsense/descriptor.go | 8 ++++++++ device/dualsense/device_output_test.go | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 1013d369..8421771e 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -21,6 +21,14 @@ var defaultDescriptor = usb.Descriptor{ 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{ diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 8226b413..75954566 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -69,6 +69,11 @@ func TestDualSenseDescriptorAdvertisesExperimentalHapticsAudioEndpoint(t *testin 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()) } From c4726f0dc299c1bfcb8797741d3448d90c430436 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 23 Jun 2026 19:56:54 -0500 Subject: [PATCH 258/298] Reduce DualSense Bluetooth haptics latency --- device/dualsense/bthaptics.go | 16 +++++++--- device/dualsense/bthaptics_test.go | 8 ++++- device/dualsense/device.go | 50 +++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/device/dualsense/bthaptics.go b/device/dualsense/bthaptics.go index 31bfaf95..8c655e05 100644 --- a/device/dualsense/bthaptics.go +++ b/device/dualsense/bthaptics.go @@ -20,6 +20,12 @@ const ( 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 @@ -101,11 +107,11 @@ func BuildBluetoothCombinedHapticsReport(sequence uint8, packetSequence uint8, s report[2] = 0x91 report[3] = 0x07 report[4] = 0xFE - report[5] = BluetoothHapticsSampleSize - report[6] = BluetoothHapticsSampleSize - report[7] = BluetoothHapticsSampleSize - report[8] = BluetoothHapticsSampleSize - report[9] = BluetoothHapticsSampleSize + 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 diff --git a/device/dualsense/bthaptics_test.go b/device/dualsense/bthaptics_test.go index c7ed6a21..d3217915 100644 --- a/device/dualsense/bthaptics_test.go +++ b/device/dualsense/bthaptics_test.go @@ -73,9 +73,15 @@ func TestBuildBluetoothCombinedHapticsReportMatchesVDSLayout(t *testing.T) { if report[0] != BluetoothCombinedHapticsReportID || report[1] != 0xA0 { t.Fatalf("unexpected 0x36 report header: % x", report[:2]) } - if !bytes.Equal(report[2:11], []byte{0x91, 0x07, 0xFE, 64, 64, 64, 64, 64, 0x37}) { + 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]) } diff --git a/device/dualsense/device.go b/device/dualsense/device.go index bb94740d..4fa98f86 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -38,7 +38,11 @@ type DualSense struct { hapticsSeq uint8 hapticsInterval uint8 hapticsPCM []byte - timestampBase time.Time + // 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 } @@ -210,17 +214,22 @@ 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 len(d.hapticsPCM) == 0 { + d.hapticsPCMStartedAt = receivedAt + } d.hapticsPCM = append(d.hapticsPCM, out...) - reports := d.drainBluetoothHapticsReportsLocked() + reports := d.drainBluetoothHapticsReportsLocked(receivedAt) d.mtx.Unlock() - for _, report := range reports { + for _, pending := range reports { + report := pending.data if len(report) == 0 { continue } @@ -234,7 +243,9 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { report, "reportType", "output", "reportID", fmt.Sprintf("0x%02X", report[0]), - "summary", fmt.Sprintf("from audio ep=%d bytes=%d", EndpointHapticsAudioOut, BluetoothHapticsSampleSize)) + "summary", fmt.Sprintf("from audio ep=%d bytes=%d assemblyMs=%.3f", + EndpointHapticsAudioOut, BluetoothHapticsSampleSize, + float64(pending.assemblyDelay.Microseconds())/1000.0)) if d.outputFunc != nil { d.mtx.Lock() @@ -245,12 +256,27 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { copy(feedback.BluetoothHapticsOutputReport[:], report) } d.mtx.Unlock() + dispatchStarted := time.Now() d.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), + }) } } } -func (d *DualSense) drainBluetoothHapticsReportsLocked() [][]byte { +type pendingBluetoothHapticsReport struct { + data []byte + assemblyDelay time.Duration +} + +func (d *DualSense) drainBluetoothHapticsReportsLocked(now time.Time) []pendingBluetoothHapticsReport { const inputBytesPerReport = (BluetoothHapticsSampleSize / 2) * USBHapticsAudioDownsample * USBHapticsAudioFrameSize @@ -259,7 +285,7 @@ func (d *DualSense) drainBluetoothHapticsReportsLocked() [][]byte { return nil } - reports := make([][]byte, 0, len(d.hapticsPCM)/inputBytesPerReport) + reports := make([]pendingBluetoothHapticsReport, 0, len(d.hapticsPCM)/inputBytesPerReport) for len(d.hapticsPCM) >= inputBytesPerReport { sample := make([]byte, BluetoothHapticsSampleSize) copyUSBHapticsChannelsToBluetoothSample(sample, d.hapticsPCM[:inputBytesPerReport]) @@ -279,11 +305,21 @@ func (d *DualSense) drainBluetoothHapticsReportsLocked() [][]byte { if err != nil { slog.Warn("failed to build DualSense Bluetooth haptics report", "error", err) } else { - reports = append(reports, report) + assemblyDelay := now.Sub(d.hapticsPCMStartedAt) + if d.hapticsPCMStartedAt.IsZero() || assemblyDelay < 0 { + assemblyDelay = 0 + } + reports = append(reports, pendingBluetoothHapticsReport{ + data: report, + 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 From b9dd8cfdaf210673836bafe8defe73f85a477f3b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 24 Jun 2026 09:20:01 -0500 Subject: [PATCH 259/298] Diagnose DualSense ISO audio pacing --- internal/server/usb/server.go | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 3749b5b6..24fba5b2 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -659,6 +659,75 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { lastInResp := map[uint32][]byte{} var outPayloadScratch []byte + var isoAudioWindowStarted time.Time + var isoAudioLastCompletion time.Time + var isoAudioURBs int + var isoAudioPackets int + var isoAudioBytes int + var isoAudioRequestedSleep time.Duration + var isoAudioActualSleep time.Duration + var isoAudioProcessing time.Duration + var isoAudioSleepOverruns int + var isoAudioMaximumSleepOverrun time.Duration + var isoAudioMaximumCompletionGap time.Duration + logIsoAudioWindow := func(ep uint32, payloadBytes, packetCount int, + requestedSleep, actualSleep, processing 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 + isoAudioRequestedSleep += requestedSleep + isoAudioActualSleep += actualSleep + isoAudioProcessing += processing + if actualSleep > requestedSleep { + overrun := actualSleep - requestedSleep + isoAudioSleepOverruns++ + if overrun > isoAudioMaximumSleepOverrun { + isoAudioMaximumSleepOverrun = overrun + } + } + + 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), + "requestedSleepMs", fmt.Sprintf("%.3f", float64(isoAudioRequestedSleep.Microseconds())/1000.0), + "actualSleepMs", fmt.Sprintf("%.3f", float64(isoAudioActualSleep.Microseconds())/1000.0), + "processingMs", fmt.Sprintf("%.3f", float64(isoAudioProcessing.Microseconds())/1000.0), + "sleepOverruns", isoAudioSleepOverruns, + "maxSleepOverrunMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumSleepOverrun.Microseconds())/1000.0), + "maxCompletionGapMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumCompletionGap.Microseconds())/1000.0)) + + isoAudioWindowStarted = now + isoAudioLastCompletion = now + isoAudioURBs = 0 + isoAudioPackets = 0 + isoAudioBytes = 0 + isoAudioRequestedSleep = 0 + isoAudioActualSleep = 0 + isoAudioProcessing = 0 + isoAudioSleepOverruns = 0 + isoAudioMaximumSleepOverrun = 0 + isoAudioMaximumCompletionGap = 0 + } for { select { @@ -823,14 +892,20 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { // 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. + processingStarted := time.Now() respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) + processingDuration := time.Since(processingStarted) actualLen := uint32(len(respData)) if dir == usbip.DirOut { actualLen = uint32(len(outPayload)) } completedPackets := completeIsoPackets(isoPackets, actualLen) if dir == usbip.DirOut && isIso { - time.Sleep(isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets))) + requestedSleep := isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets)) + sleepStarted := time.Now() + time.Sleep(requestedSleep) + logIsoAudioWindow(ep, len(outPayload), len(isoPackets), requestedSleep, + time.Since(sleepStarted), processingDuration) } if err := writeRet(seq, actualLen, respData, completedPackets, isIso, ep == 0 || isIso); err != nil { return err From 3332717f792ef7c11125a36a715b9ad478d19bc7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 24 Jun 2026 09:20:56 -0500 Subject: [PATCH 260/298] Limit ISO pacing diagnostics to audio transfers --- internal/server/usb/server.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 24fba5b2..82f14429 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -892,15 +892,22 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { // 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. - processingStarted := time.Now() + isIsoOut := dir == usbip.DirOut && isIso + var processingStarted time.Time + if isIsoOut { + processingStarted = time.Now() + } respData := s.processSubmit(ctx, dev, ep, dir, setup, outPayload) - processingDuration := time.Since(processingStarted) + 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 dir == usbip.DirOut && isIso { + if isIsoOut { requestedSleep := isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets)) sleepStarted := time.Now() time.Sleep(requestedSleep) From aa45ffef7a1a5bf143b7e2e8f1f0acfbb027a6ba Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 24 Jun 2026 09:32:11 -0500 Subject: [PATCH 261/298] Pace ISO audio completions against service deadlines --- internal/server/usb/server.go | 87 ++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 82f14429..b9755e88 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -664,14 +664,17 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var isoAudioURBs int var isoAudioPackets int var isoAudioBytes int - var isoAudioRequestedSleep time.Duration - var isoAudioActualSleep time.Duration + var isoAudioTransferDuration time.Duration + var isoAudioScheduledWait time.Duration + var isoAudioActualWait time.Duration var isoAudioProcessing time.Duration - var isoAudioSleepOverruns int - var isoAudioMaximumSleepOverrun 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, - requestedSleep, actualSleep, processing time.Duration) { + transferDuration, scheduledWait, actualWait, processing, deadlineLateness time.Duration) { now := time.Now() if isoAudioWindowStarted.IsZero() { isoAudioWindowStarted = now @@ -686,16 +689,20 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { isoAudioURBs++ isoAudioPackets += packetCount isoAudioBytes += payloadBytes - isoAudioRequestedSleep += requestedSleep - isoAudioActualSleep += actualSleep + isoAudioTransferDuration += transferDuration + isoAudioScheduledWait += scheduledWait + isoAudioActualWait += actualWait isoAudioProcessing += processing - if actualSleep > requestedSleep { - overrun := actualSleep - requestedSleep - isoAudioSleepOverruns++ - if overrun > isoAudioMaximumSleepOverrun { - isoAudioMaximumSleepOverrun = overrun + 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 { @@ -709,24 +716,28 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { "isoPackets", isoAudioPackets, "bytes", isoAudioBytes, "framesPerSecond", fmt.Sprintf("%.1f", framesPerSecond), - "requestedSleepMs", fmt.Sprintf("%.3f", float64(isoAudioRequestedSleep.Microseconds())/1000.0), - "actualSleepMs", fmt.Sprintf("%.3f", float64(isoAudioActualSleep.Microseconds())/1000.0), + "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), - "sleepOverruns", isoAudioSleepOverruns, - "maxSleepOverrunMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumSleepOverrun.Microseconds())/1000.0), - "maxCompletionGapMs", fmt.Sprintf("%.3f", float64(isoAudioMaximumCompletionGap.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 - isoAudioRequestedSleep = 0 - isoAudioActualSleep = 0 + isoAudioTransferDuration = 0 + isoAudioScheduledWait = 0 + isoAudioActualWait = 0 isoAudioProcessing = 0 - isoAudioSleepOverruns = 0 - isoAudioMaximumSleepOverrun = 0 + isoAudioWaitOverruns = 0 + isoAudioMaximumWaitOverrun = 0 isoAudioMaximumCompletionGap = 0 + isoAudioMaximumDeadlineLateness = 0 } for { @@ -893,8 +904,23 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { // 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) @@ -908,11 +934,20 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } completedPackets := completeIsoPackets(isoPackets, actualLen) if isIsoOut { - requestedSleep := isoCompletionDelay(dev.GetDescriptor(), ep, len(isoPackets)) - sleepStarted := time.Now() - time.Sleep(requestedSleep) - logIsoAudioWindow(ep, len(outPayload), len(isoPackets), requestedSleep, - time.Since(sleepStarted), processingDuration) + 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 From d1f0f3247afc5a04271b531835b707acc4339e01 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 24 Jun 2026 09:46:42 -0500 Subject: [PATCH 262/298] Stabilize USB/IP integration test deadlines --- _testing/test_server.go | 4 ++++ _testing/usbip_client.go | 2 +- device/dualshock4/dualshock4_test.go | 4 ++-- device/keyboard/keyboard_test.go | 4 ++-- device/mouse/mouse_test.go | 3 +-- device/ns2pro/ns2pro_test.go | 6 +++--- device/xbox360/xbox360_test.go | 4 ++-- internal/server/api/server_test.go | 4 ++-- 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/_testing/test_server.go b/_testing/test_server.go index 85829923..d8ab6e8a 100644 --- a/_testing/test_server.go +++ b/_testing/test_server.go @@ -12,6 +12,10 @@ import ( "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 diff --git a/_testing/usbip_client.go b/_testing/usbip_client.go index a3c4cf46..31c356fc 100644 --- a/_testing/usbip_client.go +++ b/_testing/usbip_client.go @@ -213,7 +213,7 @@ func readExportedDeviceWithRawInternal(r net.Conn, readIfaces bool) (Device, []b } 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, 750*time.Millisecond) + 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 { diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 3c050086..6704c15c 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -371,7 +371,7 @@ func TestInputReports(t *testing.T) { if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { return } - got, err := pollInputReport(tc.expectedReport, 750*time.Millisecond) + got, err := pollInputReport(tc.expectedReport, viiperTesting.IntegrationTimeout) if !assert.NoError(t, err) { return } @@ -470,7 +470,7 @@ func TestFeedback(t *testing.T) { return } var buf [7]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) _, err := io.ReadFull(stream, buf[:]) if !assert.NoError(t, err) { return diff --git a/device/keyboard/keyboard_test.go b/device/keyboard/keyboard_test.go index d11ba3b2..1fddf566 100644 --- a/device/keyboard/keyboard_test.go +++ b/device/keyboard/keyboard_test.go @@ -109,7 +109,7 @@ func TestInputReports(t *testing.T) { if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { return } - got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, 750*time.Millisecond) + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) if !assert.NoError(t, err) { return } @@ -203,7 +203,7 @@ func TestLEDs(t *testing.T) { return } var buf [1]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) _, err := io.ReadFull(stream, buf[:]) if !assert.NoError(t, err) { return diff --git a/device/mouse/mouse_test.go b/device/mouse/mouse_test.go index 0864ae84..6fa32835 100644 --- a/device/mouse/mouse_test.go +++ b/device/mouse/mouse_test.go @@ -3,7 +3,6 @@ package mouse_test import ( "context" "testing" - "time" viiperTesting "github.com/Alia5/VIIPER/_testing" "github.com/Alia5/VIIPER/device/mouse" @@ -184,7 +183,7 @@ func TestInputReports(t *testing.T) { if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { return } - got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, 750*time.Millisecond) + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) if !assert.NoError(t, err) { return } diff --git a/device/ns2pro/ns2pro_test.go b/device/ns2pro/ns2pro_test.go index 5eef95e6..f5f93e5b 100644 --- a/device/ns2pro/ns2pro_test.go +++ b/device/ns2pro/ns2pro_test.go @@ -451,7 +451,7 @@ func TestStreamInputAndRumble(t *testing.T) { require.NoError(t, stream.WriteBinary(&state)) expected := state.buildProReport(0, FeatureButtons|FeatureSticks, *defaultMetaState()) - got := pollInputIgnoringCounter(t, usbipClient, imp.Conn, expected, 750*time.Millisecond) + got := pollInputIgnoringCounter(t, usbipClient, imp.Conn, expected, viiperTesting.IntegrationTimeout) require.Len(t, got, InputReportSize) got[1] = 0 assert.Equal(t, expected, got) @@ -465,7 +465,7 @@ func TestStreamInputAndRumble(t *testing.T) { require.NoError(t, usbipClient.Submit(imp.Conn, usbip.DirOut, 1, rumble, nil)) var outBuf [OutputWireSize]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) _, err = io.ReadFull(stream, outBuf[:]) require.NoError(t, err) var out OutputState @@ -528,7 +528,7 @@ func controlIn(conn net.Conn, setup [8]byte) ([]byte, error) { TransferBufferLen: uint32(binary.LittleEndian.Uint16(setup[6:8])), Setup: setup, } - _ = conn.SetDeadline(time.Now().Add(750 * time.Millisecond)) + _ = conn.SetDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) defer conn.SetDeadline(time.Time{}) if err := cmd.Write(conn); err != nil { return nil, err diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go index 7e8a0a01..4738fc68 100644 --- a/device/xbox360/xbox360_test.go +++ b/device/xbox360/xbox360_test.go @@ -374,7 +374,7 @@ func TestInputReports(t *testing.T) { if !assert.NoError(t, stream.WriteBinary(&tc.inputState)) { return } - got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, 750*time.Millisecond) + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) if !assert.NoError(t, err) { return } @@ -466,7 +466,7 @@ func TestRumble(t *testing.T) { return } var buf [2]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) _, err := io.ReadFull(stream, buf[:]) if !assert.NoError(t, err) { return diff --git a/internal/server/api/server_test.go b/internal/server/api/server_test.go index 051f00cf..1912517b 100644 --- a/internal/server/api/server_test.go +++ b/internal/server/api/server_test.go @@ -242,7 +242,7 @@ func TestAPIServer_WrappedConn(t *testing.T) { return } - got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, 750*time.Millisecond) + got, err := usbipClient.PollInputReport(imp.Conn, tc.expectedReport, viiperTesting.IntegrationTimeout) if !assert.NoError(t, err) { return } @@ -252,7 +252,7 @@ func TestAPIServer_WrappedConn(t *testing.T) { return } var buf [2]byte - _ = stream.SetReadDeadline(time.Now().Add(750 * time.Millisecond)) + _ = stream.SetReadDeadline(time.Now().Add(viiperTesting.IntegrationTimeout)) _, err = io.ReadFull(stream, buf[:]) if !assert.NoError(t, err) { return From dc475f1ded73d3f353950e2ff4c81d7a3c7c14f1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Wed, 24 Jun 2026 19:11:41 -0500 Subject: [PATCH 263/298] Expose DualSense microphone input endpoint --- device/dualsense/const.go | 19 ++- device/dualsense/device.go | 56 ++++++++- device/dualsense/ds_handler.go | 40 ++++++ device/dualsense/dsedge_handler.go | 20 +-- .../dualsense-bluetooth-microphone.md | 119 ++++++++++++++++++ 5 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 docs/research/dualsense-bluetooth-microphone.md diff --git a/device/dualsense/const.go b/device/dualsense/const.go index ce38579d..122a368d 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -41,10 +41,21 @@ const ( ) const ( - InputReportSize = 64 - OutputReportSize = 48 - InputStateSize = 33 - OutputStateSize = 6 + InputReportSize = 64 + OutputReportSize = 48 + InputStateSize = 33 + OutputStateSize = 6 + StreamFrameInputState = 0x01 + StreamFrameMicrophonePCM = 0x02 + USBMicrophoneSampleRate = 48000 + USBMicrophoneChannels = 2 + USBMicrophoneBytesPerSample = 2 + USBMicrophonePacketFrames = USBMicrophoneSampleRate / 1000 + USBMicrophonePacketSize = USBMicrophonePacketFrames * + 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 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 4fa98f86..88cc5c9f 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -34,10 +34,12 @@ type DualSense struct { subcommand [2]byte - seqCounter uint8 - hapticsSeq uint8 - hapticsInterval uint8 - hapticsPCM []byte + seqCounter uint8 + hapticsSeq uint8 + hapticsInterval uint8 + hapticsPCM []byte + microphonePCM []byte + microphoneSignal chan struct{} // 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. @@ -101,8 +103,9 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { } d := &DualSense{ - descriptor: makeDescriptor(edge), - metaState: metaState, + descriptor: makeDescriptor(edge), + metaState: metaState, + microphoneSignal: make(chan struct{}, 1), } if o != nil { @@ -189,6 +192,8 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o d.mtx.Unlock() return d.buildUSBInputReport(is, &ms) } + case EndpointMicrophoneIn: + return d.handleMicrophoneIn(ctx) default: return nil } @@ -210,6 +215,45 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o return nil } +func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { + if len(frame) != USBMicrophoneClientFrameSize { + return + } + + d.mtx.Lock() + if len(d.microphonePCM) > USBMicrophoneClientFrameSize*4 { + d.microphonePCM = d.microphonePCM[len(d.microphonePCM)-USBMicrophoneClientFrameSize*4:] + } + d.microphonePCM = append(d.microphonePCM, frame...) + d.mtx.Unlock() + + select { + case d.microphoneSignal <- struct{}{}: + default: + } +} + +func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { + for { + d.mtx.Lock() + if len(d.microphonePCM) > 0 { + packet := make([]byte, USBMicrophonePacketSize) + n := copy(packet, d.microphonePCM) + copy(d.microphonePCM, d.microphonePCM[n:]) + d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-n] + d.mtx.Unlock() + return packet + } + d.mtx.Unlock() + + select { + case <-ctx.Done(): + return make([]byte, USBMicrophonePacketSize) + case <-d.microphoneSignal: + } + } +} + func (d *DualSense) handleHapticsAudioOut(out []byte) { if len(out) == 0 { return diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 4cc7db58..9cc21c11 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -17,11 +17,13 @@ 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}) } type dshandler struct { extendedFeedback bool combinedBluetoothFeedback bool + microphoneInput bool } func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -139,6 +141,12 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } }) + return readDualSenseInputStream(conn, dse, logger, h.microphoneInput) + } +} + +func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger, microphoneInput bool) error { + if !microphoneInput { buf := make([]byte, InputStateSize) for { if _, err := io.ReadFull(conn, buf); err != nil { @@ -156,6 +164,38 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { dse.UpdateInputState(&state) } } + + header := make([]byte, 1) + input := make([]byte, InputStateSize) + microphonePCM := make([]byte, USBMicrophoneClientFrameSize) + 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 type: %w", err) + } + + switch header[0] { + case StreamFrameInputState: + if _, err := io.ReadFull(conn, input); err != nil { + return fmt.Errorf("read framed input state: %w", err) + } + var state InputState + if err := state.UnmarshalBinary(input); err != nil { + return fmt.Errorf("unmarshal framed input state: %w", err) + } + dse.UpdateInputState(&state) + case StreamFrameMicrophonePCM: + if _, err := io.ReadFull(conn, microphonePCM); err != nil { + return fmt.Errorf("read microphone pcm frame: %w", err) + } + dse.QueueMicrophonePCMFrame(microphonePCM) + default: + return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X", header[0]) + } + } } func (h *dshandler) UpdateMetaState(meta string, dev *usb.Device) error { diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index 6ad5d4ca..3e0aef3d 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -3,7 +3,6 @@ package dualsense import ( "encoding/json" "fmt" - "io" "log/slog" "net" "strings" @@ -17,11 +16,13 @@ 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}) } type dsedgehandler struct { extendedFeedback bool combinedBluetoothFeedback bool + microphoneInput bool } func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -139,22 +140,7 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } }) - 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) - } + return readDualSenseInputStream(conn, dse, logger, h.microphoneInput) } } 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. From 81b3e494d5021c99d3512b46b8ecec8b865b48f7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 25 Jun 2026 21:02:53 -0500 Subject: [PATCH 264/298] Harden DualSense mic stream framing --- device/dualsense/const.go | 6 ++ device/dualsense/ds_handler.go | 29 ++++++- device/dualsense/ds_handler_test.go | 118 ++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 device/dualsense/ds_handler_test.go diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 122a368d..e4cbb8eb 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -45,6 +45,12 @@ const ( OutputReportSize = 48 InputStateSize = 33 OutputStateSize = 6 + StreamFrameHeaderSize = 8 + StreamFrameMagic0 = byte('V') + StreamFrameMagic1 = byte('P') + StreamFrameMagic2 = byte('C') + StreamFrameMagic3 = byte('M') + StreamFrameVersion = 0x01 StreamFrameInputState = 0x01 StreamFrameMicrophonePCM = 0x02 USBMicrophoneSampleRate = 48000 diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 9cc21c11..4b33102b 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -1,6 +1,7 @@ package dualsense import ( + "encoding/binary" "encoding/json" "fmt" "io" @@ -165,7 +166,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } } - header := make([]byte, 1) + header := make([]byte, StreamFrameHeaderSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) for { @@ -174,11 +175,28 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger logger.Info("client disconnected") return nil } - return fmt.Errorf("read stream frame type: %w", err) + return fmt.Errorf("read stream frame header: %w", err) } - switch header[0] { + 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] != StreamFrameVersion { + return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", header[4]) + } + + frameType := header[5] + payloadLen := int(binary.LittleEndian.Uint16(header[6:8])) + + switch frameType { case StreamFrameInputState: + if payloadLen != InputStateSize { + return fmt.Errorf("invalid framed input state length %d", payloadLen) + } if _, err := io.ReadFull(conn, input); err != nil { return fmt.Errorf("read framed input state: %w", err) } @@ -188,12 +206,15 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } dse.UpdateInputState(&state) case StreamFrameMicrophonePCM: + if payloadLen != USBMicrophoneClientFrameSize { + return fmt.Errorf("invalid microphone pcm frame length %d", payloadLen) + } if _, err := io.ReadFull(conn, microphonePCM); err != nil { return fmt.Errorf("read microphone pcm frame: %w", err) } dse.QueueMicrophonePCMFrame(microphonePCM) default: - return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X", header[0]) + return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X length %d", frameType, payloadLen) } } } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go new file mode 100644 index 00000000..a826e837 --- /dev/null +++ b/device/dualsense/ds_handler_test.go @@ -0,0 +1,118 @@ +package dualsense + +import ( + "encoding/binary" + "io" + "log/slog" + "net" + "strings" + "testing" +) + +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)) + 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(len(payload))) + copy(frame[StreamFrameHeaderSize:], payload) + return frame +} + +func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(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 = 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) + } + 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 + gotMicrophoneLen := len(dev.microphonePCM) + dev.mtx.Unlock() + + if gotInput.LX != 42 || gotInput.R2 != 99 { + t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2) + } + if gotMicrophoneLen != USBMicrophoneClientFrameSize { + t.Fatalf("unexpected microphone queue length: %d", gotMicrophoneLen) + } +} + +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) + } + + dev.mtx.Lock() + gotMicrophoneLen := len(dev.microphonePCM) + dev.mtx.Unlock() + if gotMicrophoneLen != 0 { + t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen) + } +} From 81ab9eeb018c67d2f232cfa54373b1dec02a660a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 26 Jun 2026 07:03:17 -0500 Subject: [PATCH 265/298] Use numeric DualSense stream magic constants --- device/dualsense/const.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index e4cbb8eb..68de48a3 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -46,10 +46,10 @@ const ( InputStateSize = 33 OutputStateSize = 6 StreamFrameHeaderSize = 8 - StreamFrameMagic0 = byte('V') - StreamFrameMagic1 = byte('P') - StreamFrameMagic2 = byte('C') - StreamFrameMagic3 = byte('M') + StreamFrameMagic0 = 0x56 + StreamFrameMagic1 = 0x50 + StreamFrameMagic2 = 0x43 + StreamFrameMagic3 = 0x4D StreamFrameVersion = 0x01 StreamFrameInputState = 0x01 StreamFrameMicrophonePCM = 0x02 From 9929bc86049596c604199db117f62e4a464dbea2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 26 Jun 2026 09:10:16 -0500 Subject: [PATCH 266/298] Handle USB audio ISO-IN microphone packets --- internal/server/usb/server.go | 147 +++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index b9755e88..4eae3968 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -659,6 +659,8 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { lastInResp := map[uint32][]byte{} var outPayloadScratch []byte + var nextIsoInCompletion time.Time + var isoInCompletionMu sync.Mutex var isoAudioWindowStarted time.Time var isoAudioLastCompletion time.Time var isoAudioURBs int @@ -852,6 +854,40 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool) { defer urbCancel() var respData []byte + var completedPackets []usbip.IsoPacketDescriptor + if iso && len(submitted) > 0 { + isoTransferDuration := isoCompletionDelay(dev.GetDescriptor(), ep, len(submitted)) + respData, completedPackets = s.buildIsoInResponse(urbCtx, dev, ep, dir, submitted) + if isoTransferDuration > 0 { + isoInCompletionMu.Lock() + now := time.Now() + if nextIsoInCompletion.IsZero() || now.Sub(nextIsoInCompletion) > isoTransferDuration { + nextIsoInCompletion = now.Add(isoTransferDuration) + } else { + nextIsoInCompletion = nextIsoInCompletion.Add(isoTransferDuration) + } + isoCompletionDeadline := nextIsoInCompletion + isoInCompletionMu.Unlock() + + if wait := time.Until(isoCompletionDeadline); wait > 0 { + time.Sleep(wait) + } + } + + 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 { @@ -888,7 +924,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { delete(pending, seq) pendingMu.Unlock() - completedPackets := completeIsoPackets(submitted, uint32(len(respData))) + 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) @@ -999,6 +1035,94 @@ func completeIsoPackets(submitted []usbip.IsoPacketDescriptor, actualLen uint32) 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, +) ([]byte, []usbip.IsoPacketDescriptor) { + if len(submitted) == 0 { + return nil, nil + } + + interval := isoPacketInterval(dev.GetDescriptor(), ep) + if interval <= 0 { + interval = time.Millisecond + } + + actualLengths := make([]uint32, len(submitted)) + totalLen := uint32(0) + for _, packet := range submitted { + if packet.Length == 0 { + continue + } + end := packet.Offset + packet.Length + if end > totalLen { + totalLen = end + } + } + + respData := make([]byte, totalLen) + for i, packet := range submitted { + if packet.Length == 0 || packet.Offset >= uint32(len(respData)) { + continue + } + + attemptCtx, cancel := context.WithTimeout(ctx, interval) + packetData := s.processSubmit(attemptCtx, dev, ep, dir, nil, nil) + cancel() + if ctx.Err() != nil { + return nil, nil + } + if len(packetData) == 0 { + packetData = make([]byte, int(packet.Length)) + } + + available := uint32(len(respData)) - packet.Offset + desired := min(packet.Length, available) + actual := min(desired, uint32(len(packetData))) + copy(respData[packet.Offset:packet.Offset+actual], packetData[:actual]) + actualLengths[i] = actual + } + + completed := completeIsoPacketsWithActuals(submitted, actualLengths) + actualTotal := uint32(0) + for i, packet := range completed { + if i >= len(submitted) || packet.ActualLength == 0 { + continue + } + end := submitted[i].Offset + packet.ActualLength + if end > actualTotal { + actualTotal = end + } + } + if actualTotal < uint32(len(respData)) { + respData = respData[:actualTotal] + } + + return respData, completed +} + // 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. @@ -1027,6 +1151,27 @@ func isoCompletionDelay(desc *usb.Descriptor, ep uint32, packetCount int) time.D 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 From 711a95a7dbb2190a7464337c0092ec0400d642b2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 26 Jun 2026 10:00:58 -0500 Subject: [PATCH 267/298] Track DualSense mic interface state --- device/dualsense/const.go | 5 ++ device/dualsense/device.go | 42 +++++++++++-- device/dualsense/ds_handler.go | 47 +++++++++++++++ device/dualsense/ds_handler_test.go | 82 ++++++++++++++++++++++++++ internal/server/usb/descriptor_test.go | 11 +++- internal/server/usb/server.go | 39 +++++++++++- usb/device.go | 6 ++ 7 files changed, 222 insertions(+), 10 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 68de48a3..ca069d42 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -35,6 +35,11 @@ const ( EndpointMicrophoneIn = 0x82 ) +const ( + InterfaceHapticsAudio = 0x01 + InterfaceMicrophone = 0x02 +) + const ( ReportIDInput = 0x01 ReportIDOutput = 0x02 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 88cc5c9f..d09a025f 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -34,12 +34,14 @@ type DualSense struct { subcommand [2]byte - seqCounter uint8 - hapticsSeq uint8 - hapticsInterval uint8 - hapticsPCM []byte - microphonePCM []byte - microphoneSignal chan struct{} + seqCounter uint8 + hapticsSeq uint8 + hapticsInterval uint8 + hapticsPCM []byte + microphonePCM []byte + microphoneSignal chan struct{} + speakerInterfaceActive bool + microphoneInterfaceActive bool // 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. @@ -169,9 +171,27 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { if err != nil { return map[string]any{} } + res["speakerInterfaceActive"] = d.speakerInterfaceActive + res["microphoneInterfaceActive"] = d.microphoneInterfaceActive + res["queuedMicrophoneBytes"] = len(d.microphonePCM) return res } +func (d *DualSense) SetInterfaceAltSetting(iface, alt uint8) { + d.mtx.Lock() + defer d.mtx.Unlock() + + switch iface { + case InterfaceHapticsAudio: + d.speakerInterfaceActive = alt != 0 + case InterfaceMicrophone: + d.microphoneInterfaceActive = alt != 0 + if !d.microphoneInterfaceActive { + d.microphonePCM = nil + } + } +} + func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { if dir == usbip.DirIn { switch ep { @@ -221,6 +241,11 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { } d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.mtx.Unlock() + return + } + if len(d.microphonePCM) > USBMicrophoneClientFrameSize*4 { d.microphonePCM = d.microphonePCM[len(d.microphonePCM)-USBMicrophoneClientFrameSize*4:] } @@ -236,6 +261,11 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { for { d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.mtx.Unlock() + return make([]byte, USBMicrophonePacketSize) + } + if len(d.microphonePCM) > 0 { packet := make([]byte, USBMicrophonePacketSize) n := copy(packet, d.microphonePCM) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 4b33102b..76cdc9f0 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -169,6 +169,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger header := make([]byte, StreamFrameHeaderSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) + corruptMotionFrames := 0 for { if _, err := io.ReadFull(conn, header); err != nil { if err == io.EOF { @@ -200,6 +201,13 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger if _, err := io.ReadFull(conn, input); err != nil { return fmt.Errorf("read framed input state: %w", err) } + if sanitizeInputStateTransportSignature(input) { + corruptMotionFrames++ + if corruptMotionFrames <= 128 || isPowerOfTwo(corruptMotionFrames) { + logger.Warn("DualSense framed input contained transport signature in motion payload; motion reset to neutral", + "count", corruptMotionFrames) + } + } var state InputState if err := state.UnmarshalBinary(input); err != nil { return fmt.Errorf("unmarshal framed input state: %w", err) @@ -219,6 +227,45 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } } +func sanitizeInputStateTransportSignature(input []byte) bool { + const motionOffset = 21 + const motionLength = 12 + if !containsStreamMagic(input, motionOffset, motionLength) { + return false + } + + for i := motionOffset; i < motionOffset+motionLength && i < len(input); i++ { + input[i] = 0 + } + if len(input) >= motionOffset+motionLength { + binary.LittleEndian.PutUint16(input[31:33], uint16(int(DefaultAccelZRaw)&0xFFFF)) + } + return true +} + +func containsStreamMagic(data []byte, offset int, length int) bool { + if len(data) < 4 || length < 4 { + return false + } + + start := max(offset, 0) + end := min(offset+length, len(data)) + for i := start; i+3 < end; i++ { + if data[i] == StreamFrameMagic0 && + data[i+1] == StreamFrameMagic1 && + data[i+2] == StreamFrameMagic2 && + data[i+3] == StreamFrameMagic3 { + 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 { diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index a826e837..6009a966 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -32,6 +32,7 @@ func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { if err != nil { t.Fatalf("New returned error: %v", err) } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -116,3 +117,84 @@ func TestReadDualSenseInputStreamRejectsUnversionedMicFrames(t *testing.T) { 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 len(dev.microphonePCM) != 0 { + t.Fatalf("inactive mic interface should drop PCM, got %d bytes", len(dev.microphonePCM)) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + dev.QueueMicrophonePCMFrame(frame) + if len(dev.microphonePCM) != USBMicrophoneClientFrameSize { + t.Fatalf("active mic interface should queue PCM, got %d bytes", len(dev.microphonePCM)) + } + + dev.SetInterfaceAltSetting(InterfaceMicrophone, 0) + if len(dev.microphonePCM) != 0 { + t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", len(dev.microphonePCM)) + } +} + +func TestReadDualSenseInputStreamSanitizesTransportMagicInMotion(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 = 12 + state.R2 = 34 + state.Buttons = ButtonCross + state.GyroX = 111 + state.GyroY = 222 + state.GyroZ = 333 + state.AccelX = 444 + state.AccelY = 555 + state.AccelZ = 666 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[25:29], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) + + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := *dev.inputState + dev.mtx.Unlock() + + if gotInput.LX != state.LX || gotInput.R2 != state.R2 || gotInput.Buttons != state.Buttons { + t.Fatalf("non-motion input changed: got LX=%d R2=%d buttons=%#x", gotInput.LX, gotInput.R2, gotInput.Buttons) + } + if gotInput.GyroX != 0 || gotInput.GyroY != 0 || gotInput.GyroZ != 0 || + gotInput.AccelX != DefaultAccelXRaw || gotInput.AccelY != DefaultAccelYRaw || gotInput.AccelZ != DefaultAccelZRaw { + t.Fatalf("motion was not sanitized: gyro=%d,%d,%d accel=%d,%d,%d", + gotInput.GyroX, gotInput.GyroY, gotInput.GyroZ, + gotInput.AccelX, gotInput.AccelY, gotInput.AccelZ) + } +} diff --git a/internal/server/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go index 03da589b..1c616772 100644 --- a/internal/server/usb/descriptor_test.go +++ b/internal/server/usb/descriptor_test.go @@ -12,7 +12,8 @@ import ( ) type altSettingTestDevice struct { - desc *usbdesc.Descriptor + desc *usbdesc.Descriptor + altEvents [][2]uint8 } func (d altSettingTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { @@ -27,6 +28,10 @@ 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{ @@ -103,7 +108,7 @@ func TestProcessSubmitTracksInterfaceAlternateSetting(t *testing.T) { }, }, } - dev := altSettingTestDevice{desc: desc} + dev := &altSettingTestDevice{desc: desc} server := New(ServerConfig{}, nil, nil) getAlt := []byte{0x81, usbReqGetInterface, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00} @@ -113,6 +118,8 @@ func TestProcessSubmitTracksInterfaceAlternateSetting(t *testing.T) { 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) } diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 4eae3968..60147bbc 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -1221,6 +1221,7 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d } if breq == usbReqSetConfiguration && bm == usbReqTypeStandardToDevice { s.clearInterfaceAlt(dev) + s.notifyInterfaceAltsCleared(dev) return nil } if breq == usbReqGetConfiguration && bm == usbReqTypeStandardFromDevice { @@ -1231,8 +1232,11 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d } if breq == usbReqSetInterface && bm == usbReqTypeStandardFromInterface { desc := dev.GetDescriptor() - if descriptorHasInterfaceAlt(desc, uint8(wIndex&usbIfaceIndexMask), uint8(wValue&0xff)) { - s.setInterfaceAlt(dev, uint8(wIndex&usbIfaceIndexMask), uint8(wValue&0xff)) + 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 } @@ -1440,6 +1444,37 @@ func descriptorHasInterfaceAlt(desc *usb.Descriptor, ifaceNumber, altSetting uin 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() diff --git a/usb/device.go b/usb/device.go index 2248dd28..c32f6b52 100644 --- a/usb/device.go +++ b/usb/device.go @@ -31,3 +31,9 @@ type ControlDevice interface { // 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) +} From d7f306f8bcd949919d368b680a4a0a701448235a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 26 Jun 2026 10:24:13 -0500 Subject: [PATCH 268/298] Drop corrupted DualSense mic stream input frames --- device/dualsense/ds_handler.go | 48 ++++++++++++------- device/dualsense/ds_handler_test.go | 74 ++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 76cdc9f0..df699f7a 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -169,7 +169,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger header := make([]byte, StreamFrameHeaderSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) - corruptMotionFrames := 0 + corruptInputFrames := 0 for { if _, err := io.ReadFull(conn, header); err != nil { if err == io.EOF { @@ -202,10 +202,10 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger return fmt.Errorf("read framed input state: %w", err) } if sanitizeInputStateTransportSignature(input) { - corruptMotionFrames++ - if corruptMotionFrames <= 128 || isPowerOfTwo(corruptMotionFrames) { - logger.Warn("DualSense framed input contained transport signature in motion payload; motion reset to neutral", - "count", corruptMotionFrames) + corruptInputFrames++ + if corruptInputFrames <= 128 || isPowerOfTwo(corruptInputFrames) { + logger.Warn("DualSense framed input contained transport signature; input reset to neutral", + "count", corruptInputFrames) } } var state InputState @@ -228,33 +228,36 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } func sanitizeInputStateTransportSignature(input []byte) bool { - const motionOffset = 21 - const motionLength = 12 - if !containsStreamMagic(input, motionOffset, motionLength) { + if !containsStreamFrameHeader(input, 0, len(input)) { return false } - for i := motionOffset; i < motionOffset+motionLength && i < len(input); i++ { - input[i] = 0 - } - if len(input) >= motionOffset+motionLength { - binary.LittleEndian.PutUint16(input[31:33], uint16(int(DefaultAccelZRaw)&0xFFFF)) + neutral, err := NewInputState().MarshalBinary() + if err != nil { + for i := range input { + input[i] = 0 + } + return true } + + copy(input, neutral) return true } -func containsStreamMagic(data []byte, offset int, length int) bool { - if len(data) < 4 || length < 4 { +func containsStreamFrameHeader(data []byte, offset int, length int) bool { + if len(data) < StreamFrameHeaderSize || length < StreamFrameHeaderSize { return false } start := max(offset, 0) end := min(offset+length, len(data)) - for i := start; i+3 < end; i++ { + for i := start; i+StreamFrameHeaderSize <= end; i++ { if data[i] == StreamFrameMagic0 && data[i+1] == StreamFrameMagic1 && data[i+2] == StreamFrameMagic2 && - data[i+3] == StreamFrameMagic3 { + data[i+3] == StreamFrameMagic3 && + data[i+4] == StreamFrameVersion && + isKnownStreamFrame(data[i+5], binary.LittleEndian.Uint16(data[i+6:i+8])) { return true } } @@ -262,6 +265,17 @@ func containsStreamMagic(data []byte, offset int, length int) bool { return false } +func isKnownStreamFrame(frameType byte, payloadLength uint16) bool { + switch frameType { + case StreamFrameInputState: + return payloadLength == InputStateSize + case StreamFrameMicrophonePCM: + return payloadLength == USBMicrophoneClientFrameSize + default: + return false + } +} + func isPowerOfTwo(value int) bool { return value > 0 && value&(value-1) == 0 } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 6009a966..6e3c6610 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -16,14 +16,20 @@ func makeStreamFrame(t *testing.T, frameType byte, payload []byte) []byte { } 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(len(payload))) - copy(frame[StreamFrameHeaderSize:], payload) + binary.LittleEndian.PutUint16(frame[6:8], uint16(payloadLength)) return frame } @@ -142,7 +148,7 @@ func TestQueueMicrophonePCMFrameRequiresActiveInterface(t *testing.T) { } } -func TestReadDualSenseInputStreamSanitizesTransportMagicInMotion(t *testing.T) { +func TestReadDualSenseInputStreamDropsCorruptedTransportMagicInput(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) @@ -171,7 +177,7 @@ func TestReadDualSenseInputStreamSanitizesTransportMagicInMotion(t *testing.T) { if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[25:29], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) + copy(inputPayload[25:33], makeStreamFrameHeader(StreamFrameMicrophonePCM, USBMicrophoneClientFrameSize)) if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { t.Fatalf("write input frame: %v", err) @@ -188,13 +194,67 @@ func TestReadDualSenseInputStreamSanitizesTransportMagicInMotion(t *testing.T) { gotInput := *dev.inputState dev.mtx.Unlock() - if gotInput.LX != state.LX || gotInput.R2 != state.R2 || gotInput.Buttons != state.Buttons { - t.Fatalf("non-motion input changed: got LX=%d R2=%d buttons=%#x", gotInput.LX, gotInput.R2, gotInput.Buttons) + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.LY != neutral.LY || + gotInput.RX != neutral.RX || gotInput.RY != neutral.RY || + gotInput.Buttons != neutral.Buttons || gotInput.DPad != neutral.DPad || + gotInput.L2 != neutral.L2 || gotInput.R2 != neutral.R2 { + t.Fatalf("corrupted input was not reset to neutral controls: got LX=%d LY=%d RX=%d RY=%d buttons=%#x dpad=%#x L2=%d R2=%d", + gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.Buttons, gotInput.DPad, gotInput.L2, gotInput.R2) } if gotInput.GyroX != 0 || gotInput.GyroY != 0 || gotInput.GyroZ != 0 || gotInput.AccelX != DefaultAccelXRaw || gotInput.AccelY != DefaultAccelYRaw || gotInput.AccelZ != DefaultAccelZRaw { - t.Fatalf("motion was not sanitized: gyro=%d,%d,%d accel=%d,%d,%d", + t.Fatalf("corrupted input motion was not reset to neutral: gyro=%d,%d,%d accel=%d,%d,%d", gotInput.GyroX, gotInput.GyroY, gotInput.GyroZ, gotInput.AccelX, gotInput.AccelY, gotInput.AccelZ) } } + +func TestReadDualSenseInputStreamKeepsPlainMagicInputBytes(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 = int8(StreamFrameMagic0) + state.LY = int8(StreamFrameMagic1) + state.RX = int8(StreamFrameMagic2) + state.RY = int8(StreamFrameMagic3) + state.R2 = 77 + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := *dev.inputState + dev.mtx.Unlock() + + if gotInput.LX != state.LX || gotInput.LY != state.LY || + gotInput.RX != state.RX || gotInput.RY != state.RY || + gotInput.R2 != state.R2 { + t.Fatalf("plain magic bytes should remain normal input: got LX=%d LY=%d RX=%d RY=%d R2=%d", + gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.R2) + } +} From 911ba7803f054fe71d35d01514c4a298f650071b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 26 Jun 2026 10:42:43 -0500 Subject: [PATCH 269/298] Block mic transport bytes from DualSense HID input --- device/dualsense/device.go | 43 +++++++++++++++++++++++++- device/dualsense/device_output_test.go | 34 ++++++++++++++++++++ device/dualsense/ds_handler.go | 24 ++++---------- device/dualsense/ds_handler_test.go | 11 ++++--- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index d09a025f..e935e580 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -42,6 +42,7 @@ type DualSense struct { microphoneSignal chan struct{} speakerInterfaceActive bool microphoneInterfaceActive bool + corruptUSBInputReports int // 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. @@ -868,11 +869,51 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[41] = d.seqCounter binary.LittleEndian.PutUint32(b[49:53], ts) - b[53] = m.BatteryStatus + battery := byte(0) + if m != nil { + battery = m.BatteryStatus + } + b[53] = battery + + if containsStreamMagic(b, 0, len(b)) { + d.corruptUSBInputReports++ + count := d.corruptUSBInputReports + if count <= 128 || isPowerOfTwo(count) { + slog.Warn("DualSense USB input report contained stream transport signature; report reset to neutral", + "count", count) + } + resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery) + } return b } +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 diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 75954566..20405e55 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -572,6 +572,40 @@ func TestDualSenseTouchTrackingZeroUsesActiveFallback(t *testing.T) { } } +func TestDualSenseUSBInputReportDropsTransportMagic(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, 0, len(report)) { + t.Fatalf("USB input report leaked transport magic: % x", report) + } + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one corrupted report 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("corrupted report was not reset to neutral controls: % x", report[:11]) + } + if report[33] != TouchInactiveMask || report[37] != TouchInactiveMask { + t.Fatalf("corrupted report was not reset to inactive touches: touch1=%#x touch2=%#x", report[33], report[37]) + } + 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, diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index df699f7a..76843c86 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -228,7 +228,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } func sanitizeInputStateTransportSignature(input []byte) bool { - if !containsStreamFrameHeader(input, 0, len(input)) { + if !containsStreamMagic(input, 0, len(input)) { return false } @@ -244,20 +244,19 @@ func sanitizeInputStateTransportSignature(input []byte) bool { return true } -func containsStreamFrameHeader(data []byte, offset int, length int) bool { - if len(data) < StreamFrameHeaderSize || length < StreamFrameHeaderSize { +func containsStreamMagic(data []byte, offset int, length int) bool { + const magicLength = 4 + if len(data) < magicLength || length < magicLength { return false } start := max(offset, 0) end := min(offset+length, len(data)) - for i := start; i+StreamFrameHeaderSize <= end; i++ { + for i := start; i+magicLength <= end; i++ { if data[i] == StreamFrameMagic0 && data[i+1] == StreamFrameMagic1 && data[i+2] == StreamFrameMagic2 && - data[i+3] == StreamFrameMagic3 && - data[i+4] == StreamFrameVersion && - isKnownStreamFrame(data[i+5], binary.LittleEndian.Uint16(data[i+6:i+8])) { + data[i+3] == StreamFrameMagic3 { return true } } @@ -265,17 +264,6 @@ func containsStreamFrameHeader(data []byte, offset int, length int) bool { return false } -func isKnownStreamFrame(frameType byte, payloadLength uint16) bool { - switch frameType { - case StreamFrameInputState: - return payloadLength == InputStateSize - case StreamFrameMicrophonePCM: - return payloadLength == USBMicrophoneClientFrameSize - default: - return false - } -} - func isPowerOfTwo(value int) bool { return value > 0 && value&(value-1) == 0 } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 6e3c6610..d7729bbb 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -210,7 +210,7 @@ func TestReadDualSenseInputStreamDropsCorruptedTransportMagicInput(t *testing.T) } } -func TestReadDualSenseInputStreamKeepsPlainMagicInputBytes(t *testing.T) { +func TestReadDualSenseInputStreamDropsPlainTransportMagicInputBytes(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) @@ -251,10 +251,11 @@ func TestReadDualSenseInputStreamKeepsPlainMagicInputBytes(t *testing.T) { gotInput := *dev.inputState dev.mtx.Unlock() - if gotInput.LX != state.LX || gotInput.LY != state.LY || - gotInput.RX != state.RX || gotInput.RY != state.RY || - gotInput.R2 != state.R2 { - t.Fatalf("plain magic bytes should remain normal input: got LX=%d LY=%d RX=%d RY=%d R2=%d", + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.LY != neutral.LY || + gotInput.RX != neutral.RX || gotInput.RY != neutral.RY || + gotInput.R2 != neutral.R2 { + t.Fatalf("plain transport magic bytes should reset input: got LX=%d LY=%d RX=%d RY=%d R2=%d", gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.R2) } } From 55fa62a9d5b8222dfcc114ee17bd5e534a8b7c71 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 27 Jun 2026 18:27:40 -0500 Subject: [PATCH 270/298] Harden DualSense mic input state isolation --- device/dualsense/const.go | 22 +++++ device/dualsense/device.go | 49 ++++++++--- device/dualsense/ds_handler.go | 47 ++++++++++- device/dualsense/ds_handler_test.go | 122 +++++++++++++++++++++++++++- 4 files changed, 224 insertions(+), 16 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index ca069d42..adafaadd 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -110,6 +110,26 @@ const ( 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 @@ -117,6 +137,8 @@ const ( DPadRight = 0x08 ) +const validDualSenseInputDPad uint8 = DPadUp | DPadDown | DPadLeft | DPadRight + const ( DPadUSBUp = 0x00 DPadUSBUpRight = 0x01 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index e935e580..75796731 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -22,8 +22,8 @@ import ( var rawOutputLogEnabled = os.Getenv("VIIPER_DUALSENSE_RAW_OUTPUT_LOG") == "1" type DualSense struct { - inputCh chan *InputState - inputState *InputState + inputCh chan InputState + inputState InputState metaState *MetaState outputFunc func(OutputState) @@ -126,8 +126,8 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { "pid", d.descriptor.Device.IDProduct, "interfaces", len(d.descriptor.Interfaces)) - d.inputState = NewInputState() - d.inputCh = make(chan *InputState, 1) + d.inputState = *NewInputState() + d.inputCh = make(chan InputState, 1) d.inputCh <- d.inputState d.timestampBase = time.Now() @@ -145,14 +145,23 @@ func (d *DualSense) SetOutputCallback(f func(OutputState)) { } func (d *DualSense) UpdateInputState(state *InputState) { + next := *NewInputState() + if state != nil { + next = *state + } + d.mtx.Lock() - d.inputState = state + d.inputState = next d.mtx.Unlock() + select { case <-d.inputCh: default: } - d.inputCh <- state + select { + case d.inputCh <- next: + default: + } } func (d *DualSense) GetDescriptor() *usb.Descriptor { @@ -204,14 +213,14 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o is := d.inputState ms := *d.metaState d.mtx.Unlock() - return d.buildUSBInputReport(is, &ms) + 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) + return d.buildUSBInputReport(&is, &ms) } case EndpointMicrophoneIn: return d.handleMicrophoneIn(ctx) @@ -435,7 +444,7 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, case hidGetReport: if reportType == reportTypeInput && reportID == ReportIDInput { d.mtx.Lock() - is := *d.inputState + is := d.inputState ms := *d.metaState d.mtx.Unlock() b := d.buildUSBInputReport(&is, &ms) @@ -875,12 +884,20 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { } b[53] = battery - if containsStreamMagic(b, 0, len(b)) { + corruptReason := "" + if inputStateControlsInvalid(s) { + corruptReason = "invalid input control bits" + } else if containsStreamMagic(b, 0, len(b)) { + corruptReason = "transport signature" + } + + if corruptReason != "" { d.corruptUSBInputReports++ count := d.corruptUSBInputReports if count <= 128 || isPowerOfTwo(count) { - slog.Warn("DualSense USB input report contained stream transport signature; report reset to neutral", - "count", count) + slog.Warn("DualSense USB input report was corrupt; report reset to neutral", + "count", count, + "reason", corruptReason) } resetUSBInputReportToNeutral(b, d.seqCounter, ts, battery) } @@ -888,6 +905,14 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { return b } +func inputStateControlsInvalid(s *InputState) bool { + if s == nil { + return false + } + return s.Buttons&^validDualSenseInputButtons != 0 || + s.DPad&^validDualSenseInputDPad != 0 +} + func resetUSBInputReportToNeutral(b []byte, seq uint8, timestamp uint32, battery byte) { for i := range b { b[i] = 0 diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 76843c86..c4e7fe43 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -228,7 +228,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } func sanitizeInputStateTransportSignature(input []byte) bool { - if !containsStreamMagic(input, 0, len(input)) { + if !inputStatePayloadLooksCorrupt(input) { return false } @@ -244,6 +244,27 @@ func sanitizeInputStateTransportSignature(input []byte) bool { return true } +func inputStatePayloadLooksCorrupt(input []byte) bool { + if len(input) < InputStateSize { + return false + } + if containsStreamMagic(input, 0, len(input)) { + return true + } + + buttons := binary.LittleEndian.Uint32(input[4:8]) + dpad := input[8] + if buttons&^validDualSenseInputButtons != 0 || + dpad&^validDualSenseInputDPad != 0 { + return true + } + + // The mic storm observed in the wild leaked the framed-stream marker into + // controls. Keep this scoped away from motion bytes so a legitimate gyro + // sample cannot be mistaken for transport framing. + return containsStreamMarkerFragment(input, 0, 11) +} + func containsStreamMagic(data []byte, offset int, length int) bool { const magicLength = 4 if len(data) < magicLength || length < magicLength { @@ -264,6 +285,30 @@ func containsStreamMagic(data []byte, offset int, length int) bool { return false } +func containsStreamMarkerFragment(data []byte, offset int, length int) bool { + const markerLength = 3 + if len(data) < markerLength || length < markerLength { + return false + } + + start := max(offset, 0) + end := min(offset+length, len(data)) + for i := start; 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 isPowerOfTwo(value int) bool { return value > 0 && value&(value-1) == 0 } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index d7729bbb..d6809356 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -76,7 +76,7 @@ func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { } dev.mtx.Lock() - gotInput := *dev.inputState + gotInput := dev.inputState gotMicrophoneLen := len(dev.microphonePCM) dev.mtx.Unlock() @@ -191,7 +191,7 @@ func TestReadDualSenseInputStreamDropsCorruptedTransportMagicInput(t *testing.T) } dev.mtx.Lock() - gotInput := *dev.inputState + gotInput := dev.inputState dev.mtx.Unlock() neutral := NewInputState() @@ -248,7 +248,7 @@ func TestReadDualSenseInputStreamDropsPlainTransportMagicInputBytes(t *testing.T } dev.mtx.Lock() - gotInput := *dev.inputState + gotInput := dev.inputState dev.mtx.Unlock() neutral := NewInputState() @@ -259,3 +259,119 @@ func TestReadDualSenseInputStreamDropsPlainTransportMagicInputBytes(t *testing.T gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.R2) } } + +func TestReadDualSenseInputStreamDropsTransportMarkerFragments(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 = 55 + state.R2 = 88 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[6:9], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) + + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.Buttons != neutral.Buttons { + t.Fatalf("transport marker fragment should reset input: got LX=%d R2=%d buttons=%#x", + gotInput.LX, gotInput.R2, gotInput.Buttons) + } +} + +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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.Buttons != neutral.Buttons || gotInput.DPad != neutral.DPad { + t.Fatalf("invalid control bits should reset input: got LX=%d buttons=%#x dpad=%#x", + gotInput.LX, gotInput.Buttons, gotInput.DPad) + } +} + +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) + } +} From c931873c174e123c75026c7788a0e1bd78382956 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 27 Jun 2026 18:50:00 -0500 Subject: [PATCH 271/298] Add DualSense mic input corruption diagnostics --- device/dualsense/device.go | 73 +++++++++++++++++++++++++++++ device/dualsense/diagnostics.go | 13 ++++++ device/dualsense/ds_handler.go | 82 ++++++++++++++++++++++++++++----- 3 files changed, 157 insertions(+), 11 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 75796731..df65866c 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -43,6 +43,7 @@ type DualSense struct { 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. @@ -262,6 +263,9 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { d.microphonePCM = append(d.microphonePCM, frame...) d.mtx.Unlock() + recordTrafficSummary("client->device", "microphone-pcm-queued", len(frame), + "summary", describeMicrophonePCMFrame(frame)) + select { case d.microphoneSignal <- struct{}{}: default: @@ -822,6 +826,8 @@ func (d *DualSense) featureReportCommandResponse() []byte { func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b := make([]byte, InputReportSize) + d.usbInputReportCount++ + reportCount := d.usbInputReportCount b[0] = ReportIDInput @@ -899,9 +905,16 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { "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 } @@ -913,6 +926,66 @@ func inputStateControlsInvalid(s *InputState) bool { 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 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, 0, len(b)), + containsStreamMarkerFragment(b, 0, len(b)), + 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 diff --git a/device/dualsense/diagnostics.go b/device/dualsense/diagnostics.go index 1dffa42b..0b66bcd0 100644 --- a/device/dualsense/diagnostics.go +++ b/device/dualsense/diagnostics.go @@ -115,6 +115,19 @@ func recordTrafficBytes(direction, source string, data []byte, fields ...any) { 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]) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index c4e7fe43..80c693ab 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -201,12 +201,21 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger if _, err := io.ReadFull(conn, input); err != nil { return fmt.Errorf("read framed input state: %w", err) } - if sanitizeInputStateTransportSignature(input) { + corruptReason := inputStatePayloadCorruptionReason(input) + recordTrafficBytes("client->device", "framed-input-state", + input, + "summary", describeInputStatePayload(input, corruptReason)) + if corruptReason != "" { corruptInputFrames++ if corruptInputFrames <= 128 || isPowerOfTwo(corruptInputFrames) { - logger.Warn("DualSense framed input contained transport signature; input reset to neutral", - "count", corruptInputFrames) + logger.Warn("DualSense framed input was corrupt; input reset to neutral", + "count", corruptInputFrames, + "reason", corruptReason) } + neutralizeInputStatePayload(input) + recordTrafficBytes("client->device", "framed-input-state-after-reset", + input, + "summary", describeInputStatePayload(input, "reset from "+corruptReason)) } var state InputState if err := state.UnmarshalBinary(input); err != nil { @@ -220,6 +229,8 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger if _, err := io.ReadFull(conn, microphonePCM); err != nil { return fmt.Errorf("read microphone pcm frame: %w", err) } + recordTrafficSummary("client->device", "framed-microphone-pcm", len(microphonePCM), + "summary", describeMicrophonePCMFrame(microphonePCM)) dse.QueueMicrophonePCMFrame(microphonePCM) default: return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X length %d", frameType, payloadLen) @@ -228,41 +239,90 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } func sanitizeInputStateTransportSignature(input []byte) bool { - if !inputStatePayloadLooksCorrupt(input) { + if inputStatePayloadCorruptionReason(input) == "" { return false } + neutralizeInputStatePayload(input) + return true +} + +func neutralizeInputStatePayload(input []byte) { neutral, err := NewInputState().MarshalBinary() if err != nil { for i := range input { input[i] = 0 } - return true + return } copy(input, neutral) - return true } -func inputStatePayloadLooksCorrupt(input []byte) bool { +func inputStatePayloadCorruptionReason(input []byte) string { if len(input) < InputStateSize { - return false + return "" } if containsStreamMagic(input, 0, len(input)) { - return true + return "full transport magic in payload" } buttons := binary.LittleEndian.Uint32(input[4:8]) dpad := input[8] if buttons&^validDualSenseInputButtons != 0 || dpad&^validDualSenseInputDPad != 0 { - return true + return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) } // The mic storm observed in the wild leaked the framed-stream marker into // controls. Keep this scoped away from motion bytes so a legitimate gyro // sample cannot be mistaken for transport framing. - return containsStreamMarkerFragment(input, 0, 11) + if containsStreamMarkerFragment(input, 0, 11) { + return "transport marker fragment in controls" + } + + return "" +} + +func inputStatePayloadLooksCorrupt(input []byte) bool { + return inputStatePayloadCorruptionReason(input) != "" +} + +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 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, 0, len(input)), + containsStreamMarkerFragment(input, 0, 11), + corruptReason) } func containsStreamMagic(data []byte, offset int, length int) bool { From 51017849c37555687d15993d0a914cc352443892 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 27 Jun 2026 18:53:47 -0500 Subject: [PATCH 272/298] Fix DualSense diagnostics lint --- device/dualsense/device.go | 6 ++--- device/dualsense/device_output_test.go | 2 +- device/dualsense/ds_handler.go | 36 +++++++------------------- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index df65866c..973b15b0 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -893,7 +893,7 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { corruptReason := "" if inputStateControlsInvalid(s) { corruptReason = "invalid input control bits" - } else if containsStreamMagic(b, 0, len(b)) { + } else if containsStreamMagic(b) { corruptReason = "transport signature" } @@ -951,8 +951,8 @@ func describeUSBInputReport(b []byte, count uint64, resetReason string) string { b[37], ts, b[53], - containsStreamMagic(b, 0, len(b)), - containsStreamMarkerFragment(b, 0, len(b)), + containsStreamMagic(b), + containsStreamMarkerFragment(b, len(b)), resetReason) } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 20405e55..585df110 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -587,7 +587,7 @@ func TestDualSenseUSBInputReportDropsTransportMagic(t *testing.T) { state.Buttons = ButtonCross report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsStreamMagic(report, 0, len(report)) { + if containsStreamMagic(report) { t.Fatalf("USB input report leaked transport magic: % x", report) } if dev.corruptUSBInputReports != 1 { diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 80c693ab..f6db1368 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -238,15 +238,6 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } } -func sanitizeInputStateTransportSignature(input []byte) bool { - if inputStatePayloadCorruptionReason(input) == "" { - return false - } - - neutralizeInputStatePayload(input) - return true -} - func neutralizeInputStatePayload(input []byte) { neutral, err := NewInputState().MarshalBinary() if err != nil { @@ -263,7 +254,7 @@ func inputStatePayloadCorruptionReason(input []byte) string { if len(input) < InputStateSize { return "" } - if containsStreamMagic(input, 0, len(input)) { + if containsStreamMagic(input) { return "full transport magic in payload" } @@ -277,17 +268,13 @@ func inputStatePayloadCorruptionReason(input []byte) string { // The mic storm observed in the wild leaked the framed-stream marker into // controls. Keep this scoped away from motion bytes so a legitimate gyro // sample cannot be mistaken for transport framing. - if containsStreamMarkerFragment(input, 0, 11) { + if containsStreamMarkerFragment(input, 11) { return "transport marker fragment in controls" } return "" } -func inputStatePayloadLooksCorrupt(input []byte) bool { - return inputStatePayloadCorruptionReason(input) != "" -} - func describeInputStatePayload(input []byte, corruptReason string) string { if len(input) < InputStateSize { return fmt.Sprintf("len=%d corruptReason=%s", len(input), corruptReason) @@ -320,20 +307,18 @@ func describeInputStatePayload(input []byte, corruptReason string) string { accelX, accelY, accelZ, - containsStreamMagic(input, 0, len(input)), - containsStreamMarkerFragment(input, 0, 11), + containsStreamMagic(input), + containsStreamMarkerFragment(input, 11), corruptReason) } -func containsStreamMagic(data []byte, offset int, length int) bool { +func containsStreamMagic(data []byte) bool { const magicLength = 4 - if len(data) < magicLength || length < magicLength { + if len(data) < magicLength { return false } - start := max(offset, 0) - end := min(offset+length, len(data)) - for i := start; i+magicLength <= end; i++ { + for i := 0; i+magicLength <= len(data); i++ { if data[i] == StreamFrameMagic0 && data[i+1] == StreamFrameMagic1 && data[i+2] == StreamFrameMagic2 && @@ -345,15 +330,14 @@ func containsStreamMagic(data []byte, offset int, length int) bool { return false } -func containsStreamMarkerFragment(data []byte, offset int, length int) bool { +func containsStreamMarkerFragment(data []byte, length int) bool { const markerLength = 3 if len(data) < markerLength || length < markerLength { return false } - start := max(offset, 0) - end := min(offset+length, len(data)) - for i := start; i+markerLength <= end; i++ { + end := min(length, len(data)) + for i := 0; i+markerLength <= end; i++ { if data[i] == StreamFrameMagic0 && data[i+1] == StreamFrameMagic1 && data[i+2] == StreamFrameMagic2 { From e6b39352d70ced9b95036177114f96ccc0813283 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 27 Jun 2026 19:03:18 -0500 Subject: [PATCH 273/298] Replay latest HID output state for late streams --- device/dualshock4/device.go | 39 +++++++++++++++++++++++----- device/dualshock4/dualshock4_test.go | 29 +++++++++++++++++++++ device/keyboard/device.go | 38 +++++++++++++++++++++------ device/keyboard/keyboard_test.go | 25 ++++++++++++++++++ 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index b48934df..2cd65359 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -21,8 +21,10 @@ type DualShock4 struct { inputState *InputState metaState *MetaState - outputFunc func(OutputState) - descriptor usb.Descriptor + outputFunc func(OutputState) + outputState OutputState + outputSeen bool + descriptor usb.Descriptor probeSelector [3]byte telemetrySubcommand byte @@ -104,7 +106,20 @@ func (d *DualShock4) SetMetaState(meta MetaState) { } 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) UpdateInputState(state *InputState) { @@ -165,8 +180,14 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, if dir == usbip.DirOut && ep == 3 { if len(out) >= 11 && out[0] == ReportIDOutput { - if d.outputFunc != nil { - d.outputFunc(parseOutputReport(out)) + feedback := parseOutputReport(out) + d.mtx.Lock() + d.outputState = feedback + d.outputSeen = true + outputFunc := d.outputFunc + d.mtx.Unlock() + if outputFunc != nil { + outputFunc(feedback) } } } @@ -227,8 +248,14 @@ func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex } return nil, true case reportType == reportTypeOutput && reportID == ReportIDOutput && len(data) >= 11: - if d.outputFunc != nil { - d.outputFunc(parseOutputReport(data)) + 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 } diff --git a/device/dualshock4/dualshock4_test.go b/device/dualshock4/dualshock4_test.go index 6704c15c..978a65fa 100644 --- a/device/dualshock4/dualshock4_test.go +++ b/device/dualshock4/dualshock4_test.go @@ -15,6 +15,7 @@ import ( "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 ) @@ -483,3 +484,31 @@ func TestFeedback(t *testing.T) { }) } } + +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/keyboard/device.go b/device/keyboard/device.go index 4b12b9a4..babd17b3 100644 --- a/device/keyboard/device.go +++ b/device/keyboard/device.go @@ -18,6 +18,7 @@ type Keyboard struct { inputCh chan InputState stateMu sync.Mutex ledState uint8 + ledSeen bool ledCallback func(LEDState) descriptor usb.Descriptor } @@ -42,7 +43,20 @@ func New(o *device.CreateOptions) (*Keyboard, error) { // 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. @@ -86,24 +100,32 @@ func (k *Keyboard) HandleTransfer(ctx context.Context, ep uint32, dir uint32, ou 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 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, - }) + 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{ diff --git a/device/keyboard/keyboard_test.go b/device/keyboard/keyboard_test.go index 1fddf566..c87c9aa3 100644 --- a/device/keyboard/keyboard_test.go +++ b/device/keyboard/keyboard_test.go @@ -14,6 +14,7 @@ import ( "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 ) @@ -212,3 +213,27 @@ func TestLEDs(t *testing.T) { }) } } + +func TestLEDCallbackReplaysLatestHostState(t *testing.T) { + dev, err := keyboard.New(nil) + require.NoError(t, err) + + dev.HandleTransfer(context.Background(), 1, usbip.DirOut, + []byte{keyboard.LEDNumLock | keyboard.LEDCapsLock}) + + gotCh := make(chan keyboard.LEDState, 1) + dev.SetLEDCallback(func(led keyboard.LEDState) { + gotCh <- led + }) + + select { + case got := <-gotCh: + assert.True(t, got.NumLock) + assert.True(t, got.CapsLock) + assert.False(t, got.ScrollLock) + assert.False(t, got.Compose) + assert.False(t, got.Kana) + case <-time.After(viiperTesting.IntegrationTimeout): + t.Fatal("expected late LED callback to receive latest host state") + } +} From 818065a5c5a53178a1069884f0fae3cf118f2e2b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 28 Jun 2026 08:20:31 -0500 Subject: [PATCH 274/298] Neutralize DualSense mic transport fragments --- device/dualsense/device.go | 8 +++++ device/dualsense/device_output_test.go | 31 +++++++++++++++++ device/dualsense/ds_handler.go | 11 +++--- device/dualsense/ds_handler_test.go | 46 ++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 973b15b0..3b6eb664 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -895,6 +895,8 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { corruptReason = "invalid input control bits" } else if containsStreamMagic(b) { corruptReason = "transport signature" + } else if d.isMicrophoneInterfaceActive() && containsStreamMarkerFragment(b, len(b)) { + corruptReason = "transport marker fragment while microphone active" } if corruptReason != "" { @@ -918,6 +920,12 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { return b } +func (d *DualSense) isMicrophoneInterfaceActive() bool { + d.mtx.Lock() + defer d.mtx.Unlock() + return d.microphoneInterfaceActive +} + func inputStateControlsInvalid(s *InputState) bool { if s == nil { return false diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 585df110..1113fd92 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -606,6 +606,37 @@ func TestDualSenseUSBInputReportDropsTransportMagic(t *testing.T) { } } +func TestDualSenseUSBInputReportDropsTransportMarkerFragmentWhenMicrophoneActive(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + state := NewInputState() + state.GyroX = int16(uint16(StreamFrameMagic0) | uint16(StreamFrameMagic1)<<8) + state.GyroY = int16(uint16(StreamFrameMagic2)) + state.LX = 17 + state.R2 = 200 + state.Buttons = ButtonCross + + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + if containsStreamMarkerFragment(report, len(report)) { + t.Fatalf("USB input report leaked transport marker fragment: % x", report) + } + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one corrupted report 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("corrupted mic-active report was not reset to neutral controls: % 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, diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index f6db1368..e977be08 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -265,12 +265,15 @@ func inputStatePayloadCorruptionReason(input []byte) string { return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) } - // The mic storm observed in the wild leaked the framed-stream marker into - // controls. Keep this scoped away from motion bytes so a legitimate gyro - // sample cannot be mistaken for transport framing. + // The mic storm observed in the wild leaked framed-stream markers into + // touch and motion bytes too. A three-byte VPC/PCM fragment is specific + // enough to reject the whole input frame before it can become a HID report. if containsStreamMarkerFragment(input, 11) { return "transport marker fragment in controls" } + if containsStreamMarkerFragment(input[11:], len(input)-11) { + return "transport marker fragment in touch/motion" + } return "" } @@ -308,7 +311,7 @@ func describeInputStatePayload(input []byte, corruptReason string) string { accelY, accelZ, containsStreamMagic(input), - containsStreamMarkerFragment(input, 11), + containsStreamMarkerFragment(input, len(input)), corruptReason) } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index d6809356..9544191a 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -306,6 +306,52 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { } } +func TestReadDualSenseInputStreamDropsTransportMarkerFragmentsInTouchMotion(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 = 55 + state.R2 = 88 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[21:24], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2}) + + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { + t.Fatalf("touch/motion transport marker fragment should reset input: got LX=%d R2=%d GyroX=%d", + gotInput.LX, gotInput.R2, gotInput.GyroX) + } +} + func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) { dev, err := New(nil) if err != nil { From 8bd2246be4146448906c2a8278ad840c9de220cd Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 28 Jun 2026 08:50:30 -0500 Subject: [PATCH 275/298] Harden DualSense mic stream input guards --- device/dualsense/device.go | 2 ++ device/dualsense/device_output_test.go | 32 ++++++++++++++++++ device/dualsense/ds_handler.go | 32 +++++++++++++++++- device/dualsense/ds_handler_test.go | 46 ++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 3b6eb664..052ad091 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -897,6 +897,8 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { corruptReason = "transport signature" } else if d.isMicrophoneInterfaceActive() && containsStreamMarkerFragment(b, len(b)) { corruptReason = "transport marker fragment while microphone active" + } else if d.isMicrophoneInterfaceActive() && containsMicTransportLeakPattern(b[16:41]) { + corruptReason = "mic transport leak pattern while microphone active" } if corruptReason != "" { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 1113fd92..07d3e054 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -637,6 +637,38 @@ func TestDualSenseUSBInputReportDropsTransportMarkerFragmentWhenMicrophoneActive } } +func TestDualSenseUSBInputReportDropsMicTransportLeakPatternWhenMicrophoneActive(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + state := NewInputState() + state.GyroX = int16(0x4D43) + state.GyroY = int16(0x0101) + state.GyroZ = int16(0x0021) + state.LX = 17 + state.R2 = 200 + state.Buttons = ButtonCross + + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + if containsMicTransportLeakPattern(report[16:41]) { + t.Fatalf("USB input report leaked mic transport pattern: % x", report) + } + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one corrupted report 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("corrupted mic-leak report was not reset to neutral controls: % 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, diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index e977be08..8b24c828 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -274,6 +274,9 @@ func inputStatePayloadCorruptionReason(input []byte) string { if containsStreamMarkerFragment(input[11:], len(input)-11) { return "transport marker fragment in touch/motion" } + if containsMicTransportLeakPattern(input[11:]) { + return "mic transport leak pattern in touch/motion" + } return "" } @@ -293,7 +296,7 @@ func describeInputStatePayload(input []byte, corruptReason string) string { 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 corruptReason=%s", + "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]), @@ -312,6 +315,7 @@ func describeInputStatePayload(input []byte, corruptReason string) string { accelZ, containsStreamMagic(input), containsStreamMarkerFragment(input, len(input)), + containsMicTransportLeakPattern(input[11:]), corruptReason) } @@ -356,6 +360,32 @@ func containsStreamMarkerFragment(data []byte, length int) bool { return false } +func containsMicTransportLeakPattern(data []byte) bool { + return containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) || + containsByteSequence(data, []byte{StreamFrameMagic2, StreamFrameMagic1, 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 } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 9544191a..1f9ebdad 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -352,6 +352,52 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragmentsInTouchMotion(t *t } } +func TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(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 = 55 + state.R2 = 88 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[21:26], []byte{StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) + + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { + t.Fatalf("touch/motion mic transport leak should reset input: got LX=%d R2=%d GyroX=%d", + gotInput.LX, gotInput.R2, gotInput.GyroX) + } +} + func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) { dev, err := New(nil) if err != nil { From 3ec6305b215748dcb7e9917cfa23f96c481eb5e1 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 28 Jun 2026 09:16:32 -0500 Subject: [PATCH 276/298] Harden DualSense mic endpoint handling --- device/dualsense/device.go | 14 ++++-- device/dualsense/device_output_test.go | 61 +++++++++++++++++++++++ device/dualsense/ds_handler.go | 23 +++++++-- device/dualsense/ds_handler_test.go | 68 ++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 8 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 052ad091..a2047f20 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -294,6 +294,8 @@ func (d *DualSense) handleMicrophoneIn(ctx context.Context) []byte { case <-ctx.Done(): return make([]byte, USBMicrophonePacketSize) case <-d.microphoneSignal: + case <-time.After(time.Millisecond): + return make([]byte, USBMicrophonePacketSize) } } } @@ -891,14 +893,17 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[53] = battery corruptReason := "" + microphoneActive := d.isMicrophoneInterfaceActive() if inputStateControlsInvalid(s) { corruptReason = "invalid input control bits" } else if containsStreamMagic(b) { corruptReason = "transport signature" - } else if d.isMicrophoneInterfaceActive() && containsStreamMarkerFragment(b, len(b)) { + } else if microphoneActive && containsStreamMarkerFragment(b, len(b)) { corruptReason = "transport marker fragment while microphone active" - } else if d.isMicrophoneInterfaceActive() && containsMicTransportLeakPattern(b[16:41]) { - corruptReason = "mic transport leak pattern while microphone active" + } else if containsStrongMicTransportLeakPattern(b[16:41]) { + corruptReason = "mic transport leak pattern" + } else if microphoneActive && containsWeakMicTransportLeakPattern(b[16:41]) { + corruptReason = "weak mic transport leak pattern while microphone active" } if corruptReason != "" { @@ -943,7 +948,7 @@ func describeUSBInputReport(b []byte, count uint64, resetReason string) string { 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 resetReason=%s", + "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], @@ -963,6 +968,7 @@ func describeUSBInputReport(b []byte, count uint64, resetReason string) string { b[53], containsStreamMagic(b), containsStreamMarkerFragment(b, len(b)), + containsMicTransportLeakPattern(b[16:41]), resetReason) } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 07d3e054..f62c1848 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -669,6 +669,67 @@ func TestDualSenseUSBInputReportDropsMicTransportLeakPatternWhenMicrophoneActive } } +func TestDualSenseUSBInputReportDropsShiftedMicTransportLeakPattern(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + state := NewInputState() + state.GyroX = int16(0x014D) + state.GyroY = int16(0x2101) + state.LX = 17 + state.R2 = 200 + state.Buttons = ButtonCross + + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + if containsMicTransportLeakPattern(report[16:41]) { + t.Fatalf("USB input report leaked shifted mic transport pattern: % x", report) + } + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one corrupted report 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("shifted mic-leak report was not reset to neutral controls: % x", report[:11]) + } + if report[53] != BatteryFullyCharged { + t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) + } +} + +func TestDualSenseUSBInputReportDropsWeakMicTransportLeakPatternWhenMicrophoneActive(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + + state := NewInputState() + state.GyroX = int16(0x0101) + state.GyroY = int16(0x0021) + state.LX = 17 + state.R2 = 200 + state.Buttons = ButtonCross + + report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) + if containsWeakMicTransportLeakPattern(report[16:41]) { + t.Fatalf("USB input report leaked weak mic transport pattern: % x", report) + } + if dev.corruptUSBInputReports != 1 { + t.Fatalf("expected one corrupted report 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("weak mic-leak report was not reset to neutral controls: % 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, diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 8b24c828..217d99cd 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -201,7 +201,7 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger if _, err := io.ReadFull(conn, input); err != nil { return fmt.Errorf("read framed input state: %w", err) } - corruptReason := inputStatePayloadCorruptionReason(input) + corruptReason := inputStatePayloadCorruptionReason(input, dse.isMicrophoneInterfaceActive()) recordTrafficBytes("client->device", "framed-input-state", input, "summary", describeInputStatePayload(input, corruptReason)) @@ -250,7 +250,7 @@ func neutralizeInputStatePayload(input []byte) { copy(input, neutral) } -func inputStatePayloadCorruptionReason(input []byte) string { +func inputStatePayloadCorruptionReason(input []byte, microphoneActive bool) string { if len(input) < InputStateSize { return "" } @@ -274,9 +274,12 @@ func inputStatePayloadCorruptionReason(input []byte) string { if containsStreamMarkerFragment(input[11:], len(input)-11) { return "transport marker fragment in touch/motion" } - if containsMicTransportLeakPattern(input[11:]) { + if containsStrongMicTransportLeakPattern(input[11:]) { return "mic transport leak pattern in touch/motion" } + if microphoneActive && containsWeakMicTransportLeakPattern(input[11:]) { + return "weak mic transport leak pattern in touch/motion" + } return "" } @@ -361,8 +364,20 @@ func containsStreamMarkerFragment(data []byte, length int) bool { } 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{StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}) + 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 { diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 1f9ebdad..6b94b906 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -398,6 +398,74 @@ func TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(t *te } } +func TestReadDualSenseInputStreamDropsWeakMicTransportLeakPatternWhenMicrophoneActive(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 = 55 + state.R2 = 88 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[21:24], []byte{0x01, 0x01, hidClassOUT}) + + 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 { + t.Fatalf("readDualSenseInputStream returned error: %v", err) + } + + dev.mtx.Lock() + gotInput := dev.inputState + dev.mtx.Unlock() + + neutral := NewInputState() + if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { + t.Fatalf("weak touch/motion mic transport leak should reset input: got LX=%d R2=%d GyroX=%d", + gotInput.LX, gotInput.R2, gotInput.GyroX) + } +} + +func TestContainsMicTransportLeakPatternShiftedFragments(t *testing.T) { + cases := [][]byte{ + {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, + {StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, + {0x01, 0x01, hidClassOUT}, + {StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}, + {StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}, + {0x80, 0x87, StreamFrameMagic2}, + } + + for _, tc := range cases { + if !containsMicTransportLeakPattern(tc) { + t.Fatalf("expected shifted mic transport leak pattern to match: % x", tc) + } + } + + if containsMicTransportLeakPattern([]byte{0x80, 0x87, 0x42}) { + t.Fatal("near miss should not match mic transport leak pattern") + } +} + func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) { dev, err := New(nil) if err != nil { From e0e598e4c7ff497b561f6109f3311ef2407a2a6e Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 08:48:12 -0500 Subject: [PATCH 277/298] Harden DualSense microphone stream framing --- device/dualsense/const.go | 2 + device/dualsense/device.go | 15 +- device/dualsense/device_output_test.go | 130 +------- device/dualsense/ds_handler.go | 114 +++---- device/dualsense/ds_handler_test.go | 407 ++++++++++--------------- device/dualsense/dsedge_handler.go | 6 +- 6 files changed, 236 insertions(+), 438 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index adafaadd..5d045ee9 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -51,11 +51,13 @@ const ( InputStateSize = 33 OutputStateSize = 6 StreamFrameHeaderSize = 8 + StreamFrameV2HeaderSize = 16 StreamFrameMagic0 = 0x56 StreamFrameMagic1 = 0x50 StreamFrameMagic2 = 0x43 StreamFrameMagic3 = 0x4D StreamFrameVersion = 0x01 + StreamFrameVersionV2 = 0x02 StreamFrameInputState = 0x01 StreamFrameMicrophonePCM = 0x02 USBMicrophoneSampleRate = 48000 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index a2047f20..24517260 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -257,8 +257,10 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { return } - if len(d.microphonePCM) > USBMicrophoneClientFrameSize*4 { - d.microphonePCM = d.microphonePCM[len(d.microphonePCM)-USBMicrophoneClientFrameSize*4:] + const maximumBufferedBytes = USBMicrophoneClientFrameSize * 4 + if overflow := len(d.microphonePCM) + len(frame) - maximumBufferedBytes; overflow > 0 { + copy(d.microphonePCM, d.microphonePCM[overflow:]) + d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-overflow] } d.microphonePCM = append(d.microphonePCM, frame...) d.mtx.Unlock() @@ -893,17 +895,8 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { b[53] = battery corruptReason := "" - microphoneActive := d.isMicrophoneInterfaceActive() if inputStateControlsInvalid(s) { corruptReason = "invalid input control bits" - } else if containsStreamMagic(b) { - corruptReason = "transport signature" - } else if microphoneActive && containsStreamMarkerFragment(b, len(b)) { - corruptReason = "transport marker fragment while microphone active" - } else if containsStrongMicTransportLeakPattern(b[16:41]) { - corruptReason = "mic transport leak pattern" - } else if microphoneActive && containsWeakMicTransportLeakPattern(b[16:41]) { - corruptReason = "weak mic transport leak pattern while microphone active" } if corruptReason != "" { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index f62c1848..032f75aa 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -572,7 +572,7 @@ func TestDualSenseTouchTrackingZeroUsesActiveFallback(t *testing.T) { } } -func TestDualSenseUSBInputReportDropsTransportMagic(t *testing.T) { +func TestDualSenseUSBInputReportPreservesArbitraryMotionBytes(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) @@ -587,143 +587,37 @@ func TestDualSenseUSBInputReportDropsTransportMagic(t *testing.T) { state.Buttons = ButtonCross report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsStreamMagic(report) { - t.Fatalf("USB input report leaked transport magic: % x", report) + if !containsStreamMagic(report) { + t.Fatalf("expected arbitrary motion bytes to survive: % x", report) } - if dev.corruptUSBInputReports != 1 { - t.Fatalf("expected one corrupted report 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("corrupted report was not reset to neutral controls: % x", report[:11]) + if dev.corruptUSBInputReports != 0 { + t.Fatalf("valid motion was incorrectly rejected, resets=%d", dev.corruptUSBInputReports) } - if report[33] != TouchInactiveMask || report[37] != TouchInactiveMask { - t.Fatalf("corrupted report was not reset to inactive touches: touch1=%#x touch2=%#x", report[33], report[37]) - } - if report[53] != BatteryFullyCharged { - t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) + 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 TestDualSenseUSBInputReportDropsTransportMarkerFragmentWhenMicrophoneActive(t *testing.T) { +func TestDualSenseUSBInputReportNeutralizesInvalidControlBits(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) state := NewInputState() - state.GyroX = int16(uint16(StreamFrameMagic0) | uint16(StreamFrameMagic1)<<8) - state.GyroY = int16(uint16(StreamFrameMagic2)) state.LX = 17 state.R2 = 200 - state.Buttons = ButtonCross - + state.Buttons = 1 << 31 report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsStreamMarkerFragment(report, len(report)) { - t.Fatalf("USB input report leaked transport marker fragment: % x", report) - } - if dev.corruptUSBInputReports != 1 { - t.Fatalf("expected one corrupted report 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("corrupted mic-active report was not reset to neutral controls: % x", report[:11]) - } - if report[53] != BatteryFullyCharged { - t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) - } -} - -func TestDualSenseUSBInputReportDropsMicTransportLeakPatternWhenMicrophoneActive(t *testing.T) { - dev, err := New(nil) - if err != nil { - t.Fatalf("New returned error: %v", err) - } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) - - state := NewInputState() - state.GyroX = int16(0x4D43) - state.GyroY = int16(0x0101) - state.GyroZ = int16(0x0021) - state.LX = 17 - state.R2 = 200 - state.Buttons = ButtonCross - report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsMicTransportLeakPattern(report[16:41]) { - t.Fatalf("USB input report leaked mic transport pattern: % x", report) - } - if dev.corruptUSBInputReports != 1 { - t.Fatalf("expected one corrupted report 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("corrupted mic-leak report was not reset to neutral controls: % x", report[:11]) - } - if report[53] != BatteryFullyCharged { - t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) - } -} - -func TestDualSenseUSBInputReportDropsShiftedMicTransportLeakPattern(t *testing.T) { - dev, err := New(nil) - if err != nil { - t.Fatalf("New returned error: %v", err) - } - - state := NewInputState() - state.GyroX = int16(0x014D) - state.GyroY = int16(0x2101) - state.LX = 17 - state.R2 = 200 - state.Buttons = ButtonCross - - report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsMicTransportLeakPattern(report[16:41]) { - t.Fatalf("USB input report leaked shifted mic transport pattern: % x", report) - } - if dev.corruptUSBInputReports != 1 { - t.Fatalf("expected one corrupted report 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("shifted mic-leak report was not reset to neutral controls: % x", report[:11]) - } - if report[53] != BatteryFullyCharged { - t.Fatalf("neutral report should preserve battery byte, got %#x", report[53]) - } -} - -func TestDualSenseUSBInputReportDropsWeakMicTransportLeakPatternWhenMicrophoneActive(t *testing.T) { - dev, err := New(nil) - if err != nil { - t.Fatalf("New returned error: %v", err) - } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) - - state := NewInputState() - state.GyroX = int16(0x0101) - state.GyroY = int16(0x0021) - state.LX = 17 - state.R2 = 200 - state.Buttons = ButtonCross - - report := dev.buildUSBInputReport(state, &MetaState{BatteryStatus: BatteryFullyCharged}) - if containsWeakMicTransportLeakPattern(report[16:41]) { - t.Fatalf("USB input report leaked weak mic transport pattern: % x", report) - } if dev.corruptUSBInputReports != 1 { - t.Fatalf("expected one corrupted report reset, got %d", dev.corruptUSBInputReports) + 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("weak mic-leak report was not reset to neutral controls: % x", report[:11]) + 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]) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 217d99cd..aa47f461 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "hash/crc32" "io" "log/slog" "net" @@ -18,13 +19,15 @@ 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}) + api.RegisterDevice("dualsensecombinedmicext", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion}) + api.RegisterDevice("dualsensecombinedmicv2", &dshandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2}) } type dshandler struct { extendedFeedback bool combinedBluetoothFeedback bool microphoneInput bool + streamFrameVersion byte } func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -142,11 +145,15 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } }) - return readDualSenseInputStream(conn, dse, logger, h.microphoneInput) + return readDualSenseInputStreamVersion(conn, dse, logger, h.microphoneInput, h.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 { @@ -166,10 +173,15 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } } - header := make([]byte, StreamFrameHeaderSize) + headerSize := StreamFrameHeaderSize + if frameVersion == StreamFrameVersionV2 { + headerSize = StreamFrameV2HeaderSize + } + header := make([]byte, headerSize) input := make([]byte, InputStateSize) microphonePCM := make([]byte, USBMicrophoneClientFrameSize) - corruptInputFrames := 0 + var expectedSequence uint32 + sequenceInitialized := false for { if _, err := io.ReadFull(conn, header); err != nil { if err == io.EOF { @@ -186,36 +198,56 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger return fmt.Errorf("invalid DualSense framed stream magic %02X %02X %02X %02X", header[0], header[1], header[2], header[3]) } - if header[4] != StreamFrameVersion { + 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) } - if _, err := io.ReadFull(conn, input); err != nil { - return fmt.Errorf("read framed input state: %w", err) + 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 { + 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) } - corruptReason := inputStatePayloadCorruptionReason(input, dse.isMicrophoneInterfaceActive()) + } + + switch frameType { + case StreamFrameInputState: + corruptReason := inputStatePayloadCorruptionReason(input) recordTrafficBytes("client->device", "framed-input-state", input, "summary", describeInputStatePayload(input, corruptReason)) if corruptReason != "" { - corruptInputFrames++ - if corruptInputFrames <= 128 || isPowerOfTwo(corruptInputFrames) { - logger.Warn("DualSense framed input was corrupt; input reset to neutral", - "count", corruptInputFrames, - "reason", corruptReason) - } - neutralizeInputStatePayload(input) - recordTrafficBytes("client->device", "framed-input-state-after-reset", - input, - "summary", describeInputStatePayload(input, "reset from "+corruptReason)) + return fmt.Errorf("invalid framed input state: %s", corruptReason) } var state InputState if err := state.UnmarshalBinary(input); err != nil { @@ -223,39 +255,23 @@ func readDualSenseInputStream(conn net.Conn, dse *DualSense, logger *slog.Logger } dse.UpdateInputState(&state) case StreamFrameMicrophonePCM: - if payloadLen != USBMicrophoneClientFrameSize { - return fmt.Errorf("invalid microphone pcm frame length %d", payloadLen) - } - if _, err := io.ReadFull(conn, microphonePCM); err != nil { - return fmt.Errorf("read microphone pcm frame: %w", err) - } recordTrafficSummary("client->device", "framed-microphone-pcm", len(microphonePCM), "summary", describeMicrophonePCMFrame(microphonePCM)) dse.QueueMicrophonePCMFrame(microphonePCM) - default: - return fmt.Errorf("unknown DualSense framed stream packet type 0x%02X length %d", frameType, payloadLen) } } } -func neutralizeInputStatePayload(input []byte) { - neutral, err := NewInputState().MarshalBinary() - if err != nil { - for i := range input { - input[i] = 0 - } - return - } - - copy(input, neutral) +func framedStreamCRC(headerFields, payload []byte) uint32 { + hash := crc32.NewIEEE() + _, _ = hash.Write(headerFields) + _, _ = hash.Write(payload) + return hash.Sum32() } -func inputStatePayloadCorruptionReason(input []byte, microphoneActive bool) string { +func inputStatePayloadCorruptionReason(input []byte) string { if len(input) < InputStateSize { - return "" - } - if containsStreamMagic(input) { - return "full transport magic in payload" + return fmt.Sprintf("invalid length %d", len(input)) } buttons := binary.LittleEndian.Uint32(input[4:8]) @@ -265,22 +281,6 @@ func inputStatePayloadCorruptionReason(input []byte, microphoneActive bool) stri return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) } - // The mic storm observed in the wild leaked framed-stream markers into - // touch and motion bytes too. A three-byte VPC/PCM fragment is specific - // enough to reject the whole input frame before it can become a HID report. - if containsStreamMarkerFragment(input, 11) { - return "transport marker fragment in controls" - } - if containsStreamMarkerFragment(input[11:], len(input)-11) { - return "transport marker fragment in touch/motion" - } - if containsStrongMicTransportLeakPattern(input[11:]) { - return "mic transport leak pattern in touch/motion" - } - if microphoneActive && containsWeakMicTransportLeakPattern(input[11:]) { - return "weak mic transport leak pattern in touch/motion" - } - return "" } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 6b94b906..8d50a267 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -2,9 +2,11 @@ package dualsense import ( "encoding/binary" + "hash/crc32" "io" "log/slog" "net" + "slices" "strings" "testing" ) @@ -33,6 +35,24 @@ func makeStreamFrameHeader(frameType byte, payloadLength int) []byte { return frame } +func makeStreamFrameV2(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] = StreamFrameVersionV2 + 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 { @@ -88,71 +108,12 @@ func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { } } -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) - } - - dev.mtx.Lock() - gotMicrophoneLen := len(dev.microphonePCM) - dev.mtx.Unlock() - if gotMicrophoneLen != 0 { - t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen) - } -} - -func TestQueueMicrophonePCMFrameRequiresActiveInterface(t *testing.T) { +func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(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 len(dev.microphonePCM) != 0 { - t.Fatalf("inactive mic interface should drop PCM, got %d bytes", len(dev.microphonePCM)) - } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) - dev.QueueMicrophonePCMFrame(frame) - if len(dev.microphonePCM) != USBMicrophoneClientFrameSize { - t.Fatalf("active mic interface should queue PCM, got %d bytes", len(dev.microphonePCM)) - } - - dev.SetInterfaceAltSetting(InterfaceMicrophone, 0) - if len(dev.microphonePCM) != 0 { - t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", len(dev.microphonePCM)) - } -} - -func TestReadDualSenseInputStreamDropsCorruptedTransportMagicInput(t *testing.T) { - dev, err := New(nil) - if err != nil { - t.Fatalf("New returned error: %v", err) - } server, client := net.Pipe() defer server.Close() @@ -160,57 +121,52 @@ func TestReadDualSenseInputStreamDropsCorruptedTransportMagicInput(t *testing.T) errCh := make(chan error, 1) go func() { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - errCh <- readDualSenseInputStream(server, dev, logger, true) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) }() state := NewInputState() - state.LX = 12 - state.R2 = 34 - state.Buttons = ButtonCross - state.GyroX = 111 - state.GyroY = 222 - state.GyroZ = 333 - state.AccelX = 444 - state.AccelY = 555 - state.AccelZ = 666 + state.LX = 42 + state.R2 = 99 inputPayload, err := state.MarshalBinary() if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[25:33], makeStreamFrameHeader(StreamFrameMicrophonePCM, USBMicrophoneClientFrameSize)) + microphonePayload := make([]byte, USBMicrophoneClientFrameSize) + for i := range microphonePayload { + microphonePayload[i] = byte(i) + } - if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { + 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.Close(); err != nil { t.Fatalf("close client pipe: %v", err) } if err := <-errCh; err != nil { - t.Fatalf("readDualSenseInputStream returned error: %v", err) + t.Fatalf("readDualSenseInputStreamVersion returned error: %v", err) } dev.mtx.Lock() gotInput := dev.inputState + gotMicrophone := append([]byte(nil), dev.microphonePCM...) dev.mtx.Unlock() - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.LY != neutral.LY || - gotInput.RX != neutral.RX || gotInput.RY != neutral.RY || - gotInput.Buttons != neutral.Buttons || gotInput.DPad != neutral.DPad || - gotInput.L2 != neutral.L2 || gotInput.R2 != neutral.R2 { - t.Fatalf("corrupted input was not reset to neutral controls: got LX=%d LY=%d RX=%d RY=%d buttons=%#x dpad=%#x L2=%d R2=%d", - gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.Buttons, gotInput.DPad, gotInput.L2, gotInput.R2) + if gotInput.LX != state.LX || gotInput.R2 != state.R2 { + t.Fatalf("unexpected input state: LX=%d R2=%d", gotInput.LX, gotInput.R2) } - if gotInput.GyroX != 0 || gotInput.GyroY != 0 || gotInput.GyroZ != 0 || - gotInput.AccelX != DefaultAccelXRaw || gotInput.AccelY != DefaultAccelYRaw || gotInput.AccelZ != DefaultAccelZRaw { - t.Fatalf("corrupted input motion was not reset to neutral: gyro=%d,%d,%d accel=%d,%d,%d", - gotInput.GyroX, gotInput.GyroY, gotInput.GyroZ, - gotInput.AccelX, gotInput.AccelY, gotInput.AccelZ) + if len(gotMicrophone) != len(microphonePayload) { + t.Fatalf("unexpected microphone queue length: %d", len(gotMicrophone)) + } + if !slices.Equal(gotMicrophone, microphonePayload) { + t.Fatal("microphone payload changed in transport") } } -func TestReadDualSenseInputStreamDropsPlainTransportMagicInputBytes(t *testing.T) { +func TestReadDualSenseInputStreamRejectsUnversionedMicFrames(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) @@ -225,134 +181,142 @@ func TestReadDualSenseInputStreamDropsPlainTransportMagicInputBytes(t *testing.T errCh <- readDualSenseInputStream(server, dev, logger, true) }() - state := NewInputState() - state.LX = int8(StreamFrameMagic0) - state.LY = int8(StreamFrameMagic1) - state.RX = int8(StreamFrameMagic2) - state.RY = int8(StreamFrameMagic3) - state.R2 = 77 - 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) + 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) } - if err := <-errCh; err != nil { - t.Fatalf("readDualSenseInputStream returned error: %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) } dev.mtx.Lock() - gotInput := dev.inputState + gotMicrophoneLen := len(dev.microphonePCM) dev.mtx.Unlock() - - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.LY != neutral.LY || - gotInput.RX != neutral.RX || gotInput.RY != neutral.RY || - gotInput.R2 != neutral.R2 { - t.Fatalf("plain transport magic bytes should reset input: got LX=%d LY=%d RX=%d RY=%d R2=%d", - gotInput.LX, gotInput.LY, gotInput.RX, gotInput.RY, gotInput.R2) + if gotMicrophoneLen != 0 { + t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen) } } -func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { +func TestQueueMicrophonePCMFrameRequiresActiveInterface(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 = 55 - state.R2 = 88 - inputPayload, err := state.MarshalBinary() - if err != nil { - t.Fatalf("MarshalBinary returned error: %v", err) - } - copy(inputPayload[6:9], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) - - 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) + frame := make([]byte, USBMicrophoneClientFrameSize) + dev.QueueMicrophonePCMFrame(frame) + if len(dev.microphonePCM) != 0 { + t.Fatalf("inactive mic interface should drop PCM, got %d bytes", len(dev.microphonePCM)) } - if err := <-errCh; err != nil { - t.Fatalf("readDualSenseInputStream returned error: %v", err) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) + dev.QueueMicrophonePCMFrame(frame) + if len(dev.microphonePCM) != USBMicrophoneClientFrameSize { + t.Fatalf("active mic interface should queue PCM, got %d bytes", len(dev.microphonePCM)) } - dev.mtx.Lock() - gotInput := dev.inputState - dev.mtx.Unlock() - - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.Buttons != neutral.Buttons { - t.Fatalf("transport marker fragment should reset input: got LX=%d R2=%d buttons=%#x", - gotInput.LX, gotInput.R2, gotInput.Buttons) + dev.SetInterfaceAltSetting(InterfaceMicrophone, 0) + if len(dev.microphonePCM) != 0 { + t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", len(dev.microphonePCM)) } } -func TestReadDualSenseInputStreamDropsTransportMarkerFragmentsInTouchMotion(t *testing.T) { +func TestQueueMicrophonePCMFrameKeepsNewestFourFrames(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 = 55 - state.R2 = 88 - inputPayload, err := state.MarshalBinary() - if err != nil { - t.Fatalf("MarshalBinary returned error: %v", err) - } - copy(inputPayload[21:24], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2}) - - 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 { - t.Fatalf("readDualSenseInputStream returned error: %v", err) + for value := byte(0); value < 5; value++ { + frame := make([]byte, USBMicrophoneClientFrameSize) + for i := range frame { + frame[i] = value + } + dev.QueueMicrophonePCMFrame(frame) } dev.mtx.Lock() - gotInput := dev.inputState + queued := append([]byte(nil), dev.microphonePCM...) dev.mtx.Unlock() - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { - t.Fatalf("touch/motion transport marker fragment should reset input: got LX=%d R2=%d GyroX=%d", - gotInput.LX, gotInput.R2, gotInput.GyroX) + if len(queued) != USBMicrophoneClientFrameSize*4 { + t.Fatalf("unexpected bounded queue length: %d", len(queued)) + } + if queued[0] != 1 || queued[len(queued)-1] != 4 { + t.Fatalf("queue did not retain newest frames: first=%d last=%d", queued[0], queued[len(queued)-1]) + } +} + +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 TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(t *testing.T) { +func TestReadDualSenseInputStreamV2RejectsBadCRC(t *testing.T) { dev, err := New(nil) if err != nil { t.Fatalf("New returned error: %v", err) @@ -364,46 +328,35 @@ func TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(t *te errCh := make(chan error, 1) go func() { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - errCh <- readDualSenseInputStream(server, dev, logger, true) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) }() state := NewInputState() - state.LX = 55 - state.R2 = 88 + state.LX = 12 inputPayload, err := state.MarshalBinary() if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[21:26], []byte{StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}) - if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { + 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 { - t.Fatalf("readDualSenseInputStream returned error: %v", err) - } - - dev.mtx.Lock() - gotInput := dev.inputState - dev.mtx.Unlock() - - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { - t.Fatalf("touch/motion mic transport leak should reset input: got LX=%d R2=%d GyroX=%d", - gotInput.LX, gotInput.R2, gotInput.GyroX) + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "CRC mismatch") { + t.Fatalf("expected CRC mismatch, got %v", err) } } -func TestReadDualSenseInputStreamDropsWeakMicTransportLeakPatternWhenMicrophoneActive(t *testing.T) { +func TestReadDualSenseInputStreamV2RejectsSequenceGap(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() @@ -411,58 +364,22 @@ func TestReadDualSenseInputStreamDropsWeakMicTransportLeakPatternWhenMicrophoneA errCh := make(chan error, 1) go func() { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - errCh <- readDualSenseInputStream(server, dev, logger, true) + errCh <- readDualSenseInputStreamVersion(server, dev, logger, true, StreamFrameVersionV2) }() state := NewInputState() - state.LX = 55 - state.R2 = 88 inputPayload, err := state.MarshalBinary() if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[21:24], []byte{0x01, 0x01, hidClassOUT}) - - if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 10, 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("readDualSenseInputStream returned error: %v", err) - } - - dev.mtx.Lock() - gotInput := dev.inputState - dev.mtx.Unlock() - - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.R2 != neutral.R2 || gotInput.GyroX != neutral.GyroX { - t.Fatalf("weak touch/motion mic transport leak should reset input: got LX=%d R2=%d GyroX=%d", - gotInput.LX, gotInput.R2, gotInput.GyroX) - } -} - -func TestContainsMicTransportLeakPatternShiftedFragments(t *testing.T) { - cases := [][]byte{ - {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, - {StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, - {0x01, 0x01, hidClassOUT}, - {StreamFrameMagic2, StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}, - {StreamFrameMagic1, 0x80, 0x87, StreamFrameMagic2}, - {0x80, 0x87, StreamFrameMagic2}, + if _, err := client.Write(makeStreamFrameV2(StreamFrameInputState, 12, inputPayload)); err != nil { + t.Fatalf("write second input frame: %v", err) } - - for _, tc := range cases { - if !containsMicTransportLeakPattern(tc) { - t.Fatalf("expected shifted mic transport leak pattern to match: % x", tc) - } - } - - if containsMicTransportLeakPattern([]byte{0x80, 0x87, 0x42}) { - t.Fatal("near miss should not match mic transport leak pattern") + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "sequence mismatch") { + t.Fatalf("expected sequence mismatch, got %v", err) } } @@ -497,18 +414,8 @@ func TestReadDualSenseInputStreamDropsInvalidControlBits(t *testing.T) { 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 - dev.mtx.Unlock() - - neutral := NewInputState() - if gotInput.LX != neutral.LX || gotInput.Buttons != neutral.Buttons || gotInput.DPad != neutral.DPad { - t.Fatalf("invalid control bits should reset input: got LX=%d buttons=%#x dpad=%#x", - gotInput.LX, gotInput.Buttons, gotInput.DPad) + if err := <-errCh; err == nil || !strings.Contains(err.Error(), "invalid controls") { + t.Fatalf("expected invalid controls error, got %v", err) } } diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index 3e0aef3d..a036622c 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -16,13 +16,15 @@ 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}) + api.RegisterDevice("dualsenseedgecombinedmicext", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersion}) + api.RegisterDevice("dualsenseedgecombinedmicv2", &dsedgehandler{combinedBluetoothFeedback: true, microphoneInput: true, streamFrameVersion: StreamFrameVersionV2}) } type dsedgehandler struct { extendedFeedback bool combinedBluetoothFeedback bool microphoneInput bool + streamFrameVersion byte } func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { @@ -140,7 +142,7 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } }) - return readDualSenseInputStream(conn, dse, logger, h.microphoneInput) + return readDualSenseInputStreamVersion(conn, dse, logger, h.microphoneInput, h.streamFrameVersion) } } From 4db2c3097d415faa37c763dfab8e8000f31f5b8c Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 09:13:43 -0500 Subject: [PATCH 278/298] Build microphone branch snapshots --- .github/workflows/snapshots.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index c7d795f9..d85ba9ab 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -2,7 +2,7 @@ name: Dev Snapshot Build on: push: - branches: ["main"] + branches: ["main", "microphone-rebuild"] permissions: contents: write From 279b37c325e4b6fb341c982692623e8bcfaba301 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 09:18:29 -0500 Subject: [PATCH 279/298] Remove unused microphone state helper --- device/dualsense/device.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 24517260..49d9944d 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -920,12 +920,6 @@ func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { return b } -func (d *DualSense) isMicrophoneInterfaceActive() bool { - d.mtx.Lock() - defer d.mtx.Unlock() - return d.microphoneInterfaceActive -} - func inputStateControlsInvalid(s *InputState) bool { if s == nil { return false From 09e6f344712347c2297dceb2fa76928eee337557 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 10:43:44 -0500 Subject: [PATCH 280/298] Route DualSense microphone ISO input correctly --- device/dualsense/device.go | 13 ++++++++----- device/dualsense/device_output_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 49d9944d..f3a16da1 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -204,9 +204,12 @@ func (d *DualSense) SetInterfaceAltSetting(iface, alt uint8) { } 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 ep { - case 4: + switch epNumber { + case EndpointIn & 0x0F: select { case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { @@ -223,14 +226,14 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o d.mtx.Unlock() return d.buildUSBInputReport(&is, &ms) } - case EndpointMicrophoneIn: + case EndpointMicrophoneIn & 0x0F: return d.handleMicrophoneIn(ctx) default: return nil } } - if dir == usbip.DirOut && ep == EndpointOut { + if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { recordTrafficBytes("host->device", "interrupt-out", out, "summary", fmt.Sprintf("ep=%d", ep)) @@ -238,7 +241,7 @@ func (d *DualSense) HandleTransfer(ctx context.Context, ep uint32, dir uint32, o return nil } } - if dir == usbip.DirOut && ep == EndpointHapticsAudioOut { + if dir == usbip.DirOut && epNumber == EndpointHapticsAudioOut&0x0F { d.handleHapticsAudioOut(out) return nil } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 032f75aa..252ed85b 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -31,6 +31,30 @@ func TestDualSenseUSBOutputReportDescriptorMatchesCapture(t *testing.T) { } } +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) + } + 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 { From f1e883408c0079117d197aa8fe85466a26cbe04d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 11:10:35 -0500 Subject: [PATCH 281/298] Preserve DualSense framed stream protocol --- device/dualsense/device.go | 2 ++ device/dualsense/ds_handler.go | 12 ++++++- device/dualsense/ds_handler_test.go | 50 +++++++++++++++++++++++++++++ device/dualsense/dsedge_handler.go | 12 ++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index f3a16da1..6e393333 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -31,6 +31,8 @@ type DualSense struct { descriptor usb.Descriptor extendedFeedback bool combinedBluetoothFeedback bool + microphoneInput bool + streamFrameVersion byte subcommand [2]byte diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index aa47f461..121e3259 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -95,6 +95,8 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { } dse.extendedFeedback = h.extendedFeedback dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback + dse.microphoneInput = h.microphoneInput + dse.streamFrameVersion = h.streamFrameVersion return dse, nil } @@ -145,7 +147,15 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } }) - return readDualSenseInputStreamVersion(conn, dse, logger, h.microphoneInput, h.streamFrameVersion) + microphoneInput := h.microphoneInput || dse.microphoneInput + streamFrameVersion := h.streamFrameVersion + if dse.streamFrameVersion != 0 { + streamFrameVersion = dse.streamFrameVersion + } + logger.Info("DualSense input stream configured", + "microphoneInput", microphoneInput, + "frameVersion", streamFrameVersion) + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) } } diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 8d50a267..ca3b9966 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -166,6 +166,56 @@ func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(t } } +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 { diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index a036622c..f5520754 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -92,6 +92,8 @@ func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error } dse.extendedFeedback = h.extendedFeedback dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback + dse.microphoneInput = h.microphoneInput + dse.streamFrameVersion = h.streamFrameVersion return dse, nil } @@ -142,7 +144,15 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } }) - return readDualSenseInputStreamVersion(conn, dse, logger, h.microphoneInput, h.streamFrameVersion) + microphoneInput := h.microphoneInput || dse.microphoneInput + streamFrameVersion := h.streamFrameVersion + if dse.streamFrameVersion != 0 { + streamFrameVersion = dse.streamFrameVersion + } + logger.Info("DualSense Edge input stream configured", + "microphoneInput", microphoneInput, + "frameVersion", streamFrameVersion) + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) } } From 95536ab47d2ccfaa7d39a819ae96ed0d4306d8a9 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 11:36:15 -0500 Subject: [PATCH 282/298] Pack USBIP microphone ISO input correctly --- internal/server/usb/iso_test.go | 68 +++++++++++++++++++++++++++++++++ internal/server/usb/server.go | 38 +++++------------- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/internal/server/usb/iso_test.go b/internal/server/usb/iso_test.go index 4c2071fb..549e790b 100644 --- a/internal/server/usb/iso_test.go +++ b/internal/server/usb/iso_test.go @@ -1,6 +1,8 @@ package usb import ( + "bytes" + "context" "testing" "time" @@ -8,6 +10,26 @@ import ( "github.com/Alia5/VIIPER/usbip" ) +type isoInTestDevice struct { + desc *usbdesc.Descriptor + payloads [][]byte + calls int +} + +func (d *isoInTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { + payload := d.payloads[d.calls] + d.calls++ + return payload +} + +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}, @@ -28,6 +50,52 @@ func TestCompleteIsoPacketsPreservesOffsetsAndLengths(t *testing.T) { } } +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) + + 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}, diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 60147bbc..d9270538 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -1071,20 +1071,18 @@ func (s *Server) buildIsoInResponse( } actualLengths := make([]uint32, len(submitted)) - totalLen := uint32(0) + maximumLen := uint32(0) for _, packet := range submitted { - if packet.Length == 0 { - continue - } - end := packet.Offset + packet.Length - if end > totalLen { - totalLen = end - } + maximumLen += packet.Length } - respData := make([]byte, totalLen) + // 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) for i, packet := range submitted { - if packet.Length == 0 || packet.Offset >= uint32(len(respData)) { + if packet.Length == 0 { continue } @@ -1098,28 +1096,12 @@ func (s *Server) buildIsoInResponse( packetData = make([]byte, int(packet.Length)) } - available := uint32(len(respData)) - packet.Offset - desired := min(packet.Length, available) - actual := min(desired, uint32(len(packetData))) - copy(respData[packet.Offset:packet.Offset+actual], packetData[:actual]) + actual := min(packet.Length, uint32(len(packetData))) + respData = append(respData, packetData[:actual]...) actualLengths[i] = actual } completed := completeIsoPacketsWithActuals(submitted, actualLengths) - actualTotal := uint32(0) - for i, packet := range completed { - if i >= len(submitted) || packet.ActualLength == 0 { - continue - } - end := submitted[i].Offset + packet.ActualLength - if end > actualTotal { - actualTotal = end - } - } - if actualTotal < uint32(len(respData)) { - respData = respData[:actualTotal] - } - return respData, completed } From 1aa659131ba330ce68025d5b443415c41389adcb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 20:02:47 -0500 Subject: [PATCH 283/298] Retarget installation docs to hbashton releases --- README.md | 382 +++++++++++++++++++++++--------------------- scripts/install.ps1 | 76 ++++++--- 2 files changed, 248 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index cfd7b0d2..2293854e 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,257 @@ - -
+VIIPER logo -[![Build Status](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml/badge.svg)](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml) -[![codecov](https://codecov.io/github/Alia5/VIIPER/graph/badge.svg?token=5WTEELM3X3)](https://codecov.io/github/Alia5/VIIPER) -[![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE.txt) -[![Client Libraries: MIT](https://img.shields.io/badge/Client_Libraries-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) -[![Downloads](https://img.shields.io/github/downloads/alia5/VIIPER/total?logo=github)](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) -[![npm version](https://img.shields.io/npm/v/viiperclient?logo=npm)](https://www.npmjs.com/package/viiperclient) -[![npm downloads](https://img.shields.io/npm/dm/viiperclient?logo=npm&label=downloads)](https://www.npmjs.com/package/viiperclient) -[![NuGet version](https://img.shields.io/nuget/v/Viiper.Client?logo=nuget)](https://www.nuget.org/packages/Viiper.Client/) -[![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) -[![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) -[![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) -[![C++ Client Library](https://img.shields.io/badge/C++_Client_Library-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) -[![Discord](https://img.shields.io/discord/368823110817808384?logo=discord&logoColor=white&label=Discord&color=%23535fe5 -)](https://discord.gg/hs34MtcHJY) +# VIIPER -# VIIPER 🐍 +[![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** **I**nput over **IP** **E**mulato**R** +**Virtual Input over IP EmulatoR** -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. +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 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. +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. -- 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. +> **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 comes in two distinct flavors: +## Install for DS4Windows -- **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](docs/libviiper/overview.md) for details and examples. +The simplest and recommended path is through a VIIPER-capable DS4Windows build: -For why you should pick one over the other see the [FAQ](#why-choose-the-standalone-executable-and-interfacing-via-tcp-over-the-shared-object-libviiper-library) +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. +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. -**Emulatable devices:** +## Install VIIPER directly on Windows -- Xbox 360 controller emulation; 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) -- PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) -- PS5 DualSense controller emulation (including Edge variant); see [Devices › DualSense Controller](docs/devices/dualsense.md) -- Nintendo Switch 2 Pro Controller emulation; see [Devices › Switch 2 Pro Controller](docs/devices/ns2pro.md) +Windows x64 users can run the hbashton installer from PowerShell: -## 🔌 Requirements +```powershell +irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex +``` -**Linux:** +The script: -- **Arch Linux:** - - Install: `sudo pacman -S usbip` - - Docs: [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) +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. -- **Ubuntu:** - - Install: `sudo apt install linux-tools-generic` - - Docs: [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) +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. -**Windows:** +## What the hbashton fork adds -- [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). +### DS4Windows controller backends ---- +VIIPER can expose the following virtual USB devices for DS4Windows: -## 🥫 Feeder application development +- Xbox 360 controller +- DualShock 4 +- DualSense +- DualSense Edge +- Nintendo Switch 2 Pro Controller -You have two options for developing feeder applications that control the virtual devices created by VIIPER: +The generic VIIPER keyboard and mouse devices remain available to other feeder +applications. -- 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 [Examples](examples/libVIIPER) for examples in either C or C#. +### Native DualSense input -### 🔌 API +The virtual DualSense and DualSense Edge paths carry: -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. +- 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 -> [!TIP] -Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. -See [Client Libraries](docs/api/overview.md). +### Output reports and adaptive triggers -- 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 returns host output to DS4Windows instead of reducing every command to +generic rumble. Extended DualSense streams preserve: -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. +- 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` -See the [API documentation](./docs/api) for details +This lets DS4Windows forward game-authored adaptive-trigger commands and other +DualSense output to a compatible physical controller. ---- +### Advanced haptics and speaker audio -## 🛠️ VIIPER development +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. -### 🧰 Prerequisites +With the matching DS4Windows bridge: -- [Go](https://go.dev/) 1.26 or newer -- USBIP installed -- (Optional) [just](https://github.com/casey/just) - - Windows: `winget install --id Casey.Just --exact` - - Linux/macOS: `cargo install just` or use your package manager -- Windows compiler (required for `build-libVIIPER`): - - `winget install -e --id MartinStorsjo.LLVM-MinGW.UCRT` - `--accept-package-agreements --accept-source-agreements` - -### 🔄 Building from Source - -```bash -git clone https://github.com/Alia5/VIIPER.git -cd VIIPER -just build Release -``` - -The binary will be in `dist/viiper--` (for example `dist/viiper-windows-amd64.exe`). - -For more build options: - -```bash -just --list # Show all available targets -just test # Run tests -``` +- 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 -## 🤝 Contributing +The microphone-capable DualSense device types expose a virtual Windows recording +endpoint. The framed feeder protocol accepts PCM microphone frames separately +from controller input state, and the USBIP ISO-IN path supplies them to Windows. -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. +In the DS4Windows integration, microphone audio follows this path: ---- +1. The physical Bluetooth DualSense supplies its encoded microphone frames. +2. DS4Windows decodes and conditions the signal. +3. DS4Windows sends framed PCM to VIIPER. +4. VIIPER presents that PCM through the virtual DualSense recording endpoint. -## ❓ FAQ +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. -### What is USBIP and why does VIIPER use it? +## Architecture -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 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? +```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 +``` -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. +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. -### How is VIIPER different from other controller emulators? +## Requirements -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. +### Windows -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. +- 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 -This makes VIIPER portable, easier to extend and simpler to bundle with applications. -Adding a new device type never requires touching kernel code. +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 add support for other device types? +### Linux development -Yes! VIIPER's architecture is designed to be extensible. -Check the [xbox360 device implementation](./device/xbox360/) as a reference for creating new device types. +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. +## Server and API -### You mentioned proxying USBIP? +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. -VIIPER as a proxy mode that sits between a USBIP client and a USBIP server (like a Linux machine sharing real USB devices). -THis intercepts and logs all URBs passing through, without handling the devices directly. -Useful for reverse engineering USB protocols and understanding how devices communicate. +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. -### What about TCP overhead or input latency performance? +See: -End-to-end input latency for virtual devices created with VIIPER could be 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](/docs/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. +- [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) ---- +## 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-2026 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 ``` -## Credits / Inspiration - -- [REDACTED-Bus aka ViGEmBus](https://github.com/nefarius/ViGEmBus) - (Retired, but still widely used) Windows kernel-mode driver emulating well-known USB game controllers - Shoutout and thank you to @nefarius for paving the way and always being a super decent guy! -- [Valve Software](https://www.valvesoftware.com/) - For creating the OG Steam Controller (2015) and Steam Input (and the way it, understandably, works...) - that sent me down this rabbit hole in the first place - I kinda hate you guys... in good way(?) ;) -- **USBIP** without VIIPER would not be possible. - - [USBIP](https://usbip.sourceforge.net/) - - [USBIP-Win2](https://github.com/vadimgrn/usbip-win2) -- [SDL](https://www.libsdl.org/) - For their excellent work on input device handling, reducing reversing efforts to a minimum. +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/scripts/install.ps1 b/scripts/install.ps1 index 62efa3c2..7fdb0f19 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,9 +1,14 @@ -$ErrorActionPreference = "Stop" - -$viiperVersion = "dev-snapshot" - -$repo = "Alia5/VIIPER" -$apiUrl = "https://api.github.com/repos/$repo/releases/tags/$viiperVersion" +$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 @@ -16,19 +21,31 @@ if (-not $version) { 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") { - $arch = "arm64" -} - -$archiveName = "viiper-windows-$arch.zip" -$downloadUrl = "https://github.com/$repo/releases/download/$version/$archiveName" - -Write-Host "Downloading from: $downloadUrl" +$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 { @@ -52,12 +69,21 @@ try { catch { return $null } } - $tempArchive = Join-Path $tempDir "release.zip" - Invoke-WebRequest -Uri $downloadUrl -OutFile $tempArchive -ErrorAction Stop - - Expand-Archive -LiteralPath $tempArchive -DestinationPath $tempDir -Force - - $tempViiper = Join-Path $tempDir "viiper.exe" + $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" } From b24d45f3193e730071100afca9f98134ee818223 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 20:02:47 -0500 Subject: [PATCH 284/298] Retarget installation docs to hbashton releases --- README.md | 382 +++++++++++++++++++++++--------------------- scripts/install.ps1 | 76 ++++++--- 2 files changed, 248 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index cfd7b0d2..2293854e 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,257 @@ - -
+VIIPER logo -[![Build Status](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml/badge.svg)](https://github.com/alia5/VIIPER/actions/workflows/snapshots.yml) -[![codecov](https://codecov.io/github/Alia5/VIIPER/graph/badge.svg?token=5WTEELM3X3)](https://codecov.io/github/Alia5/VIIPER) -[![License: GPL-3.0](https://img.shields.io/github/license/alia5/VIIPER)](https://github.com/alia5/VIIPER/blob/main/LICENSE.txt) -[![Client Libraries: MIT](https://img.shields.io/badge/Client_Libraries-MIT-green)](https://github.com/alia5/VIIPER/blob/main/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) -[![Downloads](https://img.shields.io/github/downloads/alia5/VIIPER/total?logo=github)](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) -[![npm version](https://img.shields.io/npm/v/viiperclient?logo=npm)](https://www.npmjs.com/package/viiperclient) -[![npm downloads](https://img.shields.io/npm/dm/viiperclient?logo=npm&label=downloads)](https://www.npmjs.com/package/viiperclient) -[![NuGet version](https://img.shields.io/nuget/v/Viiper.Client?logo=nuget)](https://www.nuget.org/packages/Viiper.Client/) -[![NuGet downloads](https://img.shields.io/nuget/dt/Viiper.Client?logo=nuget&label=downloads)](https://www.nuget.org/packages/Viiper.Client/) -[![crates.io version](https://img.shields.io/crates/v/viiper-client?logo=rust)](https://crates.io/crates/viiper-client) -[![crates.io downloads](https://img.shields.io/crates/d/viiper-client?logo=rust&label=downloads)](https://crates.io/crates/viiper-client) -[![C++ Client Library](https://img.shields.io/badge/C++_Client_Library-Header_Only-blue)](https://github.com/Alia5/VIIPER/releases) -[![Discord](https://img.shields.io/discord/368823110817808384?logo=discord&logoColor=white&label=Discord&color=%23535fe5 -)](https://discord.gg/hs34MtcHJY) +# VIIPER -# VIIPER 🐍 +[![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** **I**nput over **IP** **E**mulato**R** +**Virtual Input over IP EmulatoR** -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. +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 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. +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. -- 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. +> **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 comes in two distinct flavors: +## Install for DS4Windows -- **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](docs/libviiper/overview.md) for details and examples. +The simplest and recommended path is through a VIIPER-capable DS4Windows build: -For why you should pick one over the other see the [FAQ](#why-choose-the-standalone-executable-and-interfacing-via-tcp-over-the-shared-object-libviiper-library) +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. +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. -**Emulatable devices:** +## Install VIIPER directly on Windows -- Xbox 360 controller emulation; 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) -- PS4 controller emulation; see [Devices › DualShock 4 Controller](docs/devices/dualshock4.md) -- PS5 DualSense controller emulation (including Edge variant); see [Devices › DualSense Controller](docs/devices/dualsense.md) -- Nintendo Switch 2 Pro Controller emulation; see [Devices › Switch 2 Pro Controller](docs/devices/ns2pro.md) +Windows x64 users can run the hbashton installer from PowerShell: -## 🔌 Requirements +```powershell +irm https://raw.githubusercontent.com/hbashton/VIIPER/main/scripts/install.ps1 | iex +``` -**Linux:** +The script: -- **Arch Linux:** - - Install: `sudo pacman -S usbip` - - Docs: [Arch Wiki: USBIP](https://wiki.archlinux.org/title/USB/IP) +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. -- **Ubuntu:** - - Install: `sudo apt install linux-tools-generic` - - Docs: [Ubuntu USBIP Manual](https://manpages.ubuntu.com/manpages/noble/man8/usbip.8.html) +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. -**Windows:** +## What the hbashton fork adds -- [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). +### DS4Windows controller backends ---- +VIIPER can expose the following virtual USB devices for DS4Windows: -## 🥫 Feeder application development +- Xbox 360 controller +- DualShock 4 +- DualSense +- DualSense Edge +- Nintendo Switch 2 Pro Controller -You have two options for developing feeder applications that control the virtual devices created by VIIPER: +The generic VIIPER keyboard and mouse devices remain available to other feeder +applications. -- 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 [Examples](examples/libVIIPER) for examples in either C or C#. +### Native DualSense input -### 🔌 API +The virtual DualSense and DualSense Edge paths carry: -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. +- 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 -> [!TIP] -Most of the time, you don't need to implement that raw protocol yourself, as client libraries are available. -See [Client Libraries](docs/api/overview.md). +### Output reports and adaptive triggers -- 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 returns host output to DS4Windows instead of reducing every command to +generic rumble. Extended DualSense streams preserve: -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. +- 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` -See the [API documentation](./docs/api) for details +This lets DS4Windows forward game-authored adaptive-trigger commands and other +DualSense output to a compatible physical controller. ---- +### Advanced haptics and speaker audio -## 🛠️ VIIPER development +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. -### 🧰 Prerequisites +With the matching DS4Windows bridge: -- [Go](https://go.dev/) 1.26 or newer -- USBIP installed -- (Optional) [just](https://github.com/casey/just) - - Windows: `winget install --id Casey.Just --exact` - - Linux/macOS: `cargo install just` or use your package manager -- Windows compiler (required for `build-libVIIPER`): - - `winget install -e --id MartinStorsjo.LLVM-MinGW.UCRT` - `--accept-package-agreements --accept-source-agreements` - -### 🔄 Building from Source - -```bash -git clone https://github.com/Alia5/VIIPER.git -cd VIIPER -just build Release -``` - -The binary will be in `dist/viiper--` (for example `dist/viiper-windows-amd64.exe`). - -For more build options: - -```bash -just --list # Show all available targets -just test # Run tests -``` +- 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 -## 🤝 Contributing +The microphone-capable DualSense device types expose a virtual Windows recording +endpoint. The framed feeder protocol accepts PCM microphone frames separately +from controller input state, and the USBIP ISO-IN path supplies them to Windows. -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. +In the DS4Windows integration, microphone audio follows this path: ---- +1. The physical Bluetooth DualSense supplies its encoded microphone frames. +2. DS4Windows decodes and conditions the signal. +3. DS4Windows sends framed PCM to VIIPER. +4. VIIPER presents that PCM through the virtual DualSense recording endpoint. -## ❓ FAQ +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. -### What is USBIP and why does VIIPER use it? +## Architecture -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 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? +```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 +``` -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. +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. -### How is VIIPER different from other controller emulators? +## Requirements -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. +### Windows -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. +- 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 -This makes VIIPER portable, easier to extend and simpler to bundle with applications. -Adding a new device type never requires touching kernel code. +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 add support for other device types? +### Linux development -Yes! VIIPER's architecture is designed to be extensible. -Check the [xbox360 device implementation](./device/xbox360/) as a reference for creating new device types. +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. +## Server and API -### You mentioned proxying USBIP? +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. -VIIPER as a proxy mode that sits between a USBIP client and a USBIP server (like a Linux machine sharing real USB devices). -THis intercepts and logs all URBs passing through, without handling the devices directly. -Useful for reverse engineering USB protocols and understanding how devices communicate. +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. -### What about TCP overhead or input latency performance? +See: -End-to-end input latency for virtual devices created with VIIPER could be 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](/docs/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. +- [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) ---- +## 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-2026 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 ``` -## Credits / Inspiration - -- [REDACTED-Bus aka ViGEmBus](https://github.com/nefarius/ViGEmBus) - (Retired, but still widely used) Windows kernel-mode driver emulating well-known USB game controllers - Shoutout and thank you to @nefarius for paving the way and always being a super decent guy! -- [Valve Software](https://www.valvesoftware.com/) - For creating the OG Steam Controller (2015) and Steam Input (and the way it, understandably, works...) - that sent me down this rabbit hole in the first place - I kinda hate you guys... in good way(?) ;) -- **USBIP** without VIIPER would not be possible. - - [USBIP](https://usbip.sourceforge.net/) - - [USBIP-Win2](https://github.com/vadimgrn/usbip-win2) -- [SDL](https://www.libsdl.org/) - For their excellent work on input device handling, reducing reversing efforts to a minimum. +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/scripts/install.ps1 b/scripts/install.ps1 index 62efa3c2..7fdb0f19 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,9 +1,14 @@ -$ErrorActionPreference = "Stop" - -$viiperVersion = "dev-snapshot" - -$repo = "Alia5/VIIPER" -$apiUrl = "https://api.github.com/repos/$repo/releases/tags/$viiperVersion" +$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 @@ -16,19 +21,31 @@ if (-not $version) { 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") { - $arch = "arm64" -} - -$archiveName = "viiper-windows-$arch.zip" -$downloadUrl = "https://github.com/$repo/releases/download/$version/$archiveName" - -Write-Host "Downloading from: $downloadUrl" +$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 { @@ -52,12 +69,21 @@ try { catch { return $null } } - $tempArchive = Join-Path $tempDir "release.zip" - Invoke-WebRequest -Uri $downloadUrl -OutFile $tempArchive -ErrorAction Stop - - Expand-Archive -LiteralPath $tempArchive -DestinationPath $tempDir -Force - - $tempViiper = Join-Path $tempDir "viiper.exe" + $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" } From b847e4bc386bbc1d007c0871bd25cdd9432ec8a7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 21:52:55 -0500 Subject: [PATCH 285/298] Avoid false neutral DualSense input reports --- device/dualsense/ds_handler.go | 31 ++++++++++++++++------------- device/dualsense/ds_handler_test.go | 23 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 217d99cd..0828d8f2 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -265,20 +265,23 @@ func inputStatePayloadCorruptionReason(input []byte, microphoneActive bool) stri return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) } - // The mic storm observed in the wild leaked framed-stream markers into - // touch and motion bytes too. A three-byte VPC/PCM fragment is specific - // enough to reject the whole input frame before it can become a HID report. - if containsStreamMarkerFragment(input, 11) { - return "transport marker fragment in controls" - } - if containsStreamMarkerFragment(input[11:], len(input)-11) { - return "transport marker fragment in touch/motion" - } - if containsStrongMicTransportLeakPattern(input[11:]) { - return "mic transport leak pattern in touch/motion" - } - if microphoneActive && containsWeakMicTransportLeakPattern(input[11:]) { - return "weak mic transport leak pattern in touch/motion" + // Short marker fragments can occur naturally in stick, touch, or motion + // samples. They only indicate transport leakage while the framed microphone + // path is active. Full framing magic and invalid controls remain guarded + // regardless of the interface state. + if microphoneActive { + if containsStreamMarkerFragment(input, 11) { + return "transport marker fragment in controls" + } + if containsStreamMarkerFragment(input[11:], len(input)-11) { + return "transport marker fragment in touch/motion" + } + if containsStrongMicTransportLeakPattern(input[11:]) { + return "mic transport leak pattern in touch/motion" + } + if containsWeakMicTransportLeakPattern(input[11:]) { + return "weak mic transport leak pattern in touch/motion" + } } return "" diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 6b94b906..50c4432a 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -265,6 +265,7 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { if err != nil { t.Fatalf("New returned error: %v", err) } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -282,7 +283,7 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[6:9], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) + copy(inputPayload[0:3], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { t.Fatalf("write input frame: %v", err) @@ -311,6 +312,7 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragmentsInTouchMotion(t *t if err != nil { t.Fatalf("New returned error: %v", err) } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -357,6 +359,7 @@ func TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(t *te if err != nil { t.Fatalf("New returned error: %v", err) } + dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -445,6 +448,24 @@ func TestReadDualSenseInputStreamDropsWeakMicTransportLeakPatternWhenMicrophoneA } } +func TestInputStatePayloadPreservesShortTransportMarkerWhenMicrophoneInactive(t *testing.T) { + state := NewInputState() + state.LX = 55 + state.R2 = 88 + inputPayload, err := state.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + copy(inputPayload[21:24], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2}) + + if reason := inputStatePayloadCorruptionReason(inputPayload, false); reason != "" { + t.Fatalf("short marker in legitimate motion data was rejected with microphone inactive: %s", reason) + } + if reason := inputStatePayloadCorruptionReason(inputPayload, true); reason == "" { + t.Fatal("short marker should remain guarded while the microphone interface is active") + } +} + func TestContainsMicTransportLeakPatternShiftedFragments(t *testing.T) { cases := [][]byte{ {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, From a0fba05c11ef5462bba150bd02adc457b48a0094 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 23:14:15 -0500 Subject: [PATCH 286/298] Revert "Avoid false neutral DualSense input reports" This reverts commit b847e4bc386bbc1d007c0871bd25cdd9432ec8a7. --- device/dualsense/ds_handler.go | 31 +++++++++++++---------------- device/dualsense/ds_handler_test.go | 23 +-------------------- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 0828d8f2..217d99cd 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -265,23 +265,20 @@ func inputStatePayloadCorruptionReason(input []byte, microphoneActive bool) stri return fmt.Sprintf("invalid controls buttons=0x%08X dpad=0x%02X", buttons, dpad) } - // Short marker fragments can occur naturally in stick, touch, or motion - // samples. They only indicate transport leakage while the framed microphone - // path is active. Full framing magic and invalid controls remain guarded - // regardless of the interface state. - if microphoneActive { - if containsStreamMarkerFragment(input, 11) { - return "transport marker fragment in controls" - } - if containsStreamMarkerFragment(input[11:], len(input)-11) { - return "transport marker fragment in touch/motion" - } - if containsStrongMicTransportLeakPattern(input[11:]) { - return "mic transport leak pattern in touch/motion" - } - if containsWeakMicTransportLeakPattern(input[11:]) { - return "weak mic transport leak pattern in touch/motion" - } + // The mic storm observed in the wild leaked framed-stream markers into + // touch and motion bytes too. A three-byte VPC/PCM fragment is specific + // enough to reject the whole input frame before it can become a HID report. + if containsStreamMarkerFragment(input, 11) { + return "transport marker fragment in controls" + } + if containsStreamMarkerFragment(input[11:], len(input)-11) { + return "transport marker fragment in touch/motion" + } + if containsStrongMicTransportLeakPattern(input[11:]) { + return "mic transport leak pattern in touch/motion" + } + if microphoneActive && containsWeakMicTransportLeakPattern(input[11:]) { + return "weak mic transport leak pattern in touch/motion" } return "" diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index 50c4432a..6b94b906 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -265,7 +265,6 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { if err != nil { t.Fatalf("New returned error: %v", err) } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -283,7 +282,7 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragments(t *testing.T) { if err != nil { t.Fatalf("MarshalBinary returned error: %v", err) } - copy(inputPayload[0:3], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) + copy(inputPayload[6:9], []byte{StreamFrameMagic1, StreamFrameMagic2, StreamFrameMagic3}) if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { t.Fatalf("write input frame: %v", err) @@ -312,7 +311,6 @@ func TestReadDualSenseInputStreamDropsTransportMarkerFragmentsInTouchMotion(t *t if err != nil { t.Fatalf("New returned error: %v", err) } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -359,7 +357,6 @@ func TestReadDualSenseInputStreamDropsMicTransportLeakPatternInTouchMotion(t *te if err != nil { t.Fatalf("New returned error: %v", err) } - dev.SetInterfaceAltSetting(InterfaceMicrophone, 1) server, client := net.Pipe() defer server.Close() @@ -448,24 +445,6 @@ func TestReadDualSenseInputStreamDropsWeakMicTransportLeakPatternWhenMicrophoneA } } -func TestInputStatePayloadPreservesShortTransportMarkerWhenMicrophoneInactive(t *testing.T) { - state := NewInputState() - state.LX = 55 - state.R2 = 88 - inputPayload, err := state.MarshalBinary() - if err != nil { - t.Fatalf("MarshalBinary returned error: %v", err) - } - copy(inputPayload[21:24], []byte{StreamFrameMagic0, StreamFrameMagic1, StreamFrameMagic2}) - - if reason := inputStatePayloadCorruptionReason(inputPayload, false); reason != "" { - t.Fatalf("short marker in legitimate motion data was rejected with microphone inactive: %s", reason) - } - if reason := inputStatePayloadCorruptionReason(inputPayload, true); reason == "" { - t.Fatal("short marker should remain guarded while the microphone interface is active") - } -} - func TestContainsMicTransportLeakPatternShiftedFragments(t *testing.T) { cases := [][]byte{ {StreamFrameMagic2, StreamFrameMagic3, 0x01, 0x01, hidClassOUT}, From 501b059b316233c773d42c823f883fc825dc296d Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 23:26:47 -0500 Subject: [PATCH 287/298] Preserve DualSense HID input ordering --- device/dualsense/device.go | 6 +++- device/dualsense/device_output_test.go | 42 ++++++++++++++++++++++++++ internal/server/usb/server.go | 35 +++++++++++++++++++-- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 6e393333..086bde15 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -52,7 +52,8 @@ type DualSense struct { hapticsPCMStartedAt time.Time timestampBase time.Time - mtx sync.Mutex + mtx sync.Mutex + inputReportMu sync.Mutex } func New(o *device.CreateOptions) (*DualSense, error) { @@ -834,6 +835,9 @@ func (d *DualSense) featureReportCommandResponse() []byte { } func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { + d.inputReportMu.Lock() + defer d.inputReportMu.Unlock() + b := make([]byte, InputReportSize) d.usbInputReportCount++ reportCount := d.usbInputReportCount diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 252ed85b..a7047e65 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/binary" "encoding/hex" + "sync" "testing" "github.com/Alia5/VIIPER/usbip" @@ -648,6 +649,47 @@ func TestDualSenseUSBInputReportNeutralizesInvalidControlBits(t *testing.T) { } } +func TestDualSenseUSBInputReportSequencesAreUniqueDuringConcurrentReads(t *testing.T) { + dev, err := New(nil) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + const reportCount = 64 + start := make(chan struct{}) + reports := make(chan []byte, reportCount) + var wg sync.WaitGroup + for i := 0; i < reportCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + reports <- dev.buildUSBInputReport(NewInputState(), &MetaState{}) + }() + } + + close(start) + wg.Wait() + close(reports) + + sequences := make(map[byte]bool, reportCount) + for report := range reports { + sequence := report[7] + if sequences[sequence] { + t.Fatalf("duplicate USB input report sequence %d", sequence) + } + sequences[sequence] = true + } + for sequence := 1; sequence <= reportCount; sequence++ { + if !sequences[byte(sequence)] { + t.Fatalf("missing USB input report sequence %d", sequence) + } + } + if dev.usbInputReportCount != reportCount { + t.Fatalf("unexpected report count: got %d want %d", dev.usbInputReportCount, reportCount) + } +} + func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { out := OutputState{ RumbleSmall: 0x11, diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index d9270538..4c2fe303 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -657,6 +657,23 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var respMu sync.Mutex lastInResp := map[uint32][]byte{} + var nonIsoInOrderMu sync.Mutex + nonIsoInOrderCond := sync.NewCond(&nonIsoInOrderMu) + nextNonIsoInTicket := map[uint32]uint64{} + activeNonIsoInTicket := map[uint32]uint64{} + waitForNonIsoInTurn := func(ep uint32, ticket uint64) { + nonIsoInOrderMu.Lock() + for ticket != activeNonIsoInTicket[ep] { + nonIsoInOrderCond.Wait() + } + nonIsoInOrderMu.Unlock() + } + completeNonIsoInTurn := func(ep uint32) { + nonIsoInOrderMu.Lock() + activeNonIsoInTicket[ep]++ + nonIsoInOrderCond.Broadcast() + nonIsoInOrderMu.Unlock() + } var outPayloadScratch []byte var nextIsoInCompletion time.Time @@ -850,9 +867,23 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { pending[seq] = urbCancel pendingMu.Unlock() interval := endpointInterval(dev.GetDescriptor(), ep) + var nonIsoInTicket uint64 + if !isIso { + nonIsoInOrderMu.Lock() + nonIsoInTicket = nextNonIsoInTicket[ep] + nextNonIsoInTicket[ep]++ + nonIsoInOrderMu.Unlock() + } - go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool) { + go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool, ticket uint64) { defer urbCancel() + if !iso { + // Windows can queue several interrupt-IN URBs at once. Keep + // their device reads and completions in submission order so + // HID report sequence numbers cannot arrive out of order. + waitForNonIsoInTurn(ep, ticket) + defer completeNonIsoInTurn(ep) + } var respData []byte var completedPackets []usbip.IsoPacketDescriptor if iso && len(submitted) > 0 { @@ -932,7 +963,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { s.logger.Error("write async RET_SUBMIT", "seq", seq, "error", err) } } - }(seq, ep, dir, isoPackets, isIso) + }(seq, ep, dir, isoPackets, isIso, nonIsoInTicket) continue } From a47d816d06b0e49f72546c33e5148adbdae87e98 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Fri, 17 Jul 2026 23:28:33 -0500 Subject: [PATCH 288/298] Revert "Preserve DualSense HID input ordering" This reverts commit 501b059b316233c773d42c823f883fc825dc296d. --- device/dualsense/device.go | 6 +--- device/dualsense/device_output_test.go | 42 -------------------------- internal/server/usb/server.go | 35 ++------------------- 3 files changed, 3 insertions(+), 80 deletions(-) diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 086bde15..6e393333 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -52,8 +52,7 @@ type DualSense struct { hapticsPCMStartedAt time.Time timestampBase time.Time - mtx sync.Mutex - inputReportMu sync.Mutex + mtx sync.Mutex } func New(o *device.CreateOptions) (*DualSense, error) { @@ -835,9 +834,6 @@ func (d *DualSense) featureReportCommandResponse() []byte { } func (d *DualSense) buildUSBInputReport(s *InputState, m *MetaState) []byte { - d.inputReportMu.Lock() - defer d.inputReportMu.Unlock() - b := make([]byte, InputReportSize) d.usbInputReportCount++ reportCount := d.usbInputReportCount diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index a7047e65..252ed85b 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/binary" "encoding/hex" - "sync" "testing" "github.com/Alia5/VIIPER/usbip" @@ -649,47 +648,6 @@ func TestDualSenseUSBInputReportNeutralizesInvalidControlBits(t *testing.T) { } } -func TestDualSenseUSBInputReportSequencesAreUniqueDuringConcurrentReads(t *testing.T) { - dev, err := New(nil) - if err != nil { - t.Fatalf("New returned error: %v", err) - } - - const reportCount = 64 - start := make(chan struct{}) - reports := make(chan []byte, reportCount) - var wg sync.WaitGroup - for i := 0; i < reportCount; i++ { - wg.Add(1) - go func() { - defer wg.Done() - <-start - reports <- dev.buildUSBInputReport(NewInputState(), &MetaState{}) - }() - } - - close(start) - wg.Wait() - close(reports) - - sequences := make(map[byte]bool, reportCount) - for report := range reports { - sequence := report[7] - if sequences[sequence] { - t.Fatalf("duplicate USB input report sequence %d", sequence) - } - sequences[sequence] = true - } - for sequence := 1; sequence <= reportCount; sequence++ { - if !sequences[byte(sequence)] { - t.Fatalf("missing USB input report sequence %d", sequence) - } - } - if dev.usbInputReportCount != reportCount { - t.Fatalf("unexpected report count: got %d want %d", dev.usbInputReportCount, reportCount) - } -} - func TestDualSenseExtendedFeedbackUsesNativeTriggerBlockSize(t *testing.T) { out := OutputState{ RumbleSmall: 0x11, diff --git a/internal/server/usb/server.go b/internal/server/usb/server.go index 4c2fe303..d9270538 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -657,23 +657,6 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { var respMu sync.Mutex lastInResp := map[uint32][]byte{} - var nonIsoInOrderMu sync.Mutex - nonIsoInOrderCond := sync.NewCond(&nonIsoInOrderMu) - nextNonIsoInTicket := map[uint32]uint64{} - activeNonIsoInTicket := map[uint32]uint64{} - waitForNonIsoInTurn := func(ep uint32, ticket uint64) { - nonIsoInOrderMu.Lock() - for ticket != activeNonIsoInTicket[ep] { - nonIsoInOrderCond.Wait() - } - nonIsoInOrderMu.Unlock() - } - completeNonIsoInTurn := func(ep uint32) { - nonIsoInOrderMu.Lock() - activeNonIsoInTicket[ep]++ - nonIsoInOrderCond.Broadcast() - nonIsoInOrderMu.Unlock() - } var outPayloadScratch []byte var nextIsoInCompletion time.Time @@ -867,23 +850,9 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { pending[seq] = urbCancel pendingMu.Unlock() interval := endpointInterval(dev.GetDescriptor(), ep) - var nonIsoInTicket uint64 - if !isIso { - nonIsoInOrderMu.Lock() - nonIsoInTicket = nextNonIsoInTicket[ep] - nextNonIsoInTicket[ep]++ - nonIsoInOrderMu.Unlock() - } - go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool, ticket uint64) { + go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool) { defer urbCancel() - if !iso { - // Windows can queue several interrupt-IN URBs at once. Keep - // their device reads and completions in submission order so - // HID report sequence numbers cannot arrive out of order. - waitForNonIsoInTurn(ep, ticket) - defer completeNonIsoInTurn(ep) - } var respData []byte var completedPackets []usbip.IsoPacketDescriptor if iso && len(submitted) > 0 { @@ -963,7 +932,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { s.logger.Error("write async RET_SUBMIT", "seq", seq, "error", err) } } - }(seq, ep, dir, isoPackets, isIso, nonIsoInTicket) + }(seq, ep, dir, isoPackets, isIso) continue } From 836de13bd6761c840c5b159dc6aa85968c82996a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 18 Jul 2026 00:59:45 -0500 Subject: [PATCH 289/298] Add DualShock 4 virtual audio input --- README.md | 16 ++-- device/dualshock4/audio_test.go | 142 +++++++++++++++++++++++++++++ device/dualshock4/const.go | 43 ++++++++- device/dualshock4/descriptor.go | 81 +++++++++++++++- device/dualshock4/device.go | 157 +++++++++++++++++++++++++++++++- device/dualshock4/handler.go | 125 ++++++++++++++++++++++++- docs/devices/dualshock4.md | 8 +- 7 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 device/dualshock4/audio_test.go diff --git a/README.md b/README.md index 2293854e..72136e0b 100644 --- a/README.md +++ b/README.md @@ -113,16 +113,20 @@ With the matching DS4Windows bridge: ### Microphone input -The microphone-capable DualSense device types expose a virtual Windows recording -endpoint. The framed feeder protocol accepts PCM microphone frames separately -from controller input state, and the USBIP ISO-IN path supplies them to 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. In the DS4Windows integration, microphone audio follows this path: -1. The physical Bluetooth DualSense supplies its encoded microphone frames. +1. The physical Bluetooth DualSense or DualShock 4 supplies its encoded + microphone frames. 2. DS4Windows decodes and conditions the signal. -3. DS4Windows sends framed PCM to VIIPER. -4. VIIPER presents that PCM through the virtual DualSense recording endpoint. +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, diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go new file mode 100644 index 00000000..823f4b67 --- /dev/null +++ b/device/dualshock4/audio_test.go @@ -0,0 +1,142 @@ +package dualshock4 + +import ( + "context" + "encoding/binary" + "io" + "log/slog" + "net" + "testing" + + "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 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 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) + _, err = client.Write(makeDualShock4StreamFrame(StreamFrameMicrophonePCM, 1, 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 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 index 8679cde2..24ea6afd 100644 --- a/device/dualshock4/const.go +++ b/device/dualshock4/const.go @@ -18,8 +18,47 @@ const ( var DefaultBuildTime = time.Date(2021, time.September, 17, 11, 34, 0, 0, time.UTC) const ( - EndpointIn = 0x84 - EndpointOut = 0x03 + 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 + StreamFrameInputState = 0x01 + StreamFrameMicrophonePCM = 0x02 ) const ( diff --git a/device/dualshock4/descriptor.go b/device/dualshock4/descriptor.go index be2f3b36..a3226714 100644 --- a/device/dualshock4/descriptor.go +++ b/device/dualshock4/descriptor.go @@ -21,10 +21,85 @@ var defaultDescriptor = usb.Descriptor{ BNumConfigurations: 0x01, Speed: 2, }, + Configuration: usb.ConfigurationDescriptor{ + BConfigurationValue: 0x01, + BMAttributes: 0xC0, + BMaxPower: 0xFA, + }, Interfaces: []usb.InterfaceConfig{ { Descriptor: usb.InterfaceDescriptor{ - BInterfaceNumber: 0x00, + 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, @@ -355,13 +430,13 @@ var defaultDescriptor = usb.Descriptor{ BEndpointAddress: EndpointIn, BMAttributes: 0x03, WMaxPacketSize: 64, - BInterval: 4, + BInterval: 5, }, { BEndpointAddress: EndpointOut, BMAttributes: 0x03, WMaxPacketSize: 64, - BInterval: 4, + BInterval: 5, }, }, }, diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 2cd65359..991846c9 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -32,6 +32,13 @@ type DualShock4 struct { usbPacketCounter uint32 timestampBase time.Time + speakerInterfaceActive bool + microphoneInterfaceActive bool + microphoneInput bool + streamFrameVersion byte + microphonePCM []byte + microphoneSignal chan struct{} + mtx sync.Mutex } @@ -71,8 +78,9 @@ func New(o *device.CreateOptions) (*DualShock4, error) { } d := &DualShock4{ - descriptor: defaultDescriptor, - metaState: metaState, + descriptor: defaultDescriptor, + metaState: metaState, + microphoneSignal: make(chan struct{}, 1), } if o != nil { if o.IDVendor != nil { @@ -150,12 +158,31 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { if err != nil { return map[string]any{} } + res["speakerInterfaceActive"] = d.speakerInterfaceActive + res["microphoneInterfaceActive"] = d.microphoneInterfaceActive + res["queuedMicrophoneBytes"] = len(d.microphonePCM) return res } +func (d *DualShock4) SetInterfaceAltSetting(iface, alt uint8) { + d.mtx.Lock() + defer d.mtx.Unlock() + + switch iface { + case InterfaceSpeaker: + d.speakerInterfaceActive = alt != 0 + case InterfaceMicrophone: + d.microphoneInterfaceActive = alt != 0 + if !d.microphoneInterfaceActive { + d.microphonePCM = nil + } + } +} + func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out []byte) []byte { + epNumber := ep & 0x0F if dir == usbip.DirIn { - switch ep { + switch epNumber { case 4: select { case <-ctx.Done(): @@ -173,12 +200,14 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, d.mtx.Unlock() return d.buildUSBInputReport(is, &ms) } + case EndpointMicrophoneIn & 0x0F: + return d.handleMicrophoneIn(ctx) default: return nil } } - if dir == usbip.DirOut && ep == 3 { + if dir == usbip.DirOut && epNumber == EndpointOut&0x0F { if len(out) >= 11 && out[0] == ReportIDOutput { feedback := parseOutputReport(out) d.mtx.Lock() @@ -191,11 +220,75 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, } } } + if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { + // DS4Windows captures this render endpoint with WASAPI loopback and + // forwards it to the connected physical controller. VIIPER still has + // to consume and pace the USB/IP isochronous transfer so Windows keeps + // the endpoint active, but it must not duplicate the audio here. + 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 + } + + const maximumBufferedBytes = USBMicrophoneClientFrameSize * 4 + if overflow := len(d.microphonePCM) + len(frame) - maximumBufferedBytes; overflow > 0 { + copy(d.microphonePCM, d.microphonePCM[overflow:]) + d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-overflow] + } + d.microphonePCM = append(d.microphonePCM, frame...) + d.mtx.Unlock() + + select { + case d.microphoneSignal <- struct{}{}: + default: + } +} + +func (d *DualShock4) handleMicrophoneIn(ctx context.Context) []byte { + for { + d.mtx.Lock() + if !d.microphoneInterfaceActive { + d.mtx.Unlock() + return make([]byte, USBMicrophonePacketSize) + } + + if len(d.microphonePCM) > 0 { + packet := make([]byte, USBMicrophonePacketSize) + n := copy(packet, d.microphonePCM) + copy(d.microphonePCM, d.microphonePCM[n:]) + d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-n] + d.mtx.Unlock() + return packet + } + d.mtx.Unlock() + + select { + case <-ctx.Done(): + return make([]byte, USBMicrophonePacketSize) + case <-d.microphoneSignal: + case <-time.After(time.Millisecond): + return make([]byte, USBMicrophonePacketSize) + } + } +} + 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) @@ -274,6 +367,62 @@ func (d *DualShock4) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex 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, diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index e75cc5e1..3f9e4f2c 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -1,8 +1,10 @@ package dualshock4 import ( + "encoding/binary" "encoding/json" "fmt" + "hash/crc32" "io" "log/slog" "net" @@ -14,9 +16,15 @@ import ( func init() { api.RegisterDevice("dualshock4", &handler{}) + api.RegisterDevice("dualshock4micv2", &handler{ + microphoneInput: true, streamFrameVersion: StreamFrameVersionV2, + }) } -type handler struct{} +type handler struct { + microphoneInput bool + streamFrameVersion byte +} var serials = map[string]struct{}{} @@ -52,7 +60,13 @@ func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return nil, fmt.Errorf("marshal meta state: %w", err) } o.DeviceSpecific = string(b) - return New(o) + ds4, err := New(o) + if err != nil { + return nil, err + } + ds4.microphoneInput = h.microphoneInput + ds4.streamFrameVersion = h.streamFrameVersion + return ds4, nil } func (h *handler) StreamHandler() api.StreamHandlerFunc { @@ -85,7 +99,23 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } }) - buf := make([]byte, 31) + microphoneInput := h.microphoneInput || ds4.microphoneInput + streamFrameVersion := h.streamFrameVersion + if ds4.streamFrameVersion != 0 { + streamFrameVersion = ds4.streamFrameVersion + } + logger.Info("DualShock 4 input stream configured", + "microphoneInput", microphoneInput, + "frameVersion", streamFrameVersion) + return readDualShock4InputStream(conn, ds4, logger, + microphoneInput, streamFrameVersion) + } +} + +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 { @@ -102,6 +132,95 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { ds4.UpdateInputState(&state) } } + + if frameVersion != StreamFrameVersionV2 { + 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 { diff --git a/docs/devices/dualshock4.md b/docs/devices/dualshock4.md index f064d9de..512bea7e 100644 --- a/docs/devices/dualshock4.md +++ b/docs/devices/dualshock4.md @@ -3,12 +3,18 @@ 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), and touchpad finger coordinates. +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. From e5e8b98940c317c2282d1fb32eeb909426d6c94b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sat, 18 Jul 2026 19:19:18 -0500 Subject: [PATCH 290/298] Build DS4 audio development snapshots --- .github/workflows/snapshots.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index d85ba9ab..915825b2 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -2,7 +2,7 @@ name: Dev Snapshot Build on: push: - branches: ["main", "microphone-rebuild"] + branches: ["main", "microphone-rebuild", "ds4-audio-emulation"] permissions: contents: write From 1069a5e0534dde2dec7ff62694e22512825bffb2 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Sun, 19 Jul 2026 01:01:53 -0500 Subject: [PATCH 291/298] Add low-latency DS4 audio duplex transport --- device/dualshock4/audio_test.go | 50 ++++++++++ device/dualshock4/const.go | 3 + device/dualshock4/device.go | 22 +++- device/dualshock4/handler.go | 171 +++++++++++++++++++++++++++++--- 4 files changed, 230 insertions(+), 16 deletions(-) diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index 823f4b67..e6cc4d6a 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -92,6 +92,56 @@ func TestAudioInterfacesTrackAlternateSettings(t *testing.T) { 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 TestFramedStreamQueuesDS4MicrophonePCM(t *testing.T) { dev, err := New(nil) require.NoError(t, err) diff --git a/device/dualshock4/const.go b/device/dualshock4/const.go index 24ea6afd..ee76a018 100644 --- a/device/dualshock4/const.go +++ b/device/dualshock4/const.go @@ -57,8 +57,11 @@ const ( StreamFrameMagic2 = 0x43 StreamFrameMagic3 = 0x4D StreamFrameVersionV2 = 0x02 + StreamFrameVersionV3 = 0x03 StreamFrameInputState = 0x01 StreamFrameMicrophonePCM = 0x02 + StreamFrameOutputState = 0x81 + StreamFrameSpeakerPCM = 0x82 ) const ( diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 991846c9..04f208f2 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -22,6 +22,7 @@ type DualShock4 struct { metaState *MetaState outputFunc func(OutputState) + speakerFunc func([]byte) outputState OutputState outputSeen bool descriptor usb.Descriptor @@ -35,6 +36,7 @@ type DualShock4 struct { speakerInterfaceActive bool microphoneInterfaceActive bool microphoneInput bool + speakerOutput bool streamFrameVersion byte microphonePCM []byte microphoneSignal chan struct{} @@ -130,6 +132,12 @@ func (d *DualShock4) SetOutputCallback(f func(OutputState)) { } } +func (d *DualShock4) SetSpeakerCallback(f func([]byte)) { + d.mtx.Lock() + d.speakerFunc = f + d.mtx.Unlock() +} + func (d *DualShock4) UpdateInputState(state *InputState) { d.mtx.Lock() d.inputState = state @@ -221,10 +229,16 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, } } if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { - // DS4Windows captures this render endpoint with WASAPI loopback and - // forwards it to the connected physical controller. VIIPER still has - // to consume and pace the USB/IP isochronous transfer so Windows keeps - // the endpoint active, but it must not duplicate the audio here. + d.mtx.Lock() + speakerActive := d.speakerInterfaceActive + speakerFunc := d.speakerFunc + d.mtx.Unlock() + if speakerActive && speakerFunc != nil && len(out) > 0 { + // The USB/IP receive buffer is owned by the transfer handler. Give the + // device-stream writer an immutable copy so it can forward without + // holding up realtime isochronous completion. + speakerFunc(append([]byte(nil), out...)) + } return nil } diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 3f9e4f2c..5c5b3e3f 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -8,6 +8,8 @@ import ( "io" "log/slog" "net" + "sync" + "time" "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/internal/server/api" @@ -19,10 +21,15 @@ func init() { api.RegisterDevice("dualshock4micv2", &handler{ microphoneInput: true, streamFrameVersion: StreamFrameVersionV2, }) + api.RegisterDevice("dualshock4audioduplexv3", &handler{ + microphoneInput: true, speakerOutput: true, + streamFrameVersion: StreamFrameVersionV3, + }) } type handler struct { microphoneInput bool + speakerOutput bool streamFrameVersion byte } @@ -65,6 +72,7 @@ func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { return nil, err } ds4.microphoneInput = h.microphoneInput + ds4.speakerOutput = h.speakerOutput ds4.streamFrameVersion = h.streamFrameVersion return ds4, nil } @@ -88,30 +96,168 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { return fmt.Errorf("%w: expected DualShock4", device.ErrWrongDeviceType) } - 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) - } - }) - 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.EnqueueAudio(StreamFrameSpeakerPCM, pcm) + }) + go writer.Run() + defer func() { + ds4.SetOutputCallback(nil) + ds4.SetSpeakerCallback(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 +} + +// 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 + sequence uint32 +} + +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) { + w.enqueue(w.control, frameType, payload) +} + +func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { + w.enqueue(w.audio, frameType, payload) +} + +func (w *dualShock4OutputWriter) enqueue(queue chan dualShock4OutputFrame, + frameType byte, payload []byte) { + frame := dualShock4OutputFrame{frameType: frameType, + payload: append([]byte(nil), payload...)} + select { + case <-w.stop: + return + case queue <- frame: + 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. + } +} + +func (w *dualShock4OutputWriter) Run() { + defer close(w.done) + for { + // Give feedback priority without starving speaker packets. + select { + case frame := <-w.control: + if !w.write(frame) { + return + } + continue + default: + } + + select { + case <-w.stop: + return + case frame := <-w.control: + if !w.write(frame) { + return + } + case frame := <-w.audio: + if !w.write(frame) { + return + } + } + } +} + +func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { + if len(frame.payload) > int(^uint16(0)) { + return true + } + header := make([]byte, 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)) + packet := append(header, frame.payload...) + if _, err := w.conn.Write(packet); err != nil { + _ = w.conn.Close() + return false + } + return true +} + +func (w *dualShock4OutputWriter) Stop() { + w.stopOnce.Do(func() { close(w.stop) }) + _ = w.conn.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) + select { + case <-w.done: + case <-time.After(300 * time.Millisecond): + } +} + func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, logger *slog.Logger, microphoneInput bool, frameVersion byte) error { if !microphoneInput { @@ -133,7 +279,8 @@ func readDualShock4InputStream(conn net.Conn, ds4 *DualShock4, } } - if frameVersion != StreamFrameVersionV2 { + if frameVersion != StreamFrameVersionV2 && + frameVersion != StreamFrameVersionV3 { return fmt.Errorf("unsupported DualShock 4 framed stream version 0x%02X", frameVersion) } From 1bb94d1ffbf1d7bab6529f70ba174efe46f526a7 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 20 Jul 2026 16:20:41 -0500 Subject: [PATCH 292/298] Harden Sony virtual audio duplex transport --- device/dualsense/audio_control.go | 335 ++++++++++ device/dualsense/audio_control_test.go | 402 ++++++++++++ device/dualsense/const.go | 6 + device/dualsense/descriptor.go | 2 +- device/dualsense/device.go | 286 ++++++--- device/dualsense/device_output_test.go | 6 +- device/dualsense/ds_handler.go | 81 ++- device/dualsense/ds_handler_test.go | 146 ++++- device/dualsense/dsedge_handler.go | 68 +- device/dualsense/output_writer.go | 506 +++++++++++++++ device/dualsense/output_writer_test.go | 601 ++++++++++++++++++ device/dualshock4/audio_test.go | 319 +++++++++- device/dualshock4/device.go | 171 ++++- device/dualshock4/handler.go | 227 ++++++- device/internal/microphonebuffer/buffer.go | 406 ++++++++++++ .../internal/microphonebuffer/buffer_test.go | 497 +++++++++++++++ docs/devices/dualsense.md | 36 ++ internal/server/api/autoattach_windows.go | 38 +- .../server/api/device_stream_ownership.go | 263 ++++++++ .../api/device_stream_ownership_test.go | 316 +++++++++ internal/server/api/handler/bus_device_add.go | 20 +- internal/server/api/server.go | 121 +++- internal/server/api/stream_reconnect_test.go | 231 +++++++ internal/server/usb/descriptor_test.go | 194 +++++- internal/server/usb/iso_test.go | 131 +++- internal/server/usb/server.go | 281 ++++++-- usb/device.go | 7 + 27 files changed, 5388 insertions(+), 309 deletions(-) create mode 100644 device/dualsense/audio_control.go create mode 100644 device/dualsense/audio_control_test.go create mode 100644 device/dualsense/output_writer.go create mode 100644 device/dualsense/output_writer_test.go create mode 100644 device/internal/microphonebuffer/buffer.go create mode 100644 device/internal/microphonebuffer/buffer_test.go create mode 100644 internal/server/api/device_stream_ownership.go create mode 100644 internal/server/api/device_stream_ownership_test.go create mode 100644 internal/server/api/stream_reconnect_test.go diff --git a/device/dualsense/audio_control.go b/device/dualsense/audio_control.go new file mode 100644 index 00000000..ab765469 --- /dev/null +++ b/device/dualsense/audio_control.go @@ -0,0 +1,335 @@ +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 audioFeatureState struct { + mute bool + volume int16 + minimum int16 + maximum int16 + resolution int16 + defaultVolume int16 + gain audioGainRamp +} + +type audioGainRamp struct { + current float64 + target float64 + framesRemaining int +} + +func newAudioFeatureState(minimum, maximum, resolution, defaultVolume int16) audioFeatureState { + return audioFeatureState{ + volume: defaultVolume, + minimum: minimum, + maximum: maximum, + resolution: resolution, + defaultVolume: defaultVolume, + gain: audioGainRamp{ + current: 1.0, + target: 1.0, + }, + } +} + +func newSpeakerAudioFeatureState() audioFeatureState { + return newAudioFeatureState( + audioSpeakerVolumeMinimum, + audioSpeakerVolumeMaximum, + audioSpeakerVolumeResolution, + audioSpeakerVolumeDefault, + ) +} + +func newMicrophoneAudioFeatureState() audioFeatureState { + return newAudioFeatureState( + audioMicrophoneVolumeMinimum, + audioMicrophoneVolumeMaximum, + audioMicrophoneVolumeResolution, + audioMicrophoneVolumeDefault, + ) +} + +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 + 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. In particular, +// the microphone advertises the DualSense-style 0..+48 dB hardware range while +// its +48 dB default remains unity for the already calibrated decoded PCM path. +func (s *audioFeatureState) targetGain() float64 { + if s.mute { + return 0 + } + 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 + } + + dst := acquireAudioGainBuffer(len(src)) + copy(dst, src) + s.applyPCMInPlace(dst, channels) + + return dst, func() { releaseAudioGainBuffer(dst) } +} + +// 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) []byte { + if pooled := audioGainBufferPool.Get(); pooled != nil { + buffer := pooled.([]byte) + if cap(buffer) >= length { + return buffer[:length] + } + } + return make([]byte, length) +} + +func releaseAudioGainBuffer(buffer []byte) { + if buffer != nil { + audioGainBufferPool.Put(buffer[:0]) + } +} + +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..96597669 --- /dev/null +++ b/device/dualsense/audio_control_test.go @@ -0,0 +1,402 @@ +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 { + t.Fatal("microphone volume change 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 <= micLast || micFirst >= 10000 || micLast < 35 || micLast > 45 { + t.Fatalf("microphone relative gain 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/const.go b/device/dualsense/const.go index 5d045ee9..417f82f9 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -36,6 +36,7 @@ const ( ) const ( + InterfaceAudioControl = 0x00 InterfaceHapticsAudio = 0x01 InterfaceMicrophone = 0x02 ) @@ -58,14 +59,19 @@ const ( StreamFrameMagic3 = 0x4D StreamFrameVersion = 0x01 StreamFrameVersionV2 = 0x02 + StreamFrameVersionV3 = 0x03 StreamFrameInputState = 0x01 StreamFrameMicrophonePCM = 0x02 + StreamFrameOutputState = 0x81 + StreamFrameSpeakerPCM = 0x82 USBMicrophoneSampleRate = 48000 USBMicrophoneChannels = 2 USBMicrophoneBytesPerSample = 2 USBMicrophonePacketFrames = USBMicrophoneSampleRate / 1000 USBMicrophonePacketSize = USBMicrophonePacketFrames * USBMicrophoneChannels * USBMicrophoneBytesPerSample + USBMicrophoneMaxPacketSize = USBMicrophonePacketSize + + USBMicrophoneChannels*USBMicrophoneBytesPerSample USBMicrophoneClientFrameFrames = 480 USBMicrophoneClientFrameSize = USBMicrophoneClientFrameFrames * USBMicrophoneChannels * USBMicrophoneBytesPerSample diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 8421771e..775d14e1 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -70,7 +70,7 @@ var defaultDescriptor = usb.Descriptor{ {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: 0x00C4, BInterval: 4, Trailing: usb.Data{0x00, 0x00}, ClassDescriptors: []usb.ClassSpecificDescriptor{{DescriptorType: 0x25, Payload: usb.Data{0x01, 0x00, 0x00, 0x00, 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}, diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 6e393333..967e1f87 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -15,10 +15,16 @@ import ( "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 { @@ -26,12 +32,15 @@ type DualSense struct { inputState InputState metaState *MetaState + speakerFunc func([]byte) + speakerResetFunc func() outputFunc func(OutputState) outputState OutputState descriptor usb.Descriptor extendedFeedback bool combinedBluetoothFeedback bool microphoneInput bool + speakerOutput bool streamFrameVersion byte subcommand [2]byte @@ -40,8 +49,11 @@ type DualSense struct { hapticsSeq uint8 hapticsInterval uint8 hapticsPCM []byte - microphonePCM []byte + microphoneBuffer microphonebuffer.Buffer microphoneSignal chan struct{} + speakerAudioFeature audioFeatureState + microphoneAudioFeature audioFeatureState + speakerStreamTelemetry *dualSenseSpeakerStreamTelemetry speakerInterfaceActive bool microphoneInterfaceActive bool corruptUSBInputReports int @@ -109,8 +121,17 @@ func new(o *device.CreateOptions, edge bool) (*DualSense, error) { } d := &DualSense{ - descriptor: makeDescriptor(edge), - metaState: metaState, + descriptor: makeDescriptor(edge), + metaState: metaState, + speakerAudioFeature: newSpeakerAudioFeatureState(), + microphoneAudioFeature: newMicrophoneAudioFeatureState(), + microphoneBuffer: microphonebuffer.New( + USBMicrophonePacketSize, + USBMicrophoneChannels*USBMicrophoneBytesPerSample, + USBMicrophoneClientFrameSize, + microphoneTargetClientFrames, + microphoneMaximumClientFrames, + ), microphoneSignal: make(chan struct{}, 1), } @@ -144,7 +165,38 @@ func (d *DualSense) SetMetaState(meta MetaState) { } 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() +} + +// 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) { @@ -185,26 +237,102 @@ func (d *DualSense) GetDeviceSpecificArgs() map[string]any { 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 - res["queuedMicrophoneBytes"] = len(d.microphonePCM) + 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() - defer d.mtx.Unlock() - + var resetSpeaker func() switch iface { case InterfaceHapticsAudio: d.speakerInterfaceActive = alt != 0 + d.resetSpeakerAudioLocked() + resetSpeaker = d.speakerResetFunc case InterfaceMicrophone: d.microphoneInterfaceActive = alt != 0 - if !d.microphoneInterfaceActive { - d.microphonePCM = nil - } + 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. @@ -262,12 +390,7 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { return } - const maximumBufferedBytes = USBMicrophoneClientFrameSize * 4 - if overflow := len(d.microphonePCM) + len(frame) - maximumBufferedBytes; overflow > 0 { - copy(d.microphonePCM, d.microphonePCM[overflow:]) - d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-overflow] - } - d.microphonePCM = append(d.microphonePCM, frame...) + d.microphoneBuffer.QueueFrame(frame) d.mtx.Unlock() recordTrafficSummary("client->device", "microphone-pcm-queued", len(frame), @@ -279,30 +402,56 @@ func (d *DualSense) QueueMicrophonePCMFrame(frame []byte) { } } +// 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 make([]byte, USBMicrophonePacketSize) + return packet[:USBMicrophonePacketSize] } - if len(d.microphonePCM) > 0 { - packet := make([]byte, USBMicrophonePacketSize) - n := copy(packet, d.microphonePCM) - copy(d.microphonePCM, d.microphonePCM[n:]) - d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-n] + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { + d.microphoneAudioFeature.applyPCMInPlace( + packet[:actualLength], USBMicrophoneChannels, + ) d.mtx.Unlock() - return packet + return packet[:actualLength] } d.mtx.Unlock() select { case <-ctx.Done(): - return make([]byte, USBMicrophonePacketSize) + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] case <-d.microphoneSignal: case <-time.After(time.Millisecond): - return make([]byte, USBMicrophonePacketSize) + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + } +} + +func (d *DualSense) drainMicrophoneSignal() { + for { + select { + case <-d.microphoneSignal: + default: + return } } } @@ -318,12 +467,29 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { "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 if len(d.hapticsPCM) == 0 { d.hapticsPCMStartedAt = receivedAt } - d.hapticsPCM = append(d.hapticsPCM, out...) + 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 { + speakerFunc(processed) + } d.mtx.Unlock() + if release != nil { + release() + } for _, pending := range reports { report := pending.data @@ -344,8 +510,9 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { EndpointHapticsAudioOut, BluetoothHapticsSampleSize, float64(pending.assemblyDelay.Microseconds())/1000.0)) - if d.outputFunc != nil { - d.mtx.Lock() + d.mtx.Lock() + outputFunc := d.outputFunc + if outputFunc != nil { feedback := d.outputState if d.combinedBluetoothFeedback { copy(feedback.BluetoothCombinedOutputReport[:], report) @@ -354,7 +521,7 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { } d.mtx.Unlock() dispatchStarted := time.Now() - d.outputFunc(feedback) + outputFunc(feedback) recordTrafficEvent(TrafficEvent{ Direction: "device->bridge", Source: "feedback-dispatch", @@ -364,6 +531,8 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { float64(time.Since(dispatchStarted).Microseconds())/1000.0, float64(pending.assemblyDelay.Microseconds())/1000.0), }) + } else { + d.mtx.Unlock() } } } @@ -444,7 +613,9 @@ func copyUSBHapticsChannelsToBluetoothSample(dst []byte, src []byte) { } func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16, data []byte) ([]byte, bool) { - if response, handled := handleAudioControlRequest(bmRequestType, bRequest, wValue, wIndex, wLength); handled { + if response, handled := d.handleAudioControlRequest( + bmRequestType, bRequest, wValue, wIndex, wLength, data, + ); handled { return response, true } @@ -531,62 +702,15 @@ func (d *DualSense) HandleControl(bmRequestType, bRequest uint8, wValue, wIndex, return nil, false } -const ( - audioClassRequestSetCurrent = 0x01 - audioClassRequestGetCurrent = 0x81 - audioClassRequestGetMinimum = 0x82 - audioClassRequestGetMaximum = 0x83 - audioClassRequestGetResolution = 0x84 - - audioClassEndpointOut = 0x22 - audioClassEndpointIn = 0xA2 - - audioControlSamplingFrequency = 0x01 -) - -// handleAudioControlRequest implements the UAC1 endpoint sampling-frequency -// controls advertised by the AudioStreaming format descriptor. Windows -// usbaudio validates these requests before it starts the render endpoint. -func handleAudioControlRequest(bmRequestType, bRequest uint8, wValue, wIndex, wLength uint16) ([]byte, bool) { - endpoint := uint8(wIndex) - if (endpoint != EndpointHapticsAudioOut && endpoint != EndpointMicrophoneIn) || - uint8(wValue>>8) != audioControlSamplingFrequency { - return nil, false - } - - switch bmRequestType { - case audioClassEndpointIn: - switch bRequest { - case audioClassRequestGetCurrent, audioClassRequestGetMinimum, audioClassRequestGetMaximum: - response := []byte{0x80, 0xBB, 0x00} // 48,000 Hz as a 24-bit little-endian value. - if wLength < uint16(len(response)) { - response = response[:wLength] - } - return response, true - case audioClassRequestGetResolution: - response := []byte{0x00, 0x00, 0x00} // One discrete advertised frequency. - if wLength < uint16(len(response)) { - response = response[:wLength] - } - return response, true - } - case audioClassEndpointOut: - if bRequest == audioClassRequestSetCurrent { - return nil, true - } - } - - return nil, false -} - func (d *DualSense) handleOutputReport(out []byte) bool { report, ok := normalizeOutputReport(out) if !ok { return false } logRawOutputReport(report) - if d.outputFunc != nil { - d.mtx.Lock() + d.mtx.Lock() + outputFunc := d.outputFunc + if outputFunc != nil { feedback := d.mergeOutputReport(report) d.mtx.Unlock() recordTrafficBytes("host->device", "parsed-output-report", @@ -594,7 +718,9 @@ func (d *DualSense) handleOutputReport(out []byte) bool { "reportType", describeReportType(reportTypeOutput), "reportID", fmt.Sprintf("0x%02X", report[0]), "decodedOutput", describeOutputState(feedback)) - d.outputFunc(feedback) + outputFunc(feedback) + } else { + d.mtx.Unlock() } return true } diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 252ed85b..3411db9f 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -42,7 +42,9 @@ func TestMicrophoneInUsesUSBIPEndpointNumber(t *testing.T) { for i := range frame { frame[i] = byte(i) } - dev.QueueMicrophonePCMFrame(frame) + for range microphoneTargetClientFrames { + dev.QueueMicrophonePCMFrame(frame) + } packet := dev.HandleTransfer(context.Background(), uint32(EndpointMicrophoneIn&0x0F), usbip.DirIn, nil) @@ -297,6 +299,7 @@ func TestDualSenseHapticsAudioOutBuildsSAxenseReports(t *testing.T) { dev.SetOutputCallback(func(out OutputState) { got = out }) + dev.SetInterfaceAltSetting(InterfaceHapticsAudio, 1) SetTrafficDiagnosticsEnabled(true, true) defer SetTrafficDiagnosticsEnabled(rawOutputLogEnabled, true) @@ -356,6 +359,7 @@ func TestDualSenseCombinedHapticsAudioOutBuildsVDSReports(t *testing.T) { 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++ { diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 121e3259..938ec73a 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -21,12 +21,14 @@ func init() { 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}) } type dshandler struct { extendedFeedback bool combinedBluetoothFeedback bool microphoneInput bool + speakerOutput bool streamFrameVersion byte } @@ -96,6 +98,7 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { dse.extendedFeedback = h.extendedFeedback dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback dse.microphoneInput = h.microphoneInput + dse.speakerOutput = h.speakerOutput dse.streamFrameVersion = h.streamFrameVersion return dse, nil } @@ -128,7 +131,18 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) } - dse.SetOutputCallback(func(feedback OutputState) { + 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 { @@ -139,22 +153,50 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { data, err = feedback.MarshalBinary() } if err != nil { - logger.Error("failed to marshal feedback", "error", err) - return + return nil, err } - if _, err := conn.Write(data); err != nil { - logger.Error("failed to send feedback", "error", err) + return data, nil + } + + if speakerOutput { + if streamFrameVersion != StreamFrameVersionV3 { + return fmt.Errorf("DualSense speaker output requires framed stream version 0x%02X", + StreamFrameVersionV3) } - }) - microphoneInput := h.microphoneInput || dse.microphoneInput - streamFrameVersion := h.streamFrameVersion - if dse.streamFrameVersion != 0 { - streamFrameVersion = dse.streamFrameVersion + 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) + }) + dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + dse.SetSpeakerResetCallback(writer.ResetSpeaker) + defer func() { + dse.SetOutputCallback(nil) + dse.SetSpeakerCallback(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) } - logger.Info("DualSense input stream configured", - "microphoneInput", microphoneInput, - "frameVersion", streamFrameVersion) + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) } } @@ -183,8 +225,16 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog } } + if frameVersion != StreamFrameVersion && + frameVersion != StreamFrameVersionV2 && + frameVersion != StreamFrameVersionV3 { + return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", + frameVersion) + } + headerSize := StreamFrameHeaderSize - if frameVersion == StreamFrameVersionV2 { + if frameVersion == StreamFrameVersionV2 || + frameVersion == StreamFrameVersionV3 { headerSize = StreamFrameV2HeaderSize } header := make([]byte, headerSize) @@ -235,7 +285,8 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog return fmt.Errorf("read framed packet type 0x%02X: %w", frameType, err) } - if frameVersion == StreamFrameVersionV2 { + if frameVersion == StreamFrameVersionV2 || + frameVersion == StreamFrameVersionV3 { 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) diff --git a/device/dualsense/ds_handler_test.go b/device/dualsense/ds_handler_test.go index ca3b9966..094eb958 100644 --- a/device/dualsense/ds_handler_test.go +++ b/device/dualsense/ds_handler_test.go @@ -1,6 +1,7 @@ package dualsense import ( + "context" "encoding/binary" "hash/crc32" "io" @@ -9,6 +10,8 @@ import ( "slices" "strings" "testing" + + "github.com/Alia5/VIIPER/usbip" ) func makeStreamFrame(t *testing.T, frameType byte, payload []byte) []byte { @@ -36,12 +39,20 @@ func makeStreamFrameHeader(frameType byte, payloadLength int) []byte { } 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] = StreamFrameVersionV2 + frame[4] = version frame[5] = frameType binary.LittleEndian.PutUint16(frame[6:8], uint16(len(payload))) binary.LittleEndian.PutUint32(frame[8:12], sequence) @@ -84,8 +95,10 @@ func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { if _, err := client.Write(makeStreamFrame(t, StreamFrameInputState, inputPayload)); err != nil { t.Fatalf("write input frame: %v", err) } - if _, err := client.Write(makeStreamFrame(t, StreamFrameMicrophonePCM, microphonePayload)); err != nil { - t.Fatalf("write microphone 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) @@ -97,14 +110,15 @@ func TestReadDualSenseInputStreamAcceptsVersionedMicFrames(t *testing.T) { dev.mtx.Lock() gotInput := dev.inputState - gotMicrophoneLen := len(dev.microphonePCM) + 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 gotMicrophoneLen != USBMicrophoneClientFrameSize { - t.Fatalf("unexpected microphone queue length: %d", gotMicrophoneLen) + if gotMicrophoneState.QueuedBytes != USBMicrophoneClientFrameSize*microphoneTargetClientFrames || + !gotMicrophoneState.Primed { + t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState) } } @@ -142,6 +156,15 @@ func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(t 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) } @@ -152,20 +175,83 @@ func TestReadDualSenseInputStreamV2AcceptsInterleavedStateAndMicrophoneFrames(t dev.mtx.Lock() gotInput := dev.inputState - gotMicrophone := append([]byte(nil), dev.microphonePCM...) + 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 len(gotMicrophone) != len(microphonePayload) { - t.Fatalf("unexpected microphone queue length: %d", len(gotMicrophone)) + if gotMicrophoneState.QueuedBytes != len(microphonePayload)*microphoneTargetClientFrames || + !gotMicrophoneState.Primed { + t.Fatalf("unexpected microphone queue state: %+v", gotMicrophoneState) } - if !slices.Equal(gotMicrophone, microphonePayload) { + 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, @@ -244,9 +330,7 @@ func TestReadDualSenseInputStreamRejectsUnversionedMicFrames(t *testing.T) { t.Fatalf("expected invalid magic error, got %v", err) } - dev.mtx.Lock() - gotMicrophoneLen := len(dev.microphonePCM) - dev.mtx.Unlock() + gotMicrophoneLen := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int) if gotMicrophoneLen != 0 { t.Fatalf("old style frame should not queue microphone data, got %d bytes", gotMicrophoneLen) } @@ -260,46 +344,48 @@ func TestQueueMicrophonePCMFrameRequiresActiveInterface(t *testing.T) { frame := make([]byte, USBMicrophoneClientFrameSize) dev.QueueMicrophonePCMFrame(frame) - if len(dev.microphonePCM) != 0 { - t.Fatalf("inactive mic interface should drop PCM, got %d bytes", len(dev.microphonePCM)) + 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 len(dev.microphonePCM) != USBMicrophoneClientFrameSize { - t.Fatalf("active mic interface should queue PCM, got %d bytes", len(dev.microphonePCM)) + 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 len(dev.microphonePCM) != 0 { - t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", len(dev.microphonePCM)) + if queued := dev.GetDeviceSpecificArgs()["queuedMicrophoneBytes"].(int); queued != 0 { + t.Fatalf("closing mic interface should drop queued PCM, got %d bytes", queued) } } -func TestQueueMicrophonePCMFrameKeepsNewestFourFrames(t *testing.T) { +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 := byte(0); value < 5; value++ { + for value := 0; value <= microphoneMaximumClientFrames; value++ { frame := make([]byte, USBMicrophoneClientFrameSize) for i := range frame { - frame[i] = value + frame[i] = byte(value) } dev.QueueMicrophonePCMFrame(frame) } - dev.mtx.Lock() - queued := append([]byte(nil), dev.microphonePCM...) - dev.mtx.Unlock() - - if len(queued) != USBMicrophoneClientFrameSize*4 { - t.Fatalf("unexpected bounded queue length: %d", len(queued)) + 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) } - if queued[0] != 1 || queued[len(queued)-1] != 4 { - t.Fatalf("queue did not retain newest frames: first=%d last=%d", queued[0], queued[len(queued)-1]) + 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]) } } diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index f5520754..eb028010 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -18,12 +18,14 @@ func init() { 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}) } type dsedgehandler struct { extendedFeedback bool combinedBluetoothFeedback bool microphoneInput bool + speakerOutput bool streamFrameVersion byte } @@ -93,6 +95,7 @@ func (h *dsedgehandler) CreateDevice(o *device.CreateOptions) (usb.Device, error dse.extendedFeedback = h.extendedFeedback dse.combinedBluetoothFeedback = h.combinedBluetoothFeedback dse.microphoneInput = h.microphoneInput + dse.speakerOutput = h.speakerOutput dse.streamFrameVersion = h.streamFrameVersion return dse, nil } @@ -125,7 +128,18 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { return fmt.Errorf("%w: expected DualSenseEdge", device.ErrWrongDeviceType) } - dse.SetOutputCallback(func(feedback OutputState) { + 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 { @@ -136,22 +150,50 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { data, err = feedback.MarshalBinary() } if err != nil { - logger.Error("failed to marshal feedback", "error", err) - return + return nil, err } - if _, err := conn.Write(data); err != nil { - logger.Error("failed to send feedback", "error", err) + return data, nil + } + + if speakerOutput { + if streamFrameVersion != StreamFrameVersionV3 { + return fmt.Errorf("DualSense Edge speaker output requires framed stream version 0x%02X", + StreamFrameVersionV3) } - }) - microphoneInput := h.microphoneInput || dse.microphoneInput - streamFrameVersion := h.streamFrameVersion - if dse.streamFrameVersion != 0 { - streamFrameVersion = dse.streamFrameVersion + 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) + }) + dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + dse.SetSpeakerResetCallback(writer.ResetSpeaker) + defer func() { + dse.SetOutputCallback(nil) + dse.SetSpeakerCallback(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) } - logger.Info("DualSense Edge input stream configured", - "microphoneInput", microphoneInput, - "frameVersion", streamFrameVersion) + return readDualSenseInputStreamVersion(conn, dse, logger, microphoneInput, streamFrameVersion) } } diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go new file mode 100644 index 00000000..1d82f63f --- /dev/null +++ b/device/dualsense/output_writer.go @@ -0,0 +1,506 @@ +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. + dualSenseSpeakerPayloadCapacity = USBHapticsAudioMaxPacketSize * 10 / 2 + dualSenseSpeakerTraceInterval = 10 * time.Second + dualSenseSpeakerResetTimeout = 250 * time.Millisecond +) + +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 + } +} + +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 V3 speaker stream" + if final { + log = w.logger.Info + message = "DualSense V3 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..e33e1917 --- /dev/null +++ b/device/dualsense/output_writer_test.go @@ -0,0 +1,601 @@ +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 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 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/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index e6cc4d6a..0a48c24f 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -6,7 +6,9 @@ import ( "io" "log/slog" "net" + "sync" "testing" + "time" "github.com/Alia5/VIIPER/usbip" "github.com/stretchr/testify/assert" @@ -142,6 +144,266 @@ func TestDuplexWriterFramesSpeakerPCM(t *testing.T) { 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) @@ -164,8 +426,10 @@ func TestFramedStreamQueuesDS4MicrophonePCM(t *testing.T) { _, err = client.Write(makeDualShock4StreamFrame(StreamFrameInputState, 0, input)) require.NoError(t, err) - _, err = client.Write(makeDualShock4StreamFrame(StreamFrameMicrophonePCM, 1, microphone)) - 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) @@ -175,6 +439,57 @@ func TestFramedStreamQueuesDS4MicrophonePCM(t *testing.T) { 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) diff --git a/device/dualshock4/device.go b/device/dualshock4/device.go index 04f208f2..7fdcda2e 100644 --- a/device/dualshock4/device.go +++ b/device/dualshock4/device.go @@ -12,20 +12,27 @@ import ( "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) - outputState OutputState - outputSeen bool - descriptor usb.Descriptor + outputFunc func(OutputState) + speakerFunc func([]byte) + speakerResetFunc func() + outputState OutputState + outputSeen bool + descriptor usb.Descriptor probeSelector [3]byte telemetrySubcommand byte @@ -38,7 +45,7 @@ type DualShock4 struct { microphoneInput bool speakerOutput bool streamFrameVersion byte - microphonePCM []byte + microphoneBuffer microphonebuffer.Buffer microphoneSignal chan struct{} mtx sync.Mutex @@ -80,8 +87,15 @@ func New(o *device.CreateOptions) (*DualShock4, error) { } d := &DualShock4{ - descriptor: defaultDescriptor, - metaState: metaState, + descriptor: defaultDescriptor, + metaState: metaState, + microphoneBuffer: microphonebuffer.New( + USBMicrophonePacketSize, + USBMicrophoneChannels*USBMicrophoneBytesPerSample, + USBMicrophoneClientFrameSize, + microphoneTargetClientFrames, + microphoneMaximumClientFrames, + ), microphoneSignal: make(chan struct{}, 1), } if o != nil { @@ -138,6 +152,15 @@ func (d *DualShock4) SetSpeakerCallback(f func([]byte)) { 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 @@ -168,23 +191,81 @@ func (d *DualShock4) GetDeviceSpecificArgs() map[string]any { } res["speakerInterfaceActive"] = d.speakerInterfaceActive res["microphoneInterfaceActive"] = d.microphoneInterfaceActive - res["queuedMicrophoneBytes"] = len(d.microphonePCM) + 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() - defer d.mtx.Unlock() - + 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 !d.microphoneInterfaceActive { - d.microphonePCM = nil + 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 { @@ -230,15 +311,16 @@ func (d *DualShock4) HandleTransfer(ctx context.Context, ep uint32, dir uint32, } if dir == usbip.DirOut && epNumber == EndpointAudioOut&0x0F { d.mtx.Lock() - speakerActive := d.speakerInterfaceActive - speakerFunc := d.speakerFunc - d.mtx.Unlock() - if speakerActive && speakerFunc != nil && len(out) > 0 { + 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 so it can forward without - // holding up realtime isochronous completion. - speakerFunc(append([]byte(nil), out...)) + // 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 } @@ -256,12 +338,7 @@ func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { return } - const maximumBufferedBytes = USBMicrophoneClientFrameSize * 4 - if overflow := len(d.microphonePCM) + len(frame) - maximumBufferedBytes; overflow > 0 { - copy(d.microphonePCM, d.microphonePCM[overflow:]) - d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-overflow] - } - d.microphonePCM = append(d.microphonePCM, frame...) + d.microphoneBuffer.QueueFrame(frame) d.mtx.Unlock() select { @@ -270,30 +347,54 @@ func (d *DualShock4) QueueMicrophonePCMFrame(frame []byte) { } } +// 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 make([]byte, USBMicrophonePacketSize) + return packet[:USBMicrophonePacketSize] } - if len(d.microphonePCM) > 0 { - packet := make([]byte, USBMicrophonePacketSize) - n := copy(packet, d.microphonePCM) - copy(d.microphonePCM, d.microphonePCM[n:]) - d.microphonePCM = d.microphonePCM[:len(d.microphonePCM)-n] + if actualLength, ok := d.microphoneBuffer.ReadPacket(packet); ok { d.mtx.Unlock() - return packet + return packet[:actualLength] } d.mtx.Unlock() select { case <-ctx.Done(): - return make([]byte, USBMicrophonePacketSize) + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] case <-d.microphoneSignal: case <-time.After(time.Millisecond): - return make([]byte, USBMicrophonePacketSize) + d.mtx.Lock() + d.microphoneBuffer.RecordZeroPacket() + d.mtx.Unlock() + return packet[:USBMicrophonePacketSize] + } + } +} + +func (d *DualShock4) drainMicrophoneSignal() { + for { + select { + case <-d.microphoneSignal: + default: + return } } } diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 5c5b3e3f..26dff08c 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -9,6 +9,7 @@ import ( "log/slog" "net" "sync" + "sync/atomic" "time" "github.com/Alia5/VIIPER/device" @@ -119,12 +120,14 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { writer.EnqueueControl(StreamFrameOutputState, data) }) ds4.SetSpeakerCallback(func(pcm []byte) { - writer.EnqueueAudio(StreamFrameSpeakerPCM, pcm) + 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 { @@ -147,22 +150,33 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } type dualShock4OutputFrame struct { - frameType byte - payload []byte + frameType byte + payload []byte + pooled bool + audio bool + generation uint64 } +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 - sequence uint32 + 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 { @@ -175,35 +189,92 @@ func newDualShock4OutputWriter(conn net.Conn, version byte) *dualShock4OutputWri } func (w *dualShock4OutputWriter) EnqueueControl(frameType byte, payload []byte) { - w.enqueue(w.control, frameType, payload) + 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) { - w.enqueue(w.audio, frameType, payload) + if len(payload) == 0 { + return + } + w.enqueueLock.RLock() + defer w.enqueueLock.RUnlock() + if w.stopped { + return + } + var owned []byte + if value := w.audioPool.Get(); value != nil { + owned = value.([]byte) + } + if cap(owned) < len(payload) { + owned = make([]byte, len(payload)) + } else { + owned = owned[:len(payload)] + } + copy(owned, payload) + frame := dualShock4OutputFrame{ + frameType: frameType, payload: owned, pooled: true, audio: true, + generation: w.audioGeneration.Load(), + } + if !w.enqueueFrameLocked(w.audio, frame) { + w.audioPool.Put(owned[:0]) + } } -func (w *dualShock4OutputWriter) enqueue(queue chan dualShock4OutputFrame, - frameType byte, payload []byte) { - frame := dualShock4OutputFrame{frameType: frameType, - payload: append([]byte(nil), payload...)} - select { - case <-w.stop: +// 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 close(w.done) + defer func() { + w.requestStop() + w.drainAudioQueue() + close(w.done) + }() for { // Give feedback priority without starving speaker packets. select { case frame := <-w.control: - if !w.write(frame) { + if !w.writeAndRelease(frame) { return } continue @@ -214,22 +285,93 @@ func (w *dualShock4OutputWriter) Run() { case <-w.stop: return case frame := <-w.control: - if !w.write(frame) { + if !w.writeAndRelease(frame) { return } case frame := <-w.audio: - if !w.write(frame) { + 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 } - header := make([]byte, StreamFrameV2HeaderSize) + 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 @@ -241,16 +383,34 @@ func (w *dualShock4OutputWriter) write(frame dualShock4OutputFrame) bool { w.sequence++ binary.LittleEndian.PutUint32(header[12:16], dualShock4FramedStreamCRC(header[4:12], frame.payload)) - packet := append(header, frame.payload...) - if _, err := w.conn.Write(packet); err != nil { - _ = w.conn.Close() - return false + 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.pooled { + w.audioPool.Put(frame.payload[:0]) + } +} + func (w *dualShock4OutputWriter) Stop() { - w.stopOnce.Do(func() { close(w.stop) }) + w.requestStop() _ = w.conn.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) select { case <-w.done: @@ -258,6 +418,15 @@ func (w *dualShock4OutputWriter) Stop() { } } +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 { 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/docs/devices/dualsense.md b/docs/devices/dualsense.md index e20c5ff6..69f62906 100644 --- a/docs/devices/dualsense.md +++ b/docs/devices/dualsense.md @@ -91,6 +91,42 @@ IMU (gyro + accelerometer), and touchpad finger coordinates. 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 diff --git a/internal/server/api/autoattach_windows.go b/internal/server/api/autoattach_windows.go index 47a805a9..a3b37774 100644 --- a/internal/server/api/autoattach_windows.go +++ b/internal/server/api/autoattach_windows.go @@ -6,8 +6,11 @@ import ( "context" "fmt" "log/slog" + "os" "os/exec" + "path/filepath" "strconv" + "strings" "syscall" "unsafe" @@ -170,7 +173,7 @@ func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, u cmd := exec.CommandContext( ctx, - "usbip", + resolveUsbipExecutable(), "--tcp-port", strconv.FormatUint(uint64(usbipServerPort), 10), "attach", @@ -190,6 +193,39 @@ func attachViaCommand(ctx context.Context, deviceExportMeta *usbip.ExportMeta, u 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)), 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/handler/bus_device_add.go b/internal/server/api/handler/bus_device_add.go index 626c06f6..c22487fe 100644 --- a/internal/server/api/handler/bus_device_add.go +++ b/internal/server/api/handler/bus_device_add.go @@ -74,24 +74,8 @@ func BusDeviceAdd(s *usbs.Server, apiSrv *api.Server) api.HandlerFunc { return apierror.ErrInternal("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 := s.RemoveDeviceByID(uint32(busID), 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) - } - } - }() + apiSrv.ScheduleDeviceCleanup(uint32(busID), + fmt.Sprintf("%d", exportMeta.DevID), devCtx) if apiSrv.Config().AutoAttachLocalClient { err := api.AttachLocalhostClient( diff --git a/internal/server/api/server.go b/internal/server/api/server.go index 5d4206cc..eb0b1d65 100644 --- a/internal/server/api/server.go +++ b/internal/server/api/server.go @@ -12,8 +12,8 @@ import ( "regexp" "strconv" "strings" + "time" - "github.com/Alia5/VIIPER/device" "github.com/Alia5/VIIPER/internal/server/api/auth" apierror "github.com/Alia5/VIIPER/internal/server/api/error" "github.com/Alia5/VIIPER/internal/server/usb" @@ -23,14 +23,28 @@ import ( // 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 + 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 @@ -53,6 +67,24 @@ 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 { @@ -237,6 +269,11 @@ func (s *Server) handleConn(conn net.Conn) { 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")) @@ -273,47 +310,61 @@ func (s *Server) handleConn(conn net.Conn) { return } - connTimer := device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Stop() + 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 - if err := sh(conn, &dev, connLogger); err != nil { + 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) - connTimer = device.GetConnTimer(devCtx) - if connTimer != nil { - connTimer.Reset(s.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 - } - connLogger.Warn("disconnect timeout: device context closed but metadata missing") - } - }() - } - 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 { 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/internal/server/usb/descriptor_test.go b/internal/server/usb/descriptor_test.go index 1c616772..c8df3b40 100644 --- a/internal/server/usb/descriptor_test.go +++ b/internal/server/usb/descriptor_test.go @@ -4,27 +4,52 @@ 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 + desc *usbdesc.Descriptor + altEvents [][2]uint8 + transferCalls int } -func (d altSettingTestDevice) HandleTransfer(context.Context, uint32, uint32, []byte) []byte { +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 { +func (d *altSettingTestDevice) GetDescriptor() *usbdesc.Descriptor { return d.desc } -func (d altSettingTestDevice) GetDeviceSpecificArgs() map[string]any { +func (d *altSettingTestDevice) GetDeviceSpecificArgs() map[string]any { return nil } @@ -123,3 +148,162 @@ func TestProcessSubmitTracksInterfaceAlternateSetting(t *testing.T) { 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 index 549e790b..a1701042 100644 --- a/internal/server/usb/iso_test.go +++ b/internal/server/usb/iso_test.go @@ -11,17 +11,72 @@ import ( ) type isoInTestDevice struct { - desc *usbdesc.Descriptor - payloads [][]byte - calls int + 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 } @@ -79,8 +134,8 @@ func TestBuildIsoInResponseCompactsPacketPadding(t *testing.T) { {Offset: 2 * packetLength, Length: packetLength}, } - response, completed := (&Server{}).buildIsoInResponse( - context.Background(), dev, 2, usbip.DirIn, submitted) + 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)...) @@ -118,3 +173,69 @@ func TestUSBServiceIntervalUsesHighSpeedMicroframes(t *testing.T) { 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 index d9270538..a849ea5e 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -140,6 +140,7 @@ const ( // USB request types (bmRequestType) usbReqTypeStandardToDevice = 0x00 usbReqTypeStandardFromInterface = 0x01 + usbReqTypeStandardToEndpoint = 0x02 usbReqTypeStandardToInterface = 0x81 usbReqTypeStandardFromDevice = 0x80 usbReqTypeMask = 0x60 @@ -572,6 +573,11 @@ func (lc *logConn) Write(p []byte) (int, error) { 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 @@ -659,8 +665,18 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { lastInResp := map[uint32][]byte{} var outPayloadScratch []byte - var nextIsoInCompletion time.Time - var isoInCompletionMu sync.Mutex + // 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 @@ -817,10 +833,16 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } xferLen := binary.BigEndian.Uint32(hdr[urbHdrOffsetLength : urbHdrOffsetLength+4]) packetCountWire := int32(binary.BigEndian.Uint32(hdr[urbHdrOffsetPackets : urbHdrOffsetPackets+4])) - isIso := packetCountWire >= 0 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 @@ -845,34 +867,102 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } 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) - go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool) { + 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, isoServiceEnd time.Time, + isoTransferDuration, isoPacketDuration time.Duration) { defer urbCancel() var respData []byte var completedPackets []usbip.IsoPacketDescriptor if iso && len(submitted) > 0 { - isoTransferDuration := isoCompletionDelay(dev.GetDescriptor(), ep, len(submitted)) - respData, completedPackets = s.buildIsoInResponse(urbCtx, dev, ep, dir, submitted) - if isoTransferDuration > 0 { - isoInCompletionMu.Lock() - now := time.Now() - if nextIsoInCompletion.IsZero() || now.Sub(nextIsoInCompletion) > isoTransferDuration { - nextIsoInCompletion = now.Add(isoTransferDuration) - } else { - nextIsoInCompletion = nextIsoInCompletion.Add(isoTransferDuration) - } - isoCompletionDeadline := nextIsoInCompletion - isoInCompletionMu.Unlock() + 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: + } - if wait := time.Until(isoCompletionDeadline); wait > 0 { - time.Sleep(wait) - } + // 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, isoServiceEnd = 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) @@ -932,7 +1022,8 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { s.logger.Error("write async RET_SUBMIT", "seq", seq, "error", err) } } - }(seq, ep, dir, isoPackets, isIso) + }(seq, ep, dir, isoPackets, isIso, previousIsoIn, isoInDone, + isoServiceStart, isoServiceEnd, isoTransferDuration, isoPacketDuration) continue } @@ -1004,6 +1095,25 @@ func endpointInterval(desc *usb.Descriptor, ep uint32) time.Duration { 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 @@ -1060,15 +1170,19 @@ func (s *Server) buildIsoInResponse( ep uint32, dir uint32, submitted []usbip.IsoPacketDescriptor, -) ([]byte, []usbip.IsoPacketDescriptor) { + serviceStart time.Time, +) ([]byte, []usbip.IsoPacketDescriptor, time.Time) { if len(submitted) == 0 { - return nil, nil + 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) @@ -1081,7 +1195,17 @@ func (s *Server) buildIsoInResponse( // 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 } @@ -1090,7 +1214,7 @@ func (s *Server) buildIsoInResponse( packetData := s.processSubmit(attemptCtx, dev, ep, dir, nil, nil) cancel() if ctx.Err() != nil { - return nil, nil + return nil, nil, time.Time{} } if len(packetData) == 0 { packetData = make([]byte, int(packet.Length)) @@ -1102,7 +1226,53 @@ func (s *Server) buildIsoInResponse( } completed := completeIsoPacketsWithActuals(submitted, actualLengths) - return respData, completed + 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 @@ -1194,6 +1364,7 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d 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} @@ -1202,18 +1373,24 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d return nil } if breq == usbReqSetConfiguration && bm == usbReqTypeStandardToDevice { - s.clearInterfaceAlt(dev) - s.notifyInterfaceAltsCleared(dev) + 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 { - desc := dev.GetDescriptor() iface := uint8(wIndex & usbIfaceIndexMask) alt := uint8(wValue & 0xff) if descriptorHasInterfaceAlt(desc, iface, alt) { @@ -1223,8 +1400,6 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d return nil } - desc := dev.GetDescriptor() - if breq == usbReqGetDescriptor && bm == usbReqTypeStandardFromDevice { dtype := uint8(wValue >> 8) dindex := uint8(wValue & 0xff) @@ -1303,6 +1478,23 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d 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 { @@ -1315,17 +1507,9 @@ func (s *Server) processSubmit(ctx context.Context, dev usb.Device, ep uint32, d } } - if iface := int(wIndex & usbIfaceIndexMask); iface >= 0 && iface < len(desc.Interfaces) { - if desc.Interfaces[iface].Descriptor.BInterfaceClass == usbInterfaceClassHID { + if ifaceConfig, ok := desc.Interface(uint8(wIndex & usbIfaceIndexMask)); ok { + if 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 case (bm == hidReqTypeIn || bm == hidReqTypeOut) && (breq == hidReqGetReport || breq == hidReqSetReport): return nil } @@ -1426,6 +1610,20 @@ func descriptorHasInterfaceAlt(desc *usb.Descriptor, ifaceNumber, altSetting uin 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{}{} @@ -1488,3 +1686,8 @@ func (s *Server) clearInterfaceAlt(dev usb.Device) { defer s.altsMu.Unlock() delete(s.alts, dev) } + +func (s *Server) resetInterfaceAlts(dev usb.Device) { + s.clearInterfaceAlt(dev) + s.notifyInterfaceAltsCleared(dev) +} diff --git a/usb/device.go b/usb/device.go index c32f6b52..7863acf3 100644 --- a/usb/device.go +++ b/usb/device.go @@ -37,3 +37,10 @@ type ControlDevice interface { 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) +} From 63b190175488f04219860dbdb22e0aadfcf8597a Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 20 Jul 2026 16:35:03 -0500 Subject: [PATCH 293/298] Fix snapshot lint and SDK generation --- device/dualsense/audio_control.go | 31 +++++++++++------ device/dualshock4/handler.go | 35 ++++++++++++++------ internal/codegen/generator/generator.go | 33 ++++++++++++------ internal/codegen/generator/generator_test.go | 33 ++++++++++++++++++ internal/server/usb/server.go | 8 ++--- 5 files changed, 105 insertions(+), 35 deletions(-) create mode 100644 internal/codegen/generator/generator_test.go diff --git a/device/dualsense/audio_control.go b/device/dualsense/audio_control.go index ab765469..1458c07b 100644 --- a/device/dualsense/audio_control.go +++ b/device/dualsense/audio_control.go @@ -43,6 +43,10 @@ const ( var audioGainBufferPool sync.Pool +type audioGainBuffer struct { + data []byte +} + type audioFeatureState struct { mute bool volume int16 @@ -142,11 +146,12 @@ func (s *audioFeatureState) applyPCM(src []byte, channels int) ([]byte, func()) return src, nil } - dst := acquireAudioGainBuffer(len(src)) + buffer := acquireAudioGainBuffer(len(src)) + dst := buffer.data copy(dst, src) s.applyPCMInPlace(dst, channels) - return dst, func() { releaseAudioGainBuffer(dst) } + return dst, func() { releaseAudioGainBuffer(buffer) } } // applyPCMInPlace is used for freshly allocated USB capture packets. Applying @@ -184,19 +189,25 @@ func (r *audioGainRamp) next() float64 { return r.current } -func acquireAudioGainBuffer(length int) []byte { +func acquireAudioGainBuffer(length int) *audioGainBuffer { + var buffer *audioGainBuffer if pooled := audioGainBufferPool.Get(); pooled != nil { - buffer := pooled.([]byte) - if cap(buffer) >= length { - return buffer[:length] - } + buffer = pooled.(*audioGainBuffer) + } else { + buffer = &audioGainBuffer{} + } + if cap(buffer.data) < length { + buffer.data = make([]byte, length) + } else { + buffer.data = buffer.data[:length] } - return make([]byte, length) + return buffer } -func releaseAudioGainBuffer(buffer []byte) { +func releaseAudioGainBuffer(buffer *audioGainBuffer) { if buffer != nil { - audioGainBufferPool.Put(buffer[:0]) + buffer.data = buffer.data[:0] + audioGainBufferPool.Put(buffer) } } diff --git a/device/dualshock4/handler.go b/device/dualshock4/handler.go index 26dff08c..d9c3b2f5 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -150,11 +150,15 @@ func (h *handler) StreamHandler() api.StreamHandlerFunc { } type dualShock4OutputFrame struct { - frameType byte - payload []byte - pooled bool - audio bool - generation uint64 + frameType byte + payload []byte + pooledBuffer *dualShock4AudioBuffer + audio bool + generation uint64 +} + +type dualShock4AudioBuffer struct { + data []byte } const dualShock4SpeakerResetWriteTimeout = 250 * time.Millisecond @@ -212,22 +216,26 @@ func (w *dualShock4OutputWriter) EnqueueAudio(frameType byte, payload []byte) { if w.stopped { return } - var owned []byte + var buffer *dualShock4AudioBuffer if value := w.audioPool.Get(); value != nil { - owned = value.([]byte) + 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, pooled: true, audio: true, + frameType: frameType, payload: owned, pooledBuffer: buffer, audio: true, generation: w.audioGeneration.Load(), } if !w.enqueueFrameLocked(w.audio, frame) { - w.audioPool.Put(owned[:0]) + w.releaseAudioBuffer(buffer) } } @@ -404,11 +412,16 @@ func (w *dualShock4OutputWriter) failStream() { } func (w *dualShock4OutputWriter) release(frame dualShock4OutputFrame) { - if frame.pooled { - w.audioPool.Put(frame.payload[:0]) + 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)) diff --git a/internal/codegen/generator/generator.go b/internal/codegen/generator/generator.go index dc85f9f1..c7a10dc6 100644 --- a/internal/codegen/generator/generator.go +++ b/internal/codegen/generator/generator.go @@ -115,20 +115,13 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { g.logger.Debug("Discovering device packages") deviceBaseDir := "device" - entries, err := os.ReadDir(deviceBaseDir) + devicePaths, err := discoverDevicePackagePaths(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) + for _, devicePath := range devicePaths { + deviceName := filepath.Base(devicePath) g.logger.Debug("Scanning device package", "device", deviceName) deviceConsts, err := scanner.ScanDeviceConstants(devicePath) @@ -170,3 +163,23 @@ func (g *Generator) ScanAll() (*meta.Metadata, error) { 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/server/usb/server.go b/internal/server/usb/server.go index a849ea5e..deb6199c 100644 --- a/internal/server/usb/server.go +++ b/internal/server/usb/server.go @@ -913,7 +913,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } go func(seq, ep, dir uint32, submitted []usbip.IsoPacketDescriptor, iso bool, previousIsoIn <-chan time.Time, isoInDone chan time.Time, - isoServiceStart, isoServiceEnd time.Time, + isoServiceStart time.Time, isoTransferDuration, isoPacketDuration time.Duration) { defer urbCancel() var respData []byte @@ -941,10 +941,10 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { // 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, isoServiceEnd = reanchorIsoServiceWindow( + isoServiceStart, _ = reanchorIsoServiceWindow( isoServiceStart, previousServiceEnd, isoTransferDuration, isoPacketDuration, time.Now()) - respData, completedPackets, isoServiceEnd = s.buildIsoInResponse( + respData, completedPackets, isoServiceEnd := s.buildIsoInResponse( urbCtx, dev, ep, dir, submitted, isoServiceStart) if urbCtx.Err() != nil { pendingMu.Lock() @@ -1023,7 +1023,7 @@ func (s *Server) handleUrbStream(conn net.Conn, dev usb.Device) error { } } }(seq, ep, dir, isoPackets, isIso, previousIsoIn, isoInDone, - isoServiceStart, isoServiceEnd, isoTransferDuration, isoPacketDuration) + isoServiceStart, isoTransferDuration, isoPacketDuration) continue } From 188011c9edcb36f068e49be6ebf20abff3c29e10 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 20 Jul 2026 16:53:22 -0500 Subject: [PATCH 294/298] Replay Xbox feedback across stream startup --- device/xbox360/device.go | 46 +++++++++++++++++++++++++++------- device/xbox360/xbox360_test.go | 39 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/device/xbox360/device.go b/device/xbox360/device.go index 22728060..d014726f 100644 --- a/device/xbox360/device.go +++ b/device/xbox360/device.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "sync/atomic" "github.com/Alia5/VIIPER/device" @@ -13,10 +14,14 @@ import ( ) type Xbox360 struct { - tick uint64 - inputCh chan InputState - rumbleFunc func(XRumbleState) - descriptor usb.Descriptor + tick uint64 + inputCh chan InputState + rumbleDispatchMu sync.Mutex + rumbleMu sync.Mutex + rumbleFunc func(XRumbleState) + rumbleState XRumbleState + rumbleSeen bool + descriptor usb.Descriptor } type Xbox360CreateOptions struct { @@ -53,7 +58,18 @@ func New(o *device.CreateOptions) (*Xbox360, error) { // 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). @@ -88,18 +104,30 @@ func (x *Xbox360) HandleTransfer(ctx context.Context, ep uint32, dir uint32, out // [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 { - rumble := XRumbleState{ + x.emitRumble(XRumbleState{ LeftMotor: out[3], // big / low-frequency motor RightMotor: out[4], // small / high-frequency motor - } - if x.rumbleFunc != nil { - x.rumbleFunc(rumble) - } + }) } } 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{ diff --git a/device/xbox360/xbox360_test.go b/device/xbox360/xbox360_test.go index 4738fc68..82fb55b3 100644 --- a/device/xbox360/xbox360_test.go +++ b/device/xbox360/xbox360_test.go @@ -14,6 +14,7 @@ import ( "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 ) @@ -477,3 +478,41 @@ func TestRumble(t *testing.T) { } } + +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: + } +} From 4ac95b364dd527930bbca39c18a4e17676122c42 Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Mon, 20 Jul 2026 23:29:18 -0500 Subject: [PATCH 295/298] Pair DualSense speaker audio and haptics atomically --- device/dualsense/const.go | 50 +++++----- device/dualsense/device.go | 30 +++++- device/dualsense/ds_handler.go | 31 +++++-- device/dualsense/dsedge_handler.go | 22 ++++- device/dualsense/output_writer.go | 68 +++++++++++++- device/dualsense/output_writer_test.go | 121 +++++++++++++++++++++++++ 6 files changed, 280 insertions(+), 42 deletions(-) diff --git a/device/dualsense/const.go b/device/dualsense/const.go index 417f82f9..c20c14a0 100644 --- a/device/dualsense/const.go +++ b/device/dualsense/const.go @@ -47,28 +47,34 @@ const ( ) 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 - StreamFrameInputState = 0x01 - StreamFrameMicrophonePCM = 0x02 - StreamFrameOutputState = 0x81 - StreamFrameSpeakerPCM = 0x82 - USBMicrophoneSampleRate = 48000 - USBMicrophoneChannels = 2 - USBMicrophoneBytesPerSample = 2 - USBMicrophonePacketFrames = USBMicrophoneSampleRate / 1000 - USBMicrophonePacketSize = USBMicrophonePacketFrames * + 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 diff --git a/device/dualsense/device.go b/device/dualsense/device.go index 967e1f87..9add054c 100644 --- a/device/dualsense/device.go +++ b/device/dualsense/device.go @@ -33,6 +33,7 @@ type DualSense struct { metaState *MetaState speakerFunc func([]byte) + atomicAudioHapticsFunc func(OutputState, []byte) speakerResetFunc func() outputFunc func(OutputState) outputState OutputState @@ -179,6 +180,15 @@ func (d *DualSense) SetSpeakerCallback(f func([]byte)) { 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. @@ -474,6 +484,7 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { processed, release := d.speakerAudioFeature.applyPCM(out, USBHapticsAudioChannels) speakerFunc := d.speakerFunc + atomicAudioHapticsFunc := d.atomicAudioHapticsFunc if len(d.hapticsPCM) == 0 { d.hapticsPCMStartedAt = receivedAt } @@ -483,7 +494,7 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { // 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 { + if speakerFunc != nil && atomicAudioHapticsFunc == nil { speakerFunc(processed) } d.mtx.Unlock() @@ -512,7 +523,8 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { d.mtx.Lock() outputFunc := d.outputFunc - if outputFunc != nil { + atomicAudioHapticsFunc = d.atomicAudioHapticsFunc + if outputFunc != nil || atomicAudioHapticsFunc != nil { feedback := d.outputState if d.combinedBluetoothFeedback { copy(feedback.BluetoothCombinedOutputReport[:], report) @@ -521,7 +533,11 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { } d.mtx.Unlock() dispatchStarted := time.Now() - outputFunc(feedback) + if atomicAudioHapticsFunc != nil { + atomicAudioHapticsFunc(feedback, pending.speakerPCM) + } else { + outputFunc(feedback) + } recordTrafficEvent(TrafficEvent{ Direction: "device->bridge", Source: "feedback-dispatch", @@ -539,6 +555,7 @@ func (d *DualSense) handleHapticsAudioOut(out []byte) { type pendingBluetoothHapticsReport struct { data []byte + speakerPCM []byte assemblyDelay time.Duration } @@ -554,7 +571,11 @@ func (d *DualSense) drainBluetoothHapticsReportsLocked(now time.Time) []pendingB reports := make([]pendingBluetoothHapticsReport, 0, len(d.hapticsPCM)/inputBytesPerReport) for len(d.hapticsPCM) >= inputBytesPerReport { sample := make([]byte, BluetoothHapticsSampleSize) - copyUSBHapticsChannelsToBluetoothSample(sample, d.hapticsPCM[:inputBytesPerReport]) + generationPCM := d.hapticsPCM[:inputBytesPerReport] + copyUSBHapticsChannelsToBluetoothSample(sample, generationPCM) + speakerPCM := make([]byte, (inputBytesPerReport/USBHapticsAudioFrameSize)* + 2*USBHapticsAudioBytesPerSample) + copyDualSenseSpeakerChannels(speakerPCM, generationPCM) seq := d.hapticsSeq interval := d.hapticsInterval @@ -577,6 +598,7 @@ func (d *DualSense) drainBluetoothHapticsReportsLocked(now time.Time) []pendingB } reports = append(reports, pendingBluetoothHapticsReport{ data: report, + speakerPCM: speakerPCM, assemblyDelay: assemblyDelay, }) } diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index 938ec73a..dc27e6d8 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -22,6 +22,7 @@ func init() { 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}) } type dshandler struct { @@ -159,9 +160,10 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } if speakerOutput { - if streamFrameVersion != StreamFrameVersionV3 { - return fmt.Errorf("DualSense speaker output requires framed stream version 0x%02X", - StreamFrameVersionV3) + 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, @@ -175,11 +177,23 @@ func (h *dshandler) StreamHandler() api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + 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() }() @@ -227,14 +241,16 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog if frameVersion != StreamFrameVersion && frameVersion != StreamFrameVersionV2 && - frameVersion != StreamFrameVersionV3 { + frameVersion != StreamFrameVersionV3 && + frameVersion != StreamFrameVersionV4 { return fmt.Errorf("unsupported DualSense framed stream version 0x%02X", frameVersion) } headerSize := StreamFrameHeaderSize if frameVersion == StreamFrameVersionV2 || - frameVersion == StreamFrameVersionV3 { + frameVersion == StreamFrameVersionV3 || + frameVersion == StreamFrameVersionV4 { headerSize = StreamFrameV2HeaderSize } header := make([]byte, headerSize) @@ -286,7 +302,8 @@ func readDualSenseInputStreamVersion(conn net.Conn, dse *DualSense, logger *slog } if frameVersion == StreamFrameVersionV2 || - frameVersion == StreamFrameVersionV3 { + 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) diff --git a/device/dualsense/dsedge_handler.go b/device/dualsense/dsedge_handler.go index eb028010..5779039f 100644 --- a/device/dualsense/dsedge_handler.go +++ b/device/dualsense/dsedge_handler.go @@ -19,6 +19,7 @@ func init() { 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 { @@ -156,9 +157,10 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } if speakerOutput { - if streamFrameVersion != StreamFrameVersionV3 { - return fmt.Errorf("DualSense Edge speaker output requires framed stream version 0x%02X", - StreamFrameVersionV3) + 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, @@ -172,11 +174,23 @@ func (h *dsedgehandler) StreamHandler() api.StreamHandlerFunc { } writer.EnqueueControl(StreamFrameOutputState, data) }) - dse.SetSpeakerCallback(writer.EnqueueSpeakerFromUSB) + 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() }() diff --git a/device/dualsense/output_writer.go b/device/dualsense/output_writer.go index 1d82f63f..511e67e6 100644 --- a/device/dualsense/output_writer.go +++ b/device/dualsense/output_writer.go @@ -16,9 +16,17 @@ const ( // 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. - dualSenseSpeakerPayloadCapacity = USBHapticsAudioMaxPacketSize * 10 / 2 - dualSenseSpeakerTraceInterval = 10 * time.Second - dualSenseSpeakerResetTimeout = 250 * time.Millisecond + // 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 { @@ -223,6 +231,56 @@ func (w *dualSenseOutputWriter) EnqueueSpeakerFromUSB(usbPCM []byte) { } } +// 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)) @@ -413,10 +471,10 @@ func (w *dualSenseOutputWriter) traceSpeakerState(final bool) { w.lastTrace = now state := w.telemetry.snapshot() log := w.logger.Debug - message := "DualSense V3 speaker stream" + message := "DualSense framed speaker stream" if final { log = w.logger.Info - message = "DualSense V3 speaker stream stopped" + message = "DualSense framed speaker stream stopped" } log(message, "receivedPayloads", state.ReceivedPayloads, diff --git a/device/dualsense/output_writer_test.go b/device/dualsense/output_writer_test.go index e33e1917..1e3d836f 100644 --- a/device/dualsense/output_writer_test.go +++ b/device/dualsense/output_writer_test.go @@ -108,6 +108,47 @@ func TestDualSenseV3WriterFramesNativeSpeakerPairAndFeedback(t *testing.T) { } } +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) @@ -478,6 +519,86 @@ func TestDualSenseAndEdgeV3VariantsForwardSpeakerAndAcceptInput(t *testing.T) { } } +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, From f535bf12e7dbf592b24eeb58118fa4857ec8a1cb Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Tue, 21 Jul 2026 01:16:52 -0500 Subject: [PATCH 296/298] Keep virtual DualSense microphone PCM at unity --- device/dualsense/audio_control.go | 23 ++++++++++++++++++----- device/dualsense/audio_control_test.go | 12 +++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/device/dualsense/audio_control.go b/device/dualsense/audio_control.go index 1458c07b..01936c4e 100644 --- a/device/dualsense/audio_control.go +++ b/device/dualsense/audio_control.go @@ -54,6 +54,7 @@ type audioFeatureState struct { maximum int16 resolution int16 defaultVolume int16 + applyVolume bool gain audioGainRamp } @@ -63,13 +64,14 @@ type audioGainRamp struct { framesRemaining int } -func newAudioFeatureState(minimum, maximum, resolution, defaultVolume int16) audioFeatureState { +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, @@ -83,6 +85,7 @@ func newSpeakerAudioFeatureState() audioFeatureState { audioSpeakerVolumeMaximum, audioSpeakerVolumeResolution, audioSpeakerVolumeDefault, + true, ) } @@ -92,6 +95,7 @@ func newMicrophoneAudioFeatureState() audioFeatureState { audioMicrophoneVolumeMaximum, audioMicrophoneVolumeResolution, audioMicrophoneVolumeDefault, + false, ) } @@ -109,7 +113,9 @@ func (s *audioFeatureState) setVolume(volume int16) { return } s.volume = volume - s.beginGainTransition() + if s.applyVolume { + s.beginGainTransition() + } } func (s *audioFeatureState) beginGainTransition() { @@ -123,13 +129,20 @@ func (s *audioFeatureState) resetStreamGain() { s.gain.framesRemaining = 0 } -// targetGain is relative to the feature unit's default value. In particular, -// the microphone advertises the DualSense-style 0..+48 dB hardware range while -// its +48 dB default remains unity for the already calibrated decoded PCM path. +// 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)) } diff --git a/device/dualsense/audio_control_test.go b/device/dualsense/audio_control_test.go index 96597669..b9e33670 100644 --- a/device/dualsense/audio_control_test.go +++ b/device/dualsense/audio_control_test.go @@ -202,15 +202,21 @@ func TestDualSenseAudioGainDefaultsAreNeutralAndChangesRamp(t *testing.T) { 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 volume change did not process PCM") + 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 <= micLast || micFirst >= 10000 || micLast < 35 || micLast > 45 { - t.Fatalf("microphone relative gain ramp was unexpected: first=%d last=%d", micFirst, micLast) + if micFirst <= 0 || micFirst >= 10000 || micLast != 0 { + t.Fatalf("microphone mute ramp was unexpected: first=%d last=%d", micFirst, micLast) } } From 02fffe60e7413a3f6b8c3cbd54469857fbaf9c2b Mon Sep 17 00:00:00 2001 From: strifeforlyfe Date: Thu, 23 Jul 2026 19:31:58 -0500 Subject: [PATCH 297/298] Add PlayStation audio-only virtual devices --- device/dualsense/descriptor.go | 16 ++++++++++++++ device/dualsense/device_output_test.go | 29 ++++++++++++++++++++++++++ device/dualsense/ds_handler.go | 6 ++++++ device/dualshock4/audio_test.go | 24 +++++++++++++++++++++ device/dualshock4/descriptor.go | 20 ++++++++++++++++++ device/dualshock4/handler.go | 8 +++++++ 6 files changed, 103 insertions(+) diff --git a/device/dualsense/descriptor.go b/device/dualsense/descriptor.go index 775d14e1..3c276bce 100644 --- a/device/dualsense/descriptor.go +++ b/device/dualsense/descriptor.go @@ -356,6 +356,22 @@ func makeDescriptor(edge bool) usb.Descriptor { 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++ { diff --git a/device/dualsense/device_output_test.go b/device/dualsense/device_output_test.go index 3411db9f..c9b7b957 100644 --- a/device/dualsense/device_output_test.go +++ b/device/dualsense/device_output_test.go @@ -10,6 +10,35 @@ import ( "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 { diff --git a/device/dualsense/ds_handler.go b/device/dualsense/ds_handler.go index dc27e6d8..1145e950 100644 --- a/device/dualsense/ds_handler.go +++ b/device/dualsense/ds_handler.go @@ -23,6 +23,8 @@ func init() { 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 { @@ -30,6 +32,7 @@ type dshandler struct { combinedBluetoothFeedback bool microphoneInput bool speakerOutput bool + audioOnly bool streamFrameVersion byte } @@ -100,6 +103,9 @@ func (h *dshandler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { 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 } diff --git a/device/dualshock4/audio_test.go b/device/dualshock4/audio_test.go index 0a48c24f..bae4d034 100644 --- a/device/dualshock4/audio_test.go +++ b/device/dualshock4/audio_test.go @@ -64,6 +64,30 @@ func TestDescriptorExposesNativeDS4AudioTopology(t *testing.T) { 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) diff --git a/device/dualshock4/descriptor.go b/device/dualshock4/descriptor.go index a3226714..ef6fd2fb 100644 --- a/device/dualshock4/descriptor.go +++ b/device/dualshock4/descriptor.go @@ -447,3 +447,23 @@ var defaultDescriptor = usb.Descriptor{ 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/handler.go b/device/dualshock4/handler.go index d9c3b2f5..e03e728d 100644 --- a/device/dualshock4/handler.go +++ b/device/dualshock4/handler.go @@ -26,11 +26,16 @@ func init() { 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 } @@ -74,6 +79,9 @@ func (h *handler) CreateDevice(o *device.CreateOptions) (usb.Device, error) { } ds4.microphoneInput = h.microphoneInput ds4.speakerOutput = h.speakerOutput + if h.audioOnly { + ds4.descriptor = makeAudioOnlyDescriptor() + } ds4.streamFrameVersion = h.streamFrameVersion return ds4, nil } From 3fd8d855e975bff4d4b5cc1fc047922a6c481349 Mon Sep 17 00:00:00 2001 From: potpiemuncher <146048482+potpiemuncher@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:35:38 -0500 Subject: [PATCH 298/298] Prevent stale release artifacts --- .github/workflows/release.yml | 138 +++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0b92e15..5b9a82fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,8 @@ jobs: name: Create Release needs: [build, generate-changelog, client-libraries] runs-on: ubuntu-latest + env: + NUGET_USER_CONFIGURED: ${{ secrets.NUGET_USER != '' }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -79,6 +81,82 @@ jobs: 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: @@ -86,17 +164,23 @@ jobs: 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 @@ -139,6 +223,7 @@ jobs: 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: | @@ -194,56 +279,3 @@ jobs: fi echo "Publishing crate with OIDC trusted publishing..." cargo publish --allow-dirty - - - 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@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 }}