feat: add desktop dashboard and Windows setup
This commit is contained in:
parent
8a16d2e1f1
commit
bb165a0af2
|
|
@ -1,7 +1,10 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
.build-venv/
|
||||||
|
.idea/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
vita/build/
|
vita/build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|
|
||||||
116
README.md
116
README.md
|
|
@ -12,7 +12,7 @@ macOS 游戏手柄。
|
||||||
- 前触摸板底部左右角模拟 L3/R3
|
- 前触摸板底部左右角模拟 L3/R3
|
||||||
- 断线 300 ms 后自动释放全部按键,避免卡键
|
- 断线 300 ms 后自动释放全部按键,避免卡键
|
||||||
- Windows 输出标准 Xbox 360/XInput 虚拟手柄
|
- Windows 输出标准 Xbox 360/XInput 虚拟手柄
|
||||||
- macOS 输出通用 HID 游戏手柄
|
- macOS 提供实验性通用 HID 后端;新版系统可能受 entitlement 限制
|
||||||
|
|
||||||
## 按键映射
|
## 按键映射
|
||||||
|
|
||||||
|
|
@ -33,21 +33,86 @@ macOS 游戏手柄。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
1. 安装 [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases)。
|
支持 Windows 10/11,电脑端会创建标准 Xbox 360/XInput 虚拟手柄。
|
||||||
2. 在 PowerShell 中运行:
|
|
||||||
|
#### 1. 安装虚拟手柄驱动
|
||||||
|
|
||||||
|
从 [ViGEmBus 官方 Releases](https://github.com/nefarius/ViGEmBus/releases)
|
||||||
|
下载并安装 `ViGEmBus Setup 1.22.0`。该项目已经归档,因此只建议从官方仓库
|
||||||
|
下载签名驱动,不要使用第三方重新打包版本。安装完成后建议重启一次 Windows。
|
||||||
|
|
||||||
|
#### 2. 安装 Vita Gamepad 电脑端
|
||||||
|
|
||||||
|
打开 PowerShell,进入本项目目录后运行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
py -m venv .venv
|
py -3 -m venv .venv
|
||||||
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
pip install -e ".[windows]"
|
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
|
vitapad
|
||||||
```
|
```
|
||||||
|
|
||||||
首次运行时允许 Windows 防火墙的“专用网络”访问。程序创建的是 Xbox 360
|
只测试 Vita 网络和按键,不创建 Xbox 虚拟手柄:
|
||||||
控制器,Steam、Xbox 应用和绝大多数 Windows 游戏都能直接识别。
|
|
||||||
|
```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
|
### macOS
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> macOS 没有正式、稳定的系统级虚拟游戏手柄 API。当前实验性
|
||||||
|
> `IOHIDUserDevice` 后端在旧版系统上可尝试通过管理员权限运行,但在
|
||||||
|
> macOS 26 等新版系统上可能仍被 entitlement/签名策略拒绝。控制面板和实时
|
||||||
|
> 按键测试可以使用 `--backend debug`,但该模式不会向游戏创建虚拟手柄。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
@ -55,10 +120,39 @@ pip install -e .
|
||||||
sudo .venv/bin/vitapad
|
sudo .venv/bin/vitapad
|
||||||
```
|
```
|
||||||
|
|
||||||
macOS 后端通过 `IOHIDUserDevice` 创建通用 HID 手柄,通常需要管理员权限。
|
macOS 后端通过 `IOHIDUserDevice` 尝试创建通用 HID 手柄,需要管理员权限,
|
||||||
Apple 并未把用户态虚拟游戏手柄作为稳定的 Game Controller API 保证;个别只
|
且不保证能被当前系统或游戏接受。
|
||||||
接受 Apple Game Controller Framework 设备的游戏可能不会显示它。Steam 游戏可
|
|
||||||
在“控制器”设置中启用 Steam Input 以提高兼容性。
|
使用预编译的 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
|
```bash
|
||||||
cd vita
|
cd vita
|
||||||
|
python3 assets/generate_assets.py # 仅在需要重新生成 LiveArea 图片时运行
|
||||||
cmake -S . -B build
|
cmake -S . -B build
|
||||||
cmake --build build
|
cmake --build build
|
||||||
```
|
```
|
||||||
|
|
@ -104,4 +199,3 @@ Vita 界面显示电脑 IP 和已发送包数。按住 `Start + Select` 两秒
|
||||||
```bash
|
```bash
|
||||||
python -m unittest discover -s tests -v
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,69 @@
|
||||||
3. 提交项目全部源码、测试与文档。
|
3. 提交项目全部源码、测试与文档。
|
||||||
4. 配置远程地址并推送 main 分支。
|
4. 配置远程地址并推送 main 分支。
|
||||||
- **执行结果**: Vita Gamepad 项目已提交并推送至指定仓库。
|
- **执行结果**: 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 打包入口已加入,项目功能与文档已提交至远程仓库。
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "vita-gamepad"
|
name = "vita-gamepad"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
description = "Use a PlayStation Vita as a low-latency network gamepad"
|
description = "Use a PlayStation Vita as a low-latency network gamepad"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
@ -17,7 +17,7 @@ windows = ["vgamepad>=0.1.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
vitapad = "vitapad.cli:main"
|
vitapad = "vitapad.cli:main"
|
||||||
|
vitapad-gui = "vitapad.gui:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["vitapad*"]
|
include = ["vitapad*"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -30,6 +30,7 @@ def unused_udp_port() -> int:
|
||||||
class ReceiverTests(unittest.TestCase):
|
class ReceiverTests(unittest.TestCase):
|
||||||
def test_receive_and_failsafe(self) -> None:
|
def test_receive_and_failsafe(self) -> None:
|
||||||
backend = FakeBackend()
|
backend = FakeBackend()
|
||||||
|
input_events: list[InputState] = []
|
||||||
port = unused_udp_port()
|
port = unused_udp_port()
|
||||||
receiver = Receiver(
|
receiver = Receiver(
|
||||||
backend,
|
backend,
|
||||||
|
|
@ -37,6 +38,7 @@ class ReceiverTests(unittest.TestCase):
|
||||||
port=port,
|
port=port,
|
||||||
discovery_port=unused_udp_port(),
|
discovery_port=unused_udp_port(),
|
||||||
timeout_ms=50,
|
timeout_ms=50,
|
||||||
|
on_input=input_events.append,
|
||||||
)
|
)
|
||||||
thread = threading.Thread(target=receiver.run)
|
thread = threading.Thread(target=receiver.run)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
@ -51,6 +53,8 @@ class ReceiverTests(unittest.TestCase):
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
self.assertEqual(backend.states[0], state)
|
self.assertEqual(backend.states[0], state)
|
||||||
self.assertEqual(backend.states[1], InputState.neutral())
|
self.assertEqual(backend.states[1], InputState.neutral())
|
||||||
|
self.assertEqual(input_events[0], state)
|
||||||
|
self.assertEqual(input_events[1], InputState.neutral())
|
||||||
finally:
|
finally:
|
||||||
receiver.stop()
|
receiver.stop()
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=2)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ include("${VITASDK}/share/vita.cmake" REQUIRED)
|
||||||
|
|
||||||
set(VITA_APP_NAME "Vita Gamepad")
|
set(VITA_APP_NAME "Vita Gamepad")
|
||||||
set(VITA_TITLEID "VITAPAD01")
|
set(VITA_TITLEID "VITAPAD01")
|
||||||
set(VITA_VERSION "01.00")
|
set(VITA_VERSION "01.01")
|
||||||
|
|
||||||
add_executable(vita-gamepad src/main.c)
|
add_executable(vita-gamepad src/main.c)
|
||||||
target_compile_options(vita-gamepad PRIVATE -Wall -Wextra -Werror -O2)
|
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
|
vita_create_vpk(vita-gamepad.vpk ${VITA_TITLEID} vita-gamepad.self
|
||||||
VERSION ${VITA_VERSION}
|
VERSION ${VITA_VERSION}
|
||||||
NAME ${VITA_APP_NAME}
|
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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
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 |
|
|
@ -56,31 +56,6 @@ _Static_assert(sizeof(InputPacket) == 20, "InputPacket must be exactly 20 bytes"
|
||||||
static uint8_t net_memory[1024 * 1024];
|
static uint8_t net_memory[1024 * 1024];
|
||||||
static volatile bool exiting = false;
|
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) {
|
static int init_network(void) {
|
||||||
int result = sceSysmoduleLoadModule(SCE_SYSMODULE_NET);
|
int result = sceSysmoduleLoadModule(SCE_SYSMODULE_NET);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
|
|
@ -264,7 +239,6 @@ static void draw_ui(
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
setup_callbacks();
|
|
||||||
sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG_WIDE);
|
sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG_WIDE);
|
||||||
sceTouchSetSamplingState(SCE_TOUCH_PORT_FRONT, SCE_TOUCH_SAMPLING_STATE_START);
|
sceTouchSetSamplingState(SCE_TOUCH_PORT_FRONT, SCE_TOUCH_SAMPLING_STATE_START);
|
||||||
sceTouchSetSamplingState(SCE_TOUCH_PORT_BACK, SCE_TOUCH_SAMPLING_STATE_START);
|
sceTouchSetSamplingState(SCE_TOUCH_PORT_BACK, SCE_TOUCH_SAMPLING_STATE_START);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
"""Vita Gamepad desktop receiver."""
|
"""Vita Gamepad desktop receiver."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from vitapad.backends import GamepadBackend
|
from vitapad.backends import GamepadBackend
|
||||||
from vitapad.protocol import (
|
from vitapad.protocol import (
|
||||||
|
|
@ -22,6 +23,8 @@ class Receiver:
|
||||||
discovery_port: int = 5001,
|
discovery_port: int = 5001,
|
||||||
allow: str | None = None,
|
allow: str | None = None,
|
||||||
timeout_ms: int = 300,
|
timeout_ms: int = 300,
|
||||||
|
log: Callable[[str], None] = print,
|
||||||
|
on_input: Callable[[InputState], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.bind = bind
|
self.bind = bind
|
||||||
|
|
@ -29,6 +32,8 @@ class Receiver:
|
||||||
self.discovery_port = discovery_port
|
self.discovery_port = discovery_port
|
||||||
self.allow = allow
|
self.allow = allow
|
||||||
self.timeout = timeout_ms / 1000.0
|
self.timeout = timeout_ms / 1000.0
|
||||||
|
self.log = log
|
||||||
|
self.on_input = on_input
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._last_packet = 0.0
|
self._last_packet = 0.0
|
||||||
self._connected_address: str | None = None
|
self._connected_address: str | None = None
|
||||||
|
|
@ -38,7 +43,7 @@ class Receiver:
|
||||||
def _broadcast_discovery(self) -> None:
|
def _broadcast_discovery(self) -> None:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as beacon:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as beacon:
|
||||||
beacon.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
beacon.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
while not self._stop.wait(1.0):
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
beacon.sendto(
|
beacon.sendto(
|
||||||
DISCOVERY_MAGIC, ("255.255.255.255", self.discovery_port)
|
DISCOVERY_MAGIC, ("255.255.255.255", self.discovery_port)
|
||||||
|
|
@ -46,6 +51,7 @@ class Receiver:
|
||||||
except OSError:
|
except OSError:
|
||||||
# Interfaces can disappear during sleep/network changes.
|
# Interfaces can disappear during sleep/network changes.
|
||||||
pass
|
pass
|
||||||
|
self._stop.wait(1.0)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
discovery = threading.Thread(
|
discovery = threading.Thread(
|
||||||
|
|
@ -55,9 +61,9 @@ class Receiver:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as listener:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as listener:
|
||||||
listener.bind((self.bind, self.port))
|
listener.bind((self.bind, self.port))
|
||||||
listener.settimeout(0.05)
|
listener.settimeout(0.05)
|
||||||
print(
|
self.log(
|
||||||
f"正在监听 {self.bind}:{self.port},后端: {self.backend.name}\n"
|
f"正在监听 {self.bind}:{self.port},后端: {self.backend.name}\n"
|
||||||
"等待 PSVita(Ctrl+C 退出)..."
|
"等待 PSVita..."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
|
|
@ -66,6 +72,8 @@ class Receiver:
|
||||||
finally:
|
finally:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
discovery.join(timeout=1.2)
|
discovery.join(timeout=1.2)
|
||||||
|
if self.on_input is not None:
|
||||||
|
self.on_input(InputState.neutral())
|
||||||
self.backend.close()
|
self.backend.close()
|
||||||
|
|
||||||
def _receive_once(self, listener: socket.socket) -> None:
|
def _receive_once(self, listener: socket.socket) -> None:
|
||||||
|
|
@ -82,7 +90,7 @@ class Receiver:
|
||||||
if self._connected_address != address[0]:
|
if self._connected_address != address[0]:
|
||||||
self._connected_address = address[0]
|
self._connected_address = address[0]
|
||||||
self._last_sequence = None
|
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(
|
if self._last_sequence is not None and not sequence_is_newer(
|
||||||
state.sequence, self._last_sequence
|
state.sequence, self._last_sequence
|
||||||
):
|
):
|
||||||
|
|
@ -91,6 +99,8 @@ class Receiver:
|
||||||
self._last_packet = time.monotonic()
|
self._last_packet = time.monotonic()
|
||||||
self._neutral_sent = False
|
self._neutral_sent = False
|
||||||
self.backend.update(state)
|
self.backend.update(state)
|
||||||
|
if self.on_input is not None:
|
||||||
|
self.on_input(state)
|
||||||
|
|
||||||
def _apply_failsafe(self) -> None:
|
def _apply_failsafe(self) -> None:
|
||||||
if (
|
if (
|
||||||
|
|
@ -98,9 +108,16 @@ class Receiver:
|
||||||
and time.monotonic() - self._last_packet >= self.timeout
|
and time.monotonic() - self._last_packet >= self.timeout
|
||||||
):
|
):
|
||||||
self.backend.update(InputState.neutral())
|
self.backend.update(InputState.neutral())
|
||||||
|
if self.on_input is not None:
|
||||||
|
self.on_input(InputState.neutral())
|
||||||
self._neutral_sent = True
|
self._neutral_sent = True
|
||||||
print("连接超时,已释放全部按键;等待重连...")
|
self.log("连接超时,已释放全部按键;等待重连...")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected_address(self) -> str | None:
|
||||||
|
if self._neutral_sent:
|
||||||
|
return None
|
||||||
|
return self._connected_address
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue