Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/DeviceConnections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 通用设备连接

**状态:已实施。** 串口协议和桥接协议分开注册;地址、设备列表和来源占用不依赖固件名称。

## 选择传输

| 传输方式 | 地址示例 | 连接行为 |
|---|---|---|
| `Serial` | `COM3`、`/dev/ttyUSB0` | 在同一串口按注册顺序探测协议,失败后关闭连接再尝试下一项 |
| `TcpBridge` | `tcp://127.0.0.1:47800`、`tcp://[::1]:31789` | 直接访问指定桥接服务;不尝试打开串口 |

`ECDevice.GetPortNames()` 只枚举真实串口,自动连接也只扫描这些端口。桥接地址由用户配置,
GUI 在设备列表中保留该选择;「刷新」不会把它标记成丢失的串口。服务是否可用在连接时确认。

默认串口顺序为原生 EasyCon 协议 115200、9600,再尝试 pokeldn-pad 协议 921600。
默认桥接适配器实现 `pokeldn.pad.service` 的 JSON 行协议。`tcp://` 只表示传输地址,
不意味着任意 TCP / 蓝牙服务均可互通;采用其他消息格式的服务需要注册相应的协议适配器。
蓝牙扫描、权限、配对和链路维护由桥接服务负责。

## 配置桥接地址

GUI 使用现有的 `controller_profile`。Windows 上先退出 EasyCon,再编辑
`%APPDATA%\easycon\config.json` 中的这一项,保留其他设置:

```json
{
"controller_profile": {
"Endpoint": "tcp://127.0.0.1:47800",
"Transport": "TcpBridge"
}
}
```

重启后在设备列表中选择该地址,点击连接。切换到普通串口并保存时,`Transport` 会更新为 `Serial`。
其他平台的配置路径由 `AppPaths.ConfigFile` 确定(用户 ApplicationData 目录下的 `easycon/config.json`)。
此配置仅设置连接目标,不启动或安装服务。

CLI 可直接指定地址,也可复用同一份配置:

```powershell
ezcon run script.ecs --port COM3
ezcon run script.ecs --port tcp://127.0.0.1:47800
ezcon run script.ecs --profiles profiles.json
```

TCP 地址必须包含主机与 1–65535 的端口,不接受用户名、密码、路径(可有结尾 `/`)、查询或片段。
配置文件的 `Transport` 必须与地址一致;CLI 的显式 `--port` 覆盖会同时更新传输方式,保留采集配置。

## 注册协议

`IConnectionProvider.GetCandidates` 按端点返回连接工厂。`SerialConnectionProvider` 管理
串口协议与波特率,`BridgeConnectionProvider` 管理桥接协议。工厂延迟创建,确保探测失败时
先释放旧句柄;成功后停止探测。

默认注册集中在 `NintendoSwitchConnections.cs`。宿主可通过构造函数提供自己的协议集合,
替换默认注册;例如以下工厂由宿主提供,分别实现自己的串口帧和桥接消息格式:

```csharp
NintendoSwitch CreateController(
Func<string, int, IConnection> serialFactory,
Func<ConnectionEndpoint, IConnection> bridgeFactory) => new(
[
new SerialConnectionProvider([new SerialProtocol(115200, serialFactory)]),
new BridgeConnectionProvider([bridgeFactory])
]);
```

连接实现继承 `IConnection`,承担连接/断开、状态事件、输入编码、写入确认和队列清理。
连接成功须上报 `Status.Connected`;`WriteReport` 接收通用 `SwitchReport`,由适配器编码。
`BytesSent` 的报告事件保留 EasyCon 打包 HID 作为确认载荷,以便 `ResetAndWaitForWriteAsync`
验证中立报告已写出;该确认不代表 NS / 游戏已处理输入。
仅支持在线输入的协议应声明 `SupportsLegacyCommands = false`,拒绝离线烧录等原生专有命令。

扩展协议不需要在 Core、GUI 或 CLI 添加固件名称分支。`NintendoSwitch.CreateConnection`
和已有的 `CreatePokeldnConnection` 测试接缝仍保留,便于复用现有的无硬件测试。

## 端点与独占

`ConnectionEndpoint` 同时提供实际连接地址和来源占用键;`PadSession` 在打开设备前取得
`SourceOwnership`,用于同机 EasyCon 会话的进程间互斥:

- 去掉首尾空白;Windows 的 `COM3`、`com3`、`\\.\COM3` 使用相同串口标识。
- Unix 的绝对串口路径解析符号链接,避免 `/dev/serial/by-id/...` 与目标路径重复占用。
- TCP 统一主机名大小写、结尾斜杠和等价 IPv6 表示;相同端点的这些写法共享占用锁。

地址规范化不执行 DNS 查询,也不合并不同主机名或 IPv4 / IPv6 监听地址。多个客户端应使用
同一服务地址;第三方客户端或多个桥接服务访问同一蓝牙板的互斥仍需桥接端负责。

## 验证边界

`ConnectionProviderTests` 覆盖延迟探测、失败释放、注册自定义协议、串口与桥接隔离和地址校验。
`PokeldnServiceTests` 通过真实本机 TCP 与模拟服务核对请求/响应。
Core / GUI 测试覆盖别名锁、配置读写、CLI 覆盖及显式桥接选择。
这些测试不依赖实物,也不替代板子和 NS 的验收;两种 ESP32 的接线、固件与实测边界见
[pokeldn 适配说明](Pokeldn.md)。
41 changes: 28 additions & 13 deletions docs/Pokeldn.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
**状态:已实施;协议及模拟设备测试通过。用户已确认早期适配构建的 ESP32E 连接和控制可用;
本次对齐上游后的构建及 S3 实机验证待完成。**

实现基于 EasyConNS/EasyCon `dev` 提交 `8787f0bda441f85848ad0f978f780d6ebd82c93e`,
在已有的实验性 `PokeldnSerialClient` 上补充可靠通信和 S3 服务连接。核对的 pokeldn
本次连接层整理基于 EasyConNS/EasyCon `dev` 提交 `26538600fce7fa3269744a17e23bb59ce3192ca7`,
在已合并的适配上分开注册通用串口与桥接协议。核对的 pokeldn
提交为 `30cbb0a47541a4293ca92152838b03cd7c8b9615`,控制器固件版本 **1.3.0**(2026-10-30)。
适配发生在 EasyCon 电脑端,使用上游原版固件,无需重编译 ESP32 固件。

Expand All @@ -18,7 +18,7 @@ pokeldn 有两个独立的固件项目:
| 板子 | 应刷固件 | 电脑 → 板子 | 板子 → NS | EasyCon 入口 |
|---|---|---|---|---|
| 普通 ESP32 / WROOM-32 | `pokeldn-pad.bin` | USB 转串口,921600、8N1 | Bluetooth Classic Pro Controller | 对应 COM/tty 端口 |
| ESP32-S3 | `pokeldn-pad-s3.bin` | 电脑 BLE,经 pokeldn 本地手柄服务 | 原生 USB,HORI HID | `pokeldn (BLE 服务)` |
| ESP32-S3 | `pokeldn-pad-s3.bin` | 电脑 BLE,经 pokeldn 本地手柄服务 | 原生 USB,HORI HID | 服务的 `tcp://主机:端口` 地址 |
| ESP32-C3 / C6 | 不支持手柄固件 | — | 缺少上述手柄所需的硬件组合 | — |

S3 本次通过**官方 pokeldn 服务**桥接 BLE,并非 EasyCon 直接调用操作系统蓝牙 API。
Expand All @@ -34,11 +34,11 @@ S3 本次通过**官方 pokeldn 服务**桥接 BLE,并非 EasyCon 直接调用
5. 确认日志出现 `pokeldn-pad (串口) 1.3.0`,然后使用虚拟手柄、键盘映射或电脑端脚本。

普通端口按原有 `115200 → 9600` 探测 EasyCon 固件,失败后再探测 `921600` 的 pokeldn-pad。
已知是 pokeldn 时,CLI/API 可显式指定 `pokeldn:COM3` 或 `pokeldn:/dev/ttyUSB0`,跳过旧协议探测。
串口地址直接使用 `COM3` 或 `/dev/ttyUSB0`;协议由设备层的注册项探测,不使用固件专用前缀。
开口可能触发开发板复位,握手会在约 3 秒内重试状态查询。连接后保持串口打开,不按每个按键重开。

```powershell
ezcon run script.ecs --port pokeldn:COM3
ezcon run script.ecs --port COM3
```

## ESP32-S3 使用步骤
Expand All @@ -48,7 +48,9 @@ ezcon run script.ecs --port pokeldn:COM3
控制固件占用此接口作为 HID,不能将其当作电脑串口使用。
3. 开启电脑蓝牙,在 pokeldn **Control** 页面连接 `POKELDN-PAD`,保持 pokeldn 运行。
若同时插着普通 ESP32,先在 pokeldn 中确认连接的是目标 S3;官方服务会优先尝试串口板。
4. EasyCon 设备下拉框选择 **pokeldn (BLE 服务)**,点击连接。
4. 在 EasyCon 的 `controller_profile` 配置中填写桥接地址 `tcp://127.0.0.1:47800`、
传输方式 `TcpBridge`,重启后从设备下拉框选择该地址并连接。完整配置见
[通用设备连接说明](DeviceConnections.md#配置桥接地址)。
5. 使用 EasyCon 控制。连接时会停止板上已有宏并释放按键;不要同时在 pokeldn Control 页面发按键或启动宏。

也可在已配置依赖的 pokeldn 源码环境启动官方服务:
Expand All @@ -60,13 +62,19 @@ python -m pokeldn.pad.service
然后在 EasyCon CLI 中使用:

```powershell
ezcon run script.ecs --port pokeldn://service
ezcon run script.ecs --port tcp://127.0.0.1:47800
```

服务固定为本机 `127.0.0.1:47800`,无需开放防火墙端口。EasyCon 不会自动启动、下载或退出 pokeldn
进程。EasyCon 断开时发送释放和 `disconnect`,服务进程继续运行。
上游服务默认监听本机 `127.0.0.1:47800`,该地址仅是配置示例,EasyCon 不固定服务端口。
设备扫描只枚举物理串口,桥接地址由用户显式配置;不会为未安装的服务添加固定列表项。
EasyCon 不会自动启动、下载或退出 pokeldn 进程。EasyCon 断开时发送释放和 `disconnect`,
服务进程继续运行。
macOS 的 BLE 权限和服务启动方式沿用 pokeldn 官方应用的设置。

从早期适配迁移:`pokeldn:COM3` / `pokeldn:/dev/ttyUSB0` 改为 `COM3` / `/dev/ttyUSB0`;
`pokeldn://service` 和 `pokeldn (BLE 服务)` 改为上述 TCP 地址,并将配置的 `Transport` 改为 `TcpBridge`。
旧的固件专用地址不再解析。

## 已适配的能力与边界

- A/B/X/Y、L/R/ZL/ZR、加减、左右摇杆按压、HOME、截屏、八向十字键和双摇杆全范围。
Expand Down Expand Up @@ -96,7 +104,13 @@ macOS 的 BLE 权限和服务启动方式沿用 pokeldn 官方应用的设置。
新增 `IConnection.WriteReport` 让连接实现选择编码;旧连接继续使用原有序列化。
保留 `PokeldnSerialClient` 的公开入口、翻译方法和 `CreatePokeldnConnection` 测试接缝。
`BytesSent` 在收到固件/服务 ACK 后回传原来的 7 位打包 HID,保持 Maa 会话确认中立报告的事件契约;
这不代表 NS 或游戏已经处理输入。串口别名及服务的显示名/URI 共用同一个来源占用锁。
这不代表 NS 或游戏已经处理输入。端点规范化和来源占用锁由通用 `ConnectionEndpoint` 负责,
覆盖串口路径别名、TCP 主机名大小写、结尾斜杠和 IPv6 的等价写法,不依赖固件名称。

串口与桥接分别通过 `SerialConnectionProvider` / `BridgeConnectionProvider` 注册。
`NintendoSwitchConnections.cs` 组合默认协议,Core、GUI、CLI 仅处理传输地址和连接能力。
桥接目前默认注册的是官方服务的 JSON 行协议适配器;其他蓝牙桥接服务需要实现对应协议,
不能仅凭使用 TCP 就自动兼容。扩展方式见 [通用设备连接说明](DeviceConnections.md)。

普通 ESP32 的 UART 帧:

Expand Down Expand Up @@ -137,8 +151,8 @@ S3 的 BLE 服务 UUID 为 `7a1e0001-5d2c-4c3e-9f4b-504f4b454c44`,末段 `0002

## 验证

2026-10-30 本机验证:设备协议测试 **51/51**,新增 Maa 会话集成测试 **4/4**;
Release 全项目测试 **1,883 通过、182 跳过、0 失败**,另有 29 项显式测试未选择运行。
2026-11-01 本机验证:设备协议与连接注册测试 **65/65**,Maa 适配集成测试 **4/4**;
Release 全项目测试 **1,904 通过、182 跳过、0 失败**,另有 29 项显式测试未选择运行。
Release 构建及 `dotnet format --verify-no-changes` 通过。
跳过项主要依赖本机未安装的 C99 编译器、OCR 模型/资料,以及非 Windows 平台功能。
这些结果不替代 NS 实机验证。
Expand All @@ -151,7 +165,8 @@ dotnet format --verify-no-changes

设备测试覆盖固定报文、14 个按钮位、9 个 HAT 值、摇杆边界、逐字节分片、粘包、噪声/坏校验、
启动重试、负 ACK/超时/取消、真实设备队列输出、Reset/断开释放、空闲掉线、旧协议回归、
能力拒绝,以及真实本机 TCP 上的官方服务请求/响应契约。
能力拒绝,以及真实本机 TCP 上的官方服务请求/响应契约。另覆盖自定义协议注册、串口与桥接隔离、
地址校验和别名独占、GUI/CLI 配置读写及桥接选择刷新。

实机验收:分别检查两种板的 A 点按、组合键、十字键、摇杆边界、持续按下后停止脚本、主动断开、
拔插重连,以及 S3 关闭 pokeldn 服务后能否在 EasyCon 观察到断线。首次配对和每种 NS 型号单独记录。
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
| 文档 | 内容 |
|---|---|
| [GETTING_STARTED.md](GETTING_STARTED.md) | 安装、GUI/CLI 运行脚本、采集画面来源(含 OBS 虚拟摄像头) |
| [DeviceConnections.md](DeviceConnections.md) | 通用串口协议注册、TCP / 蓝牙桥接配置、协议扩展与端点占用规则 |
| [Pokeldn.md](Pokeldn.md) | pokeldn-pad 固件分析、ESP32 串口 / ESP32-S3 BLE 服务连接、能力范围与验证 |
| [Script.md](Script.md) | ECS 脚本语言完整语法:变量/常量/类型/流程控制/函数/结构体/数组/按键语句/识图标签/IMPORT |
| [Functions.md](Functions.md) | 内置函数手册(按功能域;速查表含桌面/单片机可用性) |
Expand Down
26 changes: 23 additions & 3 deletions src/EasyCon.Core/Config/HostProfiles.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
#nullable enable
using EasyCon.Capture;
using EasyDevice.Connection;
using OpenCvSharp;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace EasyCon.Core.Config;

public sealed record ControllerProfile(string? Endpoint = null, string Transport = "Serial");
public sealed record ControllerProfile(string? Endpoint = null, string Transport = "Serial")
{
public static ControllerProfile FromEndpoint(string? endpoint) => new(endpoint,
endpoint == null ? nameof(ConnectionTransport.Serial) : ConnectionEndpoint.Parse(endpoint).Transport.ToString());

public ControllerProfile Validate()
{
if (!Enum.GetNames<ConnectionTransport>().Contains(Transport, StringComparer.Ordinal))
throw new InvalidDataException($"不支持的手柄传输方式 {Transport}");
if (Endpoint != null)
{
ConnectionEndpoint target;
try { target = ConnectionEndpoint.Parse(Endpoint); }
catch (ArgumentException ex) { throw new InvalidDataException(ex.Message, ex); }
if (target.Transport.ToString() != Transport)
throw new InvalidDataException("手柄地址与 profile 传输方式不一致");
}
return this;
}
}
public sealed record CaptureProfile(CaptureSourceDescriptor? Source = null, int? Index = null,
string Backend = "ANY", FrameNormalization? Normalization = null, string? DisplayKey = null)
{
Expand Down Expand Up @@ -49,7 +69,7 @@ public HostProfiles WithOverrides(string? port = null, int? captureIndex = null,
CaptureSourceCatalog.ResolveBackend(ParseCaptureBackend(Capture?.Backend ?? "ANY"));
HostProfiles result = this with
{
Controller = port == null ? Controller : (Controller ?? new()) with { Endpoint = port },
Controller = port == null ? Controller : ControllerProfile.FromEndpoint(port),
Capture = captureIndex == null && backend == null ? Capture : (Capture ?? new()) with
{
Source = captureIndex != null || backendChanged ? null : Capture?.Source,
Expand All @@ -63,7 +83,7 @@ public HostProfiles WithOverrides(string? port = null, int? captureIndex = null,

public HostProfiles Validate()
{
if (Controller is { Transport: not "Serial" }) throw new InvalidDataException("仅支持 Serial 手柄配置");
Controller?.Validate();
Capture?.Validate();
return this;
}
Expand Down
2 changes: 1 addition & 1 deletion src/EasyCon.Core/ECCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public partial class ECCore

public static IEnumerable<(string, int)> GetCaptureTypes() => ECCapture.GetCaptureTypes();

public static List<string> GetDeviceNames() => EasyDevice.ECDevice.GetConnectionNames();
public static List<string> GetDeviceNames() => EasyDevice.ECDevice.GetPortNames();

public static IEnumerable<SearchMethod> GetSearchMethods() => ECSearch.GetEnableSearchMethods();

Expand Down
24 changes: 3 additions & 21 deletions src/EasyCon.Core/Input/PadSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,9 @@ public ControllerReply Execute(ControllerCommand command, InputOwner owner, Canc
_ownership?.Dispose();
_ownership = null;
string port = command.Port ?? throw new ControllerOperationException("InvalidPort", "缺少通信端口");
string endpoint = NormalizeEndpoint(port);
string ownershipEndpoint = endpoint.StartsWith("pokeldn:", StringComparison.Ordinal)
&& endpoint != ECDevice.PokeldnServiceEndpoint ? endpoint["pokeldn:".Length..] : endpoint;
try { _ownership = new SourceOwnership("serial:" + ownershipEndpoint); }
ConnectionEndpoint target = ConnectionEndpoint.Parse(port);
string endpoint = target.Address;
try { _ownership = new SourceOwnership(target.OwnershipKey); }
catch (IOException ex) { throw new ControllerOperationException("Busy", ex.Message); }
try
{
Expand Down Expand Up @@ -203,23 +202,6 @@ public bool StandardKey(InputOwner owner, int code, bool down)
}
}

private static string NormalizeEndpoint(string port)
{
string endpoint = port.Trim();
if (endpoint == ECDevice.PokeldnServiceName || endpoint.Equals(ECDevice.PokeldnServiceEndpoint, StringComparison.OrdinalIgnoreCase))
return ECDevice.PokeldnServiceEndpoint;
if (endpoint.StartsWith("pokeldn:", StringComparison.OrdinalIgnoreCase))
return "pokeldn:" + NormalizeEndpoint(endpoint["pokeldn:".Length..]);
if (OperatingSystem.IsWindows())
{
endpoint = endpoint.StartsWith(@"\\.\", StringComparison.Ordinal) ? endpoint[4..] : endpoint;
return endpoint.StartsWith("COM", StringComparison.OrdinalIgnoreCase) && endpoint.Length > 3 && endpoint[3..].All(char.IsAsciiDigit)
? endpoint.ToUpperInvariant() : endpoint;
}
if (Path.IsPathRooted(endpoint))
return new FileInfo(endpoint).ResolveLinkTarget(true)?.FullName ?? Path.GetFullPath(endpoint);
return endpoint;
}
private static ECKey RequiredKey(ControllerCommand command) => command.Key?.ToKey() ?? throw new ControllerOperationException("InvalidKey", "缺少按键");
private static byte[] RequiredData(ControllerCommand command) => command.Data ?? throw new ControllerOperationException("InvalidData", "缺少数据");
private static void ValidateDirection(DirectionKey direction)
Expand Down
Loading
Loading