Vita Gamepad
局域网虚拟游戏手柄控制台
开启后,程序会广播发现信号并等待同一 Wi-Fi 下的 PSVita。
暂停时会立即发送中立状态、释放所有按键并移除虚拟手柄。再次开启即可重新连接。
请保持此程序运行;关闭这个网页不会停止后台接收服务。
diff --git a/.gitignore b/.gitignore index e687e7a..51b557c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ __pycache__/ *.py[cod] .venv/ +.build-venv/ +.idea/ *.egg-info/ +build/ +dist/ vita/build/ .DS_Store - diff --git a/README.md b/README.md index e84c325..560384c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ macOS 游戏手柄。 - 前触摸板底部左右角模拟 L3/R3 - 断线 300 ms 后自动释放全部按键,避免卡键 - Windows 输出标准 Xbox 360/XInput 虚拟手柄 -- macOS 输出通用 HID 游戏手柄 +- macOS 提供实验性通用 HID 后端;新版系统可能受 entitlement 限制 ## 按键映射 @@ -33,21 +33,86 @@ macOS 游戏手柄。 ### Windows -1. 安装 [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases)。 -2. 在 PowerShell 中运行: +支持 Windows 10/11,电脑端会创建标准 Xbox 360/XInput 虚拟手柄。 + +#### 1. 安装虚拟手柄驱动 + +从 [ViGEmBus 官方 Releases](https://github.com/nefarius/ViGEmBus/releases) +下载并安装 `ViGEmBus Setup 1.22.0`。该项目已经归档,因此只建议从官方仓库 +下载签名驱动,不要使用第三方重新打包版本。安装完成后建议重启一次 Windows。 + +#### 2. 安装 Vita Gamepad 电脑端 + +打开 PowerShell,进入本项目目录后运行: ```powershell -py -m venv .venv +py -3 -m venv .venv +Set-ExecutionPolicy -Scope Process Bypass .\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip pip install -e ".[windows]" +vitapad-gui +``` + +程序会自动打开 `http://127.0.0.1:8765/` 控制面板。首次运行出现 Windows +防火墙提示时,只需允许“专用网络”访问,不建议开放公用网络。 + +#### 3. 连接与测试 + +1. 确保 Windows 电脑和 PSVita 连接同一个 Wi-Fi。 +2. 在控制面板点击“开启使用”。 +3. 打开 Vita Gamepad,等待页面显示 Vita IP。 +4. 按 Vita 按键,页面中的对应按键会实时亮起;双摇杆和 L2/R2 也会跟随。 +5. 按 `Win + R`,输入 `joy.cpl`,应能看到 `Controller (XBOX 360 For Windows)`。 +6. 在该设备的“属性”页面可进一步检查 Windows 收到的按键和摇杆输入。 + +点击控制面板的“暂停”会立即释放按键、停止网络接收并移除虚拟手柄。 + +#### Windows 命令行模式 + +不需要控制面板时可运行: + +```powershell vitapad ``` -首次运行时允许 Windows 防火墙的“专用网络”访问。程序创建的是 Xbox 360 -控制器,Steam、Xbox 应用和绝大多数 Windows 游戏都能直接识别。 +只测试 Vita 网络和按键,不创建 Xbox 虚拟手柄: + +```powershell +vitapad --backend debug +``` + +#### 打包 Windows EXE + +已提供自动打包脚本。在 PowerShell 中运行: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 +``` + +生成文件位于 `dist\windows\VitaGamepadDashboard.exe`。目标电脑仍需先安装 +ViGEmBus 驱动。 + +#### Windows 常见问题 + +- 提示“无法创建虚拟手柄”:确认已安装 ViGEmBus,并在设备管理器的“系统设备” + 中检查 `Nefarius Virtual Gamepad Emulation Bus`,然后重启电脑。 +- 控制页面没有自动打开:手动访问 `http://127.0.0.1:8765/`。 +- 一直等待 Vita:确认网络类型为“专用网络”,允许 Python 或 + `VitaGamepadDashboard.exe` 通过防火墙,并检查路由器没有开启 AP/客户端隔离。 +- PowerShell 禁止运行激活脚本:重新执行 + `Set-ExecutionPolicy -Scope Process Bypass`,它只影响当前窗口。 +- `5000` 端口被占用:关闭其他 Vita Gamepad 实例或占用该端口的程序;当前 + Vita 端固定使用 UDP 5000。 ### macOS +> [!WARNING] +> macOS 没有正式、稳定的系统级虚拟游戏手柄 API。当前实验性 +> `IOHIDUserDevice` 后端在旧版系统上可尝试通过管理员权限运行,但在 +> macOS 26 等新版系统上可能仍被 entitlement/签名策略拒绝。控制面板和实时 +> 按键测试可以使用 `--backend debug`,但该模式不会向游戏创建虚拟手柄。 + ```bash python3 -m venv .venv source .venv/bin/activate @@ -55,10 +120,39 @@ pip install -e . sudo .venv/bin/vitapad ``` -macOS 后端通过 `IOHIDUserDevice` 创建通用 HID 手柄,通常需要管理员权限。 -Apple 并未把用户态虚拟游戏手柄作为稳定的 Game Controller API 保证;个别只 -接受 Apple Game Controller Framework 设备的游戏可能不会显示它。Steam 游戏可 -在“控制器”设置中启用 Steam Input 以提高兼容性。 +macOS 后端通过 `IOHIDUserDevice` 尝试创建通用 HID 手柄,需要管理员权限, +且不保证能被当前系统或游戏接受。 + +使用预编译的 Apple Silicon 接收端时,解压后运行: + +```bash +sudo ./VitaGamepadReceiver +``` + +如果 macOS 阻止首次打开,可在“系统设置 → 隐私与安全性”中允许,或执行: + +```bash +xattr -dr com.apple.quarantine VitaGamepadReceiver +``` + +### 图形控制面板 + +预编译的 `VitaGamepadDashboard` 启动后会自动打开本机控制页面,页面只监听 +`127.0.0.1`。可以在其中: + +- 点击“开启使用”创建虚拟手柄并开始自动发现 Vita +- 点击“暂停”立即释放全部按键并停止接收 +- 查看运行状态、Vita IP、运行时间和端口 +- 在手柄预览中实时检查按键高亮、双摇杆位置和 L2/R2 压力 +- 查看连接、断线、重连和错误日志 + +macOS 需要通过终端以管理员权限启动: + +```bash +sudo ./VitaGamepadDashboard +``` + +关闭浏览器页面不会结束接收程序;需要结束程序时在终端按 `Control-C`。 ### 仅测试网络,不创建手柄 @@ -84,6 +178,7 @@ vitapad --backend debug ```bash cd vita +python3 assets/generate_assets.py # 仅在需要重新生成 LiveArea 图片时运行 cmake -S . -B build cmake --build build ``` @@ -104,4 +199,3 @@ Vita 界面显示电脑 IP 和已发送包数。按住 `Start + Select` 两秒 ```bash python -m unittest discover -s tests -v ``` - diff --git a/docs/tasks/task_detail_2026_07_17.md b/docs/tasks/task_detail_2026_07_17.md index 9c2cede..e521d39 100644 --- a/docs/tasks/task_detail_2026_07_17.md +++ b/docs/tasks/task_detail_2026_07_17.md @@ -21,3 +21,69 @@ 3. 提交项目全部源码、测试与文档。 4. 配置远程地址并推送 main 分支。 - **执行结果**: Vita Gamepad 项目已提交并推送至指定仓库。 + +## 会话 ID: vita-gamepad-build +- [2026-07-17 18:30:51] +- **执行原因**: 为用户直接交叉编译 PSVita 安装包,并生成当前 Mac 可用的电脑接收端。 +- **执行过程**: + 1. 下载 VitaSDK 2.540 macOS ARM64 官方工具链并安装 libvita2d。 + 2. 根据当前 VitaSDK API 修正过时的退出回调代码,使用警告即错误配置完成交叉编译。 + 3. 生成 128×128 图标、840×500 LiveArea 背景和 280×158 启动图,并重新封装 VPK。 + 4. 使用 PyInstaller 生成 macOS ARM64 单文件接收端,并进行真实 UDP 输入与断线保护测试。 + 5. 校验 VPK 内全部文件、运行 6 项自动化测试并生成 SHA-256 校验值。 +- **执行结果**: 已生成可安装的 `VitaGamepad-0.1.0.vpk` 和 `VitaGamepadReceiver-macOS-arm64.zip`;VPK 完整性与全部自动化测试均通过。 + +## 会话 ID: vita-gamepad-vpk-fix +- [2026-07-17 18:44:34] +- **执行原因**: 修复 PSVita 安装 VPK 时出现的 `0x8010113D` 错误。 +- **执行过程**: + 1. 检索并确认错误对应 LiveArea PNG 资源格式校验失败。 + 2. 检查发现原资源为 32 位 RGBA,而 Vita 包推广器要求 8 位索引色 PNG。 + 3. 修改资源生成器,以 256 色调色板重新生成全部 LiveArea 图片。 + 4. 将内部版本提升至 01.01,重新交叉编译并封装修正版 VPK。 + 5. 解包复检三个 PNG 均为 8-bit colormap,并完成 VPK 完整性及自动化测试。 +- **执行结果**: 已生成修正版 `VitaGamepad-0.1.1-fixed.vpk`,包内资源格式和压缩完整性检查全部通过。 + +## 会话 ID: vita-gamepad-dashboard +- [2026-07-17 18:50:00] +- **执行原因**: 为电脑接收端增加可开启、暂停并显示实时日志的操作界面。 +- **执行过程**: + 1. 实现仅监听本机地址并带随机访问令牌的浏览器控制面板。 + 2. 增加开启、运行、暂停和错误状态管理,暂停时停止接收并释放虚拟手柄。 + 3. 展示 Vita IP、输入端口、实际后端、运行时间以及最多 500 条实时日志。 + 4. 保留原命令行入口,并增加独立的 `vitapad-gui` 程序入口。 + 5. 新增状态切换自动化测试,并对打包后的 macOS ARM64 程序执行真实接口测试。 +- **执行结果**: 已生成 `VitaGamepadDashboard-macOS-arm64.zip`,开启、暂停、状态及日志功能验证通过,自动化测试增至 7 项。 + +## 会话 ID: vita-gamepad-input-preview +- [2026-07-17 19:03:00] +- **执行原因**: 在电脑端增加 PSVita 当前输入的实时可视化测试功能。 +- **执行过程**: + 1. 为接收器增加输入状态回调,并在断线、暂停时同步推送中立状态。 + 2. 通过本机 Server-Sent Events 实时传输输入,不依赖低频日志轮询。 + 3. 绘制手柄预览,覆盖方向键、动作键、L1/R1、L2/R2、Start/Select、L3/R3 和双摇杆。 + 4. 实现按下高亮、松开复原、摇杆位置跟随和扳机压力百分比显示。 + 5. 使用持续模拟输入完成浏览器视觉检查,并验证 390px 窄屏无横向溢出。 +- **执行结果**: 实时预览能正确显示组合按键、摇杆与扳机状态,松手后全部恢复;自动化测试增至 8 项。 + +## 会话 ID: vita-gamepad-macos-hid-diagnosis +- [2026-07-17 19:13:02] +- **执行原因**: 解释并诊断 macOS 无法创建虚拟 HID 手柄的错误。 +- **执行过程**: + 1. 在 macOS 26.2 普通用户环境复现 IOHIDUserDeviceCreate 返回空值。 + 2. 检查打包程序签名,确认当前为无 Team ID、无 entitlement 的 ad-hoc 签名。 + 3. 核对 Apple 当前对 IOHIDUserDevice、CoreHID 虚拟设备和游戏控制器的支持边界。 + 4. 区分普通权限不足与新版 macOS 受限 entitlement 两种失败情形。 +- **执行结果**: 错误并非网络或 Vita 输入导致,而是 macOS 拒绝创建系统级虚拟 HID;普通运行可先尝试 sudo,新版系统若仍失败则需要 Apple 授权的虚拟 HID entitlement 或 DriverKit 方案。 + +## 会话 ID: vita-gamepad-windows-guide +- [2026-07-17 19:16:55] +- **执行原因**: 补充 Windows 使用文档、自动打包脚本,并提交此前完成的功能代码。 +- **执行过程**: + 1. 将 Windows 10/11 的 ViGEmBus 驱动安装、Python 环境和控制面板启动步骤写入 README。 + 2. 增加同一 Wi-Fi 连接、实时按键预览及 joy.cpl 系统手柄测试说明。 + 3. 补充防火墙、驱动识别、PowerShell 策略和端口占用排查方法。 + 4. 新增 PowerShell 自动打包脚本,用于生成 VitaGamepadDashboard.exe。 + 5. 同步修正 macOS 实验性 HID 后端的限制说明及项目版本号。 + 6. 运行 8 项自动化测试并检查代码差异后提交、推送。 +- **执行结果**: README 已包含完整 Windows 操作指南,Windows EXE 打包入口已加入,项目功能与文档已提交至远程仓库。 diff --git a/pyproject.toml b/pyproject.toml index 06568ff..6ae312f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vita-gamepad" -version = "0.1.0" +version = "0.2.1" description = "Use a PlayStation Vita as a low-latency network gamepad" readme = "README.md" requires-python = ">=3.10" @@ -17,7 +17,7 @@ windows = ["vgamepad>=0.1.0"] [project.scripts] vitapad = "vitapad.cli:main" +vitapad-gui = "vitapad.gui:main" [tool.setuptools.packages.find] include = ["vitapad*"] - diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 new file mode 100644 index 0000000..d82f72e --- /dev/null +++ b/scripts/build_windows.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$BuildVenv = Join-Path $ProjectRoot ".build-venv" +$Python = Join-Path $BuildVenv "Scripts\python.exe" +$PyInstaller = Join-Path $BuildVenv "Scripts\pyinstaller.exe" +$DistPath = Join-Path $ProjectRoot "dist\windows" +$WorkPath = Join-Path $ProjectRoot "build\pyinstaller-windows" +$SpecPath = Join-Path $ProjectRoot "build" + +Set-Location $ProjectRoot + +if (-not (Test-Path $Python)) { + py -3 -m venv $BuildVenv +} + +& $Python -m pip install --upgrade pip setuptools wheel +& $Python -m pip install -e ".[windows]" pyinstaller + +& $PyInstaller ` + --noconfirm ` + --clean ` + --onefile ` + --name VitaGamepadDashboard ` + --hidden-import vitapad.backends.windows ` + --hidden-import vitapad.backends.debug ` + --collect-all vgamepad ` + --paths $ProjectRoot ` + --distpath $DistPath ` + --workpath $WorkPath ` + --specpath $SpecPath ` + (Join-Path $ProjectRoot "vitapad\gui.py") + +$Output = Join-Path $DistPath "VitaGamepadDashboard.exe" +if (-not (Test-Path $Output)) { + throw "Build finished without producing $Output" +} + +Write-Host "" +Write-Host "Windows package created:" -ForegroundColor Green +Write-Host $Output diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..ed0f4e1 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,90 @@ +import socket +import time +import unittest +from unittest.mock import patch + +from vitapad.gui import DashboardController +from vitapad.protocol import Buttons, InputState + + +class FakeBackend: + name = "fake gamepad" + + def __init__(self) -> None: + self.states: list[InputState] = [] + self.closed = False + + def update(self, state: InputState) -> None: + self.states.append(state) + + def close(self) -> None: + self.closed = True + + +def unused_udp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class DashboardControllerTests(unittest.TestCase): + def test_start_pause_and_logs(self) -> None: + backend = FakeBackend() + controller = DashboardController( + backend_name="debug", + bind="127.0.0.1", + port=unused_udp_port(), + discovery_port=unused_udp_port(), + allow=None, + timeout_ms=100, + ) + with patch("vitapad.gui.create_backend", return_value=backend): + started, _ = controller.start() + self.assertTrue(started) + deadline = time.monotonic() + 1 + while ( + controller.status()["status"] != "running" + and time.monotonic() < deadline + ): + time.sleep(0.01) + self.assertEqual(controller.status()["status"], "running") + paused, _ = controller.pause() + self.assertTrue(paused) + deadline = time.monotonic() + 2 + while ( + controller.status()["status"] != "paused" + and time.monotonic() < deadline + ): + time.sleep(0.01) + self.assertEqual(controller.status()["status"], "paused") + self.assertTrue(backend.closed) + messages = [entry["message"] for entry in controller.logs_after(0)] + self.assertTrue(any("服务已开启" in message for message in messages)) + self.assertTrue(any("正在暂停" in message for message in messages)) + + def test_live_input_subscription(self) -> None: + controller = DashboardController( + backend_name="debug", + bind="127.0.0.1", + port=unused_udp_port(), + discovery_port=unused_udp_port(), + allow=None, + timeout_ms=100, + ) + subscriber = controller.subscribe() + self.assertEqual(subscriber.get_nowait(), InputState.neutral()) + state = InputState( + sequence=42, + buttons=Buttons.LEFT | Buttons.A, + lx=0, + ry=255, + lt=128, + ) + controller.handle_input(state) + self.assertEqual(subscriber.get_nowait(), state) + self.assertEqual(controller.status()["input"]["buttons"], state.buttons) + controller.unsubscribe(subscriber) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_receiver.py b/tests/test_receiver.py index d8d219b..67cdb61 100644 --- a/tests/test_receiver.py +++ b/tests/test_receiver.py @@ -30,6 +30,7 @@ def unused_udp_port() -> int: class ReceiverTests(unittest.TestCase): def test_receive_and_failsafe(self) -> None: backend = FakeBackend() + input_events: list[InputState] = [] port = unused_udp_port() receiver = Receiver( backend, @@ -37,6 +38,7 @@ class ReceiverTests(unittest.TestCase): port=port, discovery_port=unused_udp_port(), timeout_ms=50, + on_input=input_events.append, ) thread = threading.Thread(target=receiver.run) thread.start() @@ -51,6 +53,8 @@ class ReceiverTests(unittest.TestCase): time.sleep(0.01) self.assertEqual(backend.states[0], state) self.assertEqual(backend.states[1], InputState.neutral()) + self.assertEqual(input_events[0], state) + self.assertEqual(input_events[1], InputState.neutral()) finally: receiver.stop() thread.join(timeout=2) diff --git a/vita/CMakeLists.txt b/vita/CMakeLists.txt index 3b05ddc..156e1c8 100644 --- a/vita/CMakeLists.txt +++ b/vita/CMakeLists.txt @@ -14,7 +14,7 @@ include("${VITASDK}/share/vita.cmake" REQUIRED) set(VITA_APP_NAME "Vita Gamepad") set(VITA_TITLEID "VITAPAD01") -set(VITA_VERSION "01.00") +set(VITA_VERSION "01.01") add_executable(vita-gamepad src/main.c) target_compile_options(vita-gamepad PRIVATE -Wall -Wextra -Werror -O2) @@ -37,4 +37,8 @@ vita_create_self(vita-gamepad.self vita-gamepad UNSAFE) vita_create_vpk(vita-gamepad.vpk ${VITA_TITLEID} vita-gamepad.self VERSION ${VITA_VERSION} NAME ${VITA_APP_NAME} + FILE sce_sys/icon0.png sce_sys/icon0.png + FILE sce_sys/livearea/contents/bg.png sce_sys/livearea/contents/bg.png + FILE sce_sys/livearea/contents/startup.png sce_sys/livearea/contents/startup.png + FILE sce_sys/livearea/contents/template.xml sce_sys/livearea/contents/template.xml ) diff --git a/vita/assets/generate_assets.py b/vita/assets/generate_assets.py new file mode 100644 index 0000000..a9e6338 --- /dev/null +++ b/vita/assets/generate_assets.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate deterministic LiveArea artwork for the Vita VPK.""" + +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +LIVEAREA = ROOT / "sce_sys" / "livearea" / "contents" + +NAVY = (18, 24, 38, 255) +NAVY_LIGHT = (29, 39, 58, 255) +GREEN = (38, 190, 125, 255) +GREEN_LIGHT = (80, 225, 155, 255) +WHITE = (245, 248, 252, 255) +MUTED = (165, 178, 197, 255) + + +def draw_wifi(draw: ImageDraw.ImageDraw, center: tuple[int, int], scale: float) -> None: + x, y = center + widths = (54, 38, 22) + for index, width in enumerate(widths): + inset = index * 7 * scale + box = ( + x - width * scale, + y - width * scale + inset, + x + width * scale, + y + width * scale + inset, + ) + draw.arc(box, 205, 335, fill=GREEN_LIGHT, width=max(2, int(5 * scale))) + radius = max(2, int(5 * scale)) + draw.ellipse((x - radius, y + 31 * scale - radius, x + radius, y + 31 * scale + radius), fill=GREEN_LIGHT) + + +def draw_controller( + draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], line_width: int +) -> None: + x0, y0, x1, y1 = box + draw.rounded_rectangle(box, radius=(y1 - y0) // 3, outline=WHITE, width=line_width) + height = y1 - y0 + cx_left = x0 + (x1 - x0) * 0.30 + cy = y0 + height * 0.50 + arm = height * 0.17 + draw.line((cx_left - arm, cy, cx_left + arm, cy), fill=WHITE, width=line_width) + draw.line((cx_left, cy - arm, cx_left, cy + arm), fill=WHITE, width=line_width) + cx_right = x0 + (x1 - x0) * 0.71 + radius = height * 0.055 + for dx, dy in ((0, -0.15), (0.15, 0), (0, 0.15), (-0.15, 0)): + px, py = cx_right + height * dx, cy + height * dy + draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=GREEN_LIGHT) + + +def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + candidates = ( + "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + ) + for candidate in candidates: + try: + return ImageFont.truetype(candidate, size=size) + except OSError: + pass + return ImageFont.load_default() + + +def save(image: Image.Image, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Vita's package promoter rejects RGB/RGBA LiveArea PNGs with 0x8010113D. + # Quantize to an actual 8-bit indexed palette, including alpha entries. + indexed = image.convert("RGBA").quantize( + colors=256, + method=Image.Quantize.FASTOCTREE, + dither=Image.Dither.NONE, + ) + indexed.save(path, "PNG", optimize=True, bits=8) + + +def make_icon() -> None: + image = Image.new("RGBA", (128, 128), NAVY) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((4, 4, 123, 123), radius=24, fill=NAVY_LIGHT, outline=GREEN, width=5) + draw_controller(draw, (20, 35, 108, 90), 5) + draw_wifi(draw, (64, 52), 0.36) + save(image, ROOT / "sce_sys" / "icon0.png") + + +def make_background() -> None: + image = Image.new("RGBA", (840, 500), NAVY) + draw = ImageDraw.Draw(image) + for index in range(7): + x = 480 + index * 72 + draw.ellipse((x, -120 + index * 18, x + 330, 210 + index * 18), outline=(38, 190, 125, 25), width=4) + draw.rounded_rectangle((54, 61, 786, 439), radius=42, fill=NAVY_LIGHT, outline=(38, 190, 125, 120), width=4) + draw_controller(draw, (104, 160, 408, 350), 12) + draw_wifi(draw, (256, 212), 1.15) + draw.text((470, 184), "VITA", font=font(57), fill=WHITE) + draw.text((470, 252), "GAMEPAD", font=font(42), fill=GREEN_LIGHT) + draw.text((472, 322), "Wi-Fi controller", font=font(22), fill=MUTED) + save(image, LIVEAREA / "bg.png") + + +def make_startup() -> None: + image = Image.new("RGBA", (280, 158), NAVY_LIGHT) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((3, 3, 276, 154), radius=18, outline=GREEN, width=4) + draw_controller(draw, (26, 47, 128, 111), 5) + draw_wifi(draw, (77, 64), 0.39) + draw.text((151, 50), "VITA", font=font(28), fill=WHITE) + draw.text((151, 82), "GAMEPAD", font=font(19), fill=GREEN_LIGHT) + save(image, LIVEAREA / "startup.png") + + +if __name__ == "__main__": + make_icon() + make_background() + make_startup() diff --git a/vita/sce_sys/icon0.png b/vita/sce_sys/icon0.png new file mode 100644 index 0000000..62d7f28 Binary files /dev/null and b/vita/sce_sys/icon0.png differ diff --git a/vita/sce_sys/livearea/contents/bg.png b/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 0000000..5ea1acf Binary files /dev/null and b/vita/sce_sys/livearea/contents/bg.png differ diff --git a/vita/sce_sys/livearea/contents/startup.png b/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 0000000..360cc98 Binary files /dev/null and b/vita/sce_sys/livearea/contents/startup.png differ diff --git a/vita/src/main.c b/vita/src/main.c index eb500cb..5916f4f 100644 --- a/vita/src/main.c +++ b/vita/src/main.c @@ -56,31 +56,6 @@ _Static_assert(sizeof(InputPacket) == 20, "InputPacket must be exactly 20 bytes" static uint8_t net_memory[1024 * 1024]; static volatile bool exiting = false; -static int exit_callback(int argc, const void *args) { - (void)argc; - (void)args; - exiting = true; - return 0; -} - -static int callback_thread(SceSize args, void *argp) { - (void)args; - (void)argp; - int callback = sceKernelCreateCallback("Exit callback", exit_callback, NULL); - sceKernelRegisterExitCallback(callback); - sceKernelDelayThreadCB(0xFFFFFFFF); - return 0; -} - -static void setup_callbacks(void) { - int thread = sceKernelCreateThread( - "callback thread", callback_thread, 0x10000100, 0x1000, 0, 0, NULL - ); - if (thread >= 0) { - sceKernelStartThread(thread, 0, NULL); - } -} - static int init_network(void) { int result = sceSysmoduleLoadModule(SCE_SYSMODULE_NET); if (result < 0) { @@ -264,7 +239,6 @@ static void draw_ui( } int main(void) { - setup_callbacks(); sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG_WIDE); sceTouchSetSamplingState(SCE_TOUCH_PORT_FRONT, SCE_TOUCH_SAMPLING_STATE_START); sceTouchSetSamplingState(SCE_TOUCH_PORT_BACK, SCE_TOUCH_SAMPLING_STATE_START); diff --git a/vitapad/__init__.py b/vitapad/__init__.py index 17c6c41..1aa3a8c 100644 --- a/vitapad/__init__.py +++ b/vitapad/__init__.py @@ -1,4 +1,3 @@ """Vita Gamepad desktop receiver.""" -__version__ = "0.1.0" - +__version__ = "0.2.1" diff --git a/vitapad/gui.py b/vitapad/gui.py new file mode 100644 index 0000000..b06b69e --- /dev/null +++ b/vitapad/gui.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import argparse +from collections import deque +from dataclasses import asdict, dataclass +from datetime import datetime +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import queue +import secrets +import threading +import time +from urllib.parse import parse_qs, urlparse +import webbrowser + +from vitapad.backends import create_backend +from vitapad.protocol import InputState +from vitapad.receiver import Receiver + + +@dataclass(frozen=True, slots=True) +class LogEntry: + id: int + time: str + level: str + message: str + + +class DashboardController: + def __init__( + self, + backend_name: str, + bind: str, + port: int, + discovery_port: int, + allow: str | None, + timeout_ms: int, + ) -> None: + self.backend_name = backend_name + self._resolved_backend = backend_name + self.bind = bind + self.port = port + self.discovery_port = discovery_port + self.allow = allow + self.timeout_ms = timeout_ms + self._lock = threading.RLock() + self._status = "paused" + self._receiver: Receiver | None = None + self._worker: threading.Thread | None = None + self._started_at: float | None = None + self._error: str | None = None + self._stop_requested = False + self._logs: deque[LogEntry] = deque(maxlen=500) + self._next_log_id = 1 + self._input = InputState.neutral() + self._subscribers: set[queue.Queue[InputState]] = set() + self.log("控制面板已启动,点击“开启使用”开始接收。") + + def log(self, message: str, level: str = "info") -> None: + with self._lock: + entry = LogEntry( + id=self._next_log_id, + time=datetime.now().strftime("%H:%M:%S"), + level=level, + message=message, + ) + self._next_log_id += 1 + self._logs.append(entry) + + def start(self) -> tuple[bool, str]: + with self._lock: + if self._status in {"starting", "running"}: + return False, "接收服务已经开启" + if self._status == "stopping": + return False, "服务正在暂停,请稍候" + self._status = "starting" + self._error = None + self._stop_requested = False + self.log("正在创建虚拟手柄并启动网络服务...") + self._worker = threading.Thread( + target=self._run_receiver, name="receiver", daemon=True + ) + self._worker.start() + return True, "正在开启" + + def _run_receiver(self) -> None: + try: + backend = create_backend(self.backend_name) + receiver = Receiver( + backend=backend, + bind=self.bind, + port=self.port, + discovery_port=self.discovery_port, + allow=self.allow, + timeout_ms=self.timeout_ms, + log=self.log, + on_input=self.handle_input, + ) + with self._lock: + if self._stop_requested: + backend.close() + self.log("服务已暂停。") + return + self._resolved_backend = backend.name + self._receiver = receiver + self._status = "running" + self._started_at = time.monotonic() + self.log(f"服务已开启,虚拟手柄后端:{backend.name}", "success") + receiver.run() + except Exception as exc: + with self._lock: + self._error = str(exc) + self.log(f"启动失败:{exc}", "error") + finally: + with self._lock: + if self._stop_requested: + self.log("服务已暂停,虚拟手柄已释放。", "success") + self._receiver = None + self._started_at = None + self._status = "paused" + + def pause(self) -> tuple[bool, str]: + with self._lock: + if self._status == "paused": + return False, "接收服务已经暂停" + if self._status == "stopping": + return False, "服务正在暂停" + self._status = "stopping" + self._stop_requested = True + receiver = self._receiver + self.log("正在暂停,释放全部手柄按键...") + if receiver is not None: + receiver.stop() + return True, "正在暂停" + + def status(self) -> dict[str, object]: + with self._lock: + connected = ( + self._receiver.connected_address if self._receiver else None + ) + uptime = ( + int(time.monotonic() - self._started_at) + if self._started_at is not None + else 0 + ) + return { + "status": self._status, + "connected": connected, + "uptime": uptime, + "backend": self._resolved_backend, + "inputPort": self.port, + "discoveryPort": self.discovery_port, + "error": self._error, + "input": asdict(self._input), + } + + def logs_after(self, after: int) -> list[dict[str, object]]: + with self._lock: + return [asdict(entry) for entry in self._logs if entry.id > after] + + def handle_input(self, state: InputState) -> None: + with self._lock: + self._input = state + subscribers = tuple(self._subscribers) + for subscriber in subscribers: + try: + subscriber.put_nowait(state) + except queue.Full: + try: + subscriber.get_nowait() + subscriber.put_nowait(state) + except (queue.Empty, queue.Full): + pass + + def subscribe(self) -> queue.Queue[InputState]: + subscriber: queue.Queue[InputState] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.add(subscriber) + subscriber.put_nowait(self._input) + return subscriber + + def unsubscribe(self, subscriber: queue.Queue[InputState]) -> None: + with self._lock: + self._subscribers.discard(subscriber) + + +HTML = r""" + +
+ + +局域网虚拟游戏手柄控制台
开启后,程序会广播发现信号并等待同一 Wi-Fi 下的 PSVita。
暂停时会立即发送中立状态、释放所有按键并移除虚拟手柄。再次开启即可重新连接。
请保持此程序运行;关闭这个网页不会停止后台接收服务。