feat: add desktop dashboard and Windows setup

This commit is contained in:
zwt13703 2026-07-17 19:17:18 +08:00
parent 8a16d2e1f1
commit bb165a0af2
16 changed files with 861 additions and 48 deletions

5
.gitignore vendored
View File

@ -1,7 +1,10 @@
__pycache__/
*.py[cod]
.venv/
.build-venv/
.idea/
*.egg-info/
build/
dist/
vita/build/
.DS_Store

116
README.md
View File

@ -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
```

View File

@ -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 打包入口已加入,项目功能与文档已提交至远程仓库。

View File

@ -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*"]

42
scripts/build_windows.ps1 Normal file
View File

@ -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

90
tests/test_gui.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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
)

View File

@ -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()

BIN
vita/sce_sys/icon0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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);

View File

@ -1,4 +1,3 @@
"""Vita Gamepad desktop receiver."""
__version__ = "0.1.0"
__version__ = "0.2.1"

404
vitapad/gui.py Normal file
View File

@ -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"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Vita Gamepad</title>
<style>
:root{color-scheme:dark;--bg:#0e1420;--panel:#171f2f;--line:#29364b;--text:#f4f7fb;--muted:#9cabc1;--green:#26be7d;--green2:#50e19b;--red:#ff6b78;--amber:#f4c653}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 10%,#17392f 0,transparent 30%),var(--bg);color:var(--text);font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}
.wrap{width:min(920px,calc(100% - 32px));margin:40px auto}.top{display:flex;align-items:center;gap:15px;margin-bottom:24px}.logo{width:54px;height:54px;border:2px solid var(--green);border-radius:16px;display:grid;place-items:center;color:var(--green2);font-size:25px}.top h1{font-size:25px;margin:0}.top p{color:var(--muted);margin:4px 0 0}
.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:18px}.card{background:rgba(23,31,47,.94);border:1px solid var(--line);border-radius:18px;padding:22px;box-shadow:0 18px 60px #0004}.statusline{display:flex;align-items:center;gap:10px}.dot{width:11px;height:11px;border-radius:50%;background:#66758b;box-shadow:0 0 0 5px #66758b22}.dot.running{background:var(--green2);box-shadow:0 0 0 5px #50e19b22}.dot.starting,.dot.stopping{background:var(--amber);box-shadow:0 0 0 5px #f4c65322}.state{font-size:20px;font-weight:650}.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:24px 0}.item{background:#101725;border:1px solid #243047;border-radius:12px;padding:13px}.label{color:var(--muted);font-size:12px;margin-bottom:6px}.value{font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.actions{display:flex;gap:11px}.btn{border:0;border-radius:11px;padding:12px 19px;font-weight:650;font-size:14px;cursor:pointer;transition:.15s}.btn:hover{transform:translateY(-1px)}.btn:disabled{opacity:.42;cursor:not-allowed;transform:none}.start{background:var(--green);color:#07140e}.pause{background:#303c50;color:var(--text)}
.logcard{grid-column:1/-1;padding:0;overflow:hidden}.loghead{display:flex;justify-content:space-between;align-items:center;padding:17px 20px;border-bottom:1px solid var(--line)}.loghead h2{font-size:15px;margin:0}.clear{color:var(--muted);background:none;border:0;cursor:pointer}.logs{height:310px;overflow:auto;padding:10px 0;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.entry{display:grid;grid-template-columns:72px 72px 1fr;gap:8px;padding:7px 20px}.entry:hover{background:#ffffff06}.time{color:#718198}.level{color:#91a2ba}.level.success{color:var(--green2)}.level.error{color:var(--red)}.message{white-space:pre-wrap;word-break:break-word}.hint{color:var(--muted);font-size:13px;line-height:1.65;margin:0}.errorbox{display:none;background:#3b1d27;border:1px solid #7b3345;color:#ffadb7;padding:11px 13px;border-radius:10px;margin-top:15px}
.testcard{grid-column:1/-1;padding:18px 22px}.testhead{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:5px}.testhead h2{font-size:15px;margin:0}.pressed{color:var(--muted);font:12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.gamepad{display:block;width:100%;max-height:360px;transition:opacity .2s}.gamepad.disconnected{opacity:.45}.shell{fill:#101725;stroke:#34435b;stroke-width:5}.pad-btn{fill:#263349;stroke:#617089;stroke-width:3;transition:fill .06s,stroke .06s,filter .06s}.pad-btn.active{fill:var(--green);stroke:var(--green2);filter:drop-shadow(0 0 8px #50e19baa)}.pad-label{fill:#dce4ef;font:500 15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-anchor:middle;dominant-baseline:central;pointer-events:none}.stick-ring{fill:#1a2435;stroke:#53627a;stroke-width:5}.stick.active .stick-ring{stroke:var(--green2);filter:drop-shadow(0 0 8px #50e19b88)}.stick-knob{fill:#334158;stroke:#718099;stroke-width:3;transition:transform .025s linear}.trigger-track{fill:#263349}.trigger-fill{fill:var(--green2);transition:height .025s linear,y .025s linear}.trigger-label{fill:#9cabc1;font:500 13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-anchor:middle}.center-label{fill:#728198;font:500 11px ui-monospace,SFMono-Regular,Menlo,monospace;text-anchor:middle}
@media(max-width:680px){.grid{grid-template-columns:1fr}.wrap{margin:22px auto}.meta{grid-template-columns:1fr}.entry{grid-template-columns:62px 58px 1fr;padding-inline:13px}}
</style>
</head>
<body><main class="wrap">
<div class="top"><div class="logo"></div><div><h1>Vita Gamepad</h1><p>局域网虚拟游戏手柄控制台</p></div></div>
<section class="grid">
<div class="card">
<div class="statusline"><span id="dot" class="dot"></span><span id="state" class="state">已暂停</span></div>
<div class="meta">
<div class="item"><div class="label">PSVita 连接</div><div id="vita" class="value">尚未连接</div></div>
<div class="item"><div class="label">运行时间</div><div id="uptime" class="value">00:00:00</div></div>
<div class="item"><div class="label">输入端口</div><div id="port" class="value">5000</div></div>
<div class="item"><div class="label">虚拟手柄</div><div id="backend" class="value">自动选择</div></div>
</div>
<div class="actions"><button id="start" class="btn start">开启使用</button><button id="pause" class="btn pause">暂停</button></div>
<div id="error" class="errorbox"></div>
</div>
<div class="card"><p class="hint">开启后程序会广播发现信号并等待同一 Wi-Fi 下的 PSVita<br><br>暂停时会立即发送中立状态释放所有按键并移除虚拟手柄再次开启即可重新连接<br><br>请保持此程序运行关闭这个网页不会停止后台接收服务</p></div>
<div class="card testcard">
<div class="testhead"><h2>实时按键测试</h2><div id="pressed" class="pressed">等待手柄输入</div></div>
<svg id="gamepad" class="gamepad disconnected" viewBox="0 0 760 330" role="img" aria-label="PSVita 实时手柄输入预览">
<path class="shell" d="M176 60C113 64 79 100 59 172L27 270c-13 41 25 66 61 39l82-60h420l82 60c36 27 74 2 61-39l-32-98c-20-72-54-108-117-112z"/>
<rect class="pad-btn" data-mask="16" x="126" y="68" width="116" height="31" rx="12"/><text class="pad-label" x="184" y="84">L1</text>
<rect class="pad-btn" data-mask="32" x="518" y="68" width="116" height="31" rx="12"/><text class="pad-label" x="576" y="84">R1</text>
<g><rect class="trigger-track" x="88" y="52" width="22" height="76" rx="8"/><rect id="ltFill" class="trigger-fill" x="88" y="128" width="22" height="0" rx="8"/><text class="trigger-label" x="99" y="42">L2</text></g>
<g><rect class="trigger-track" x="650" y="52" width="22" height="76" rx="8"/><rect id="rtFill" class="trigger-fill" x="650" y="128" width="22" height="0" rx="8"/><text class="trigger-label" x="661" y="42">R2</text></g>
<g aria-label="方向键">
<rect class="pad-btn" data-mask="1024" x="142" y="113" width="46" height="53" rx="8"/><text class="pad-label" x="165" y="139"></text>
<rect class="pad-btn" data-mask="2048" x="142" y="199" width="46" height="53" rx="8"/><text class="pad-label" x="165" y="226"></text>
<rect class="pad-btn" data-mask="4096" x="99" y="156" width="53" height="46" rx="8"/><text class="pad-label" x="125" y="179"></text>
<rect class="pad-btn" data-mask="8192" x="178" y="156" width="53" height="46" rx="8"/><text class="pad-label" x="205" y="179"></text>
<rect x="151" y="165" width="28" height="29" rx="5" fill="#263349"/>
</g>
<g aria-label="动作键">
<circle class="pad-btn" data-mask="8" cx="595" cy="128" r="24"/><text class="pad-label" x="595" y="128"></text>
<circle class="pad-btn" data-mask="2" cx="643" cy="176" r="24"/><text class="pad-label" x="643" y="176"></text>
<circle class="pad-btn" data-mask="1" cx="595" cy="224" r="24"/><text class="pad-label" x="595" y="224">×</text>
<circle class="pad-btn" data-mask="4" cx="547" cy="176" r="24"/><text class="pad-label" x="547" y="176"></text>
</g>
<rect class="pad-btn" data-mask="64" x="306" y="135" width="55" height="26" rx="13"/><text class="center-label" x="333" y="179">SELECT</text>
<rect class="pad-btn" data-mask="128" x="399" y="135" width="55" height="26" rx="13"/><text class="center-label" x="426" y="179">START</text>
<g id="leftStick" class="stick" data-mask="256"><circle class="stick-ring" cx="285" cy="232" r="42"/><circle id="leftKnob" class="stick-knob" cx="285" cy="232" r="23"/><text class="center-label" x="285" y="292">L3</text></g>
<g id="rightStick" class="stick" data-mask="512"><circle class="stick-ring" cx="475" cy="232" r="42"/><circle id="rightKnob" class="stick-knob" cx="475" cy="232" r="23"/><text class="center-label" x="475" y="292">R3</text></g>
<circle cx="380" cy="216" r="27" fill="#1a2435" stroke="#34435b" stroke-width="4"/><path d="M367 216h26M380 203v26" stroke="#718099" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
<div class="card logcard"><div class="loghead"><h2>运行日志</h2><button id="clear" class="clear">清空显示</button></div><div id="logs" class="logs"></div></div>
</section></main>
<script>
const TOKEN="__TOKEN__";let last=0;const $=id=>document.getElementById(id);
const labels={paused:"已暂停",starting:"正在开启",running:"运行中",stopping:"正在暂停"};
const buttonNames=[[1,"×"],[2,""],[4,""],[8,""],[16,"L1"],[32,"R1"],[64,"Select"],[128,"Start"],[256,"L3"],[512,"R3"],[1024,""],[2048,""],[4096,""],[8192,""]];
function duration(n){const h=String(Math.floor(n/3600)).padStart(2,"0"),m=String(Math.floor(n%3600/60)).padStart(2,"0"),s=String(n%60).padStart(2,"0");return `${h}:${m}:${s}`}
async function api(path,method="GET"){const r=await fetch(path,{method,headers:{"X-Vita-Token":TOKEN}});return r.json()}
function updateInput(i){if(!i)return;document.querySelectorAll("[data-mask]").forEach(el=>el.classList.toggle("active",Boolean(i.buttons&Number(el.dataset.mask))));const move=(id,x,y)=>$(id).setAttribute("transform",`translate(${((x-128)/127*18).toFixed(1)} ${((y-128)/127*18).toFixed(1)})`);move("leftKnob",i.lx,i.ly);move("rightKnob",i.rx,i.ry);const trigger=(id,v)=>{const h=v/255*76;$(id).setAttribute("y",128-h);$(id).setAttribute("height",h)};trigger("ltFill",i.lt);trigger("rtFill",i.rt);const names=buttonNames.filter(([m])=>i.buttons&m).map(([,n])=>n);if(i.lt)names.push(`L2 ${Math.round(i.lt/255*100)}%`);if(i.rt)names.push(`R2 ${Math.round(i.rt/255*100)}%`);$("pressed").textContent=names.length?names.join(" · "):"无按键按下"}
async function refresh(){try{const s=await api("/api/status");$("state").textContent=labels[s.status]||s.status;$("dot").className="dot "+s.status;$("vita").textContent=s.connected||"尚未连接";$("gamepad").classList.toggle("disconnected",!s.connected);$("uptime").textContent=duration(s.uptime);$("port").textContent=`${s.inputPort} / 发现 ${s.discoveryPort}`;$("backend").textContent=s.backend;$("start").disabled=["running","starting","stopping"].includes(s.status);$("pause").disabled=["paused","stopping"].includes(s.status);$("error").style.display=s.error?"block":"none";$("error").textContent=s.error||"";updateInput(s.input);const logs=await api("/api/logs?after="+last);for(const e of logs.logs){last=Math.max(last,e.id);const row=document.createElement("div");row.className="entry";row.innerHTML=`<span class="time">${e.time}</span><span class="level ${e.level}">${e.level}</span><span class="message"></span>`;row.lastChild.textContent=e.message;$("logs").appendChild(row);$("logs").scrollTop=$("logs").scrollHeight}}catch(e){}}
$("start").onclick=()=>api("/api/start","POST").then(refresh);$("pause").onclick=()=>api("/api/pause","POST").then(refresh);$("clear").onclick=()=>{$("logs").replaceChildren()};refresh();setInterval(refresh,750);
const events=new EventSource("/api/events?token="+encodeURIComponent(TOKEN));events.onmessage=e=>{try{updateInput(JSON.parse(e.data))}catch(_){}};
</script></body></html>"""
def make_handler(
controller: DashboardController, token: str
) -> type[BaseHTTPRequestHandler]:
page = HTML.replace("__TOKEN__", token).encode()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format: str, *args: object) -> None:
return
def _authorized(self) -> bool:
return self.headers.get("X-Vita-Token") == token
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _events(self) -> None:
subscriber = controller.subscribe()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
try:
while True:
try:
state = subscriber.get(timeout=10)
payload = json.dumps(
asdict(state), ensure_ascii=False, separators=(",", ":")
)
self.wfile.write(f"data:{payload}\n\n".encode())
except queue.Empty:
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
controller.unsubscribe(subscriber)
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(page)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(page)
return
if parsed.path == "/api/events":
query_token = parse_qs(parsed.query).get("token", [""])[0]
if not secrets.compare_digest(query_token, token):
self._json({"error": "unauthorized"}, HTTPStatus.FORBIDDEN)
return
self._events()
return
if not self._authorized():
self._json({"error": "unauthorized"}, HTTPStatus.FORBIDDEN)
return
if parsed.path == "/api/status":
self._json(controller.status())
return
if parsed.path == "/api/logs":
query = parse_qs(parsed.query)
try:
after = int(query.get("after", ["0"])[0])
except ValueError:
after = 0
self._json({"logs": controller.logs_after(after)})
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if not self._authorized():
self._json({"error": "unauthorized"}, HTTPStatus.FORBIDDEN)
return
if self.path == "/api/start":
ok, message = controller.start()
self._json({"ok": ok, "message": message})
return
if self.path == "/api/pause":
ok, message = controller.pause()
self._json({"ok": ok, "message": message})
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return Handler
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Vita Gamepad 图形控制面板")
parser.add_argument(
"--backend", choices=("auto", "windows", "macos", "debug"), default="auto"
)
parser.add_argument("--bind", default="0.0.0.0")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--discovery-port", type=int, default=5001)
parser.add_argument("--allow")
parser.add_argument("--timeout-ms", type=int, default=300)
parser.add_argument("--ui-port", type=int, default=8765)
parser.add_argument("--no-browser", action="store_true")
return parser
def main() -> None:
args = build_parser().parse_args()
controller = DashboardController(
backend_name=args.backend,
bind=args.bind,
port=args.port,
discovery_port=args.discovery_port,
allow=args.allow,
timeout_ms=args.timeout_ms,
)
token = secrets.token_urlsafe(24)
server = ThreadingHTTPServer(
("127.0.0.1", args.ui_port), make_handler(controller, token)
)
server.daemon_threads = True
url = f"http://127.0.0.1:{server.server_port}/"
print(f"Vita Gamepad 控制面板:{url}")
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
controller.pause()
server.server_close()
if __name__ == "__main__":
main()

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import socket
import threading
import time
from collections.abc import Callable
from vitapad.backends import GamepadBackend
from vitapad.protocol import (
@ -22,6 +23,8 @@ class Receiver:
discovery_port: int = 5001,
allow: str | None = None,
timeout_ms: int = 300,
log: Callable[[str], None] = print,
on_input: Callable[[InputState], None] | None = None,
) -> None:
self.backend = backend
self.bind = bind
@ -29,6 +32,8 @@ class Receiver:
self.discovery_port = discovery_port
self.allow = allow
self.timeout = timeout_ms / 1000.0
self.log = log
self.on_input = on_input
self._stop = threading.Event()
self._last_packet = 0.0
self._connected_address: str | None = None
@ -38,7 +43,7 @@ class Receiver:
def _broadcast_discovery(self) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as beacon:
beacon.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
while not self._stop.wait(1.0):
while not self._stop.is_set():
try:
beacon.sendto(
DISCOVERY_MAGIC, ("255.255.255.255", self.discovery_port)
@ -46,6 +51,7 @@ class Receiver:
except OSError:
# Interfaces can disappear during sleep/network changes.
pass
self._stop.wait(1.0)
def run(self) -> None:
discovery = threading.Thread(
@ -55,9 +61,9 @@ class Receiver:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as listener:
listener.bind((self.bind, self.port))
listener.settimeout(0.05)
print(
self.log(
f"正在监听 {self.bind}:{self.port},后端: {self.backend.name}\n"
"等待 PSVitaCtrl+C 退出)..."
"等待 PSVita..."
)
try:
while not self._stop.is_set():
@ -66,6 +72,8 @@ class Receiver:
finally:
self._stop.set()
discovery.join(timeout=1.2)
if self.on_input is not None:
self.on_input(InputState.neutral())
self.backend.close()
def _receive_once(self, listener: socket.socket) -> None:
@ -82,7 +90,7 @@ class Receiver:
if self._connected_address != address[0]:
self._connected_address = address[0]
self._last_sequence = None
print(f"已连接 PSVita: {address[0]}")
self.log(f"已连接 PSVita: {address[0]}")
if self._last_sequence is not None and not sequence_is_newer(
state.sequence, self._last_sequence
):
@ -91,6 +99,8 @@ class Receiver:
self._last_packet = time.monotonic()
self._neutral_sent = False
self.backend.update(state)
if self.on_input is not None:
self.on_input(state)
def _apply_failsafe(self) -> None:
if (
@ -98,9 +108,16 @@ class Receiver:
and time.monotonic() - self._last_packet >= self.timeout
):
self.backend.update(InputState.neutral())
if self.on_input is not None:
self.on_input(InputState.neutral())
self._neutral_sent = True
print("连接超时,已释放全部按键;等待重连...")
self.log("连接超时,已释放全部按键;等待重连...")
def stop(self) -> None:
self._stop.set()
@property
def connected_address(self) -> str | None:
if self._neutral_sent:
return None
return self._connected_address