diff --git a/docs/DeviceConnections.md b/docs/DeviceConnections.md new file mode 100644 index 00000000..f23ab927 --- /dev/null +++ b/docs/DeviceConnections.md @@ -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 serialFactory, + Func 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)。 diff --git a/docs/Pokeldn.md b/docs/Pokeldn.md index 10c3d7b2..2fe1dfa3 100644 --- a/docs/Pokeldn.md +++ b/docs/Pokeldn.md @@ -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 固件。 @@ -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。 @@ -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 使用步骤 @@ -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 源码环境启动官方服务: @@ -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、截屏、八向十字键和双摇杆全范围。 @@ -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 帧: @@ -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 实机验证。 @@ -151,7 +165,8 @@ dotnet format --verify-no-changes 设备测试覆盖固定报文、14 个按钮位、9 个 HAT 值、摇杆边界、逐字节分片、粘包、噪声/坏校验、 启动重试、负 ACK/超时/取消、真实设备队列输出、Reset/断开释放、空闲掉线、旧协议回归、 -能力拒绝,以及真实本机 TCP 上的官方服务请求/响应契约。 +能力拒绝,以及真实本机 TCP 上的官方服务请求/响应契约。另覆盖自定义协议注册、串口与桥接隔离、 +地址校验和别名独占、GUI/CLI 配置读写及桥接选择刷新。 实机验收:分别检查两种板的 A 点按、组合键、十字键、摇杆边界、持续按下后停止脚本、主动断开、 拔插重连,以及 S3 关闭 pokeldn 服务后能否在 EasyCon 观察到断线。首次配对和每种 NS 型号单独记录。 diff --git a/docs/README.md b/docs/README.md index 29e958b8..c1b2d906 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | 内置函数手册(按功能域;速查表含桌面/单片机可用性) | diff --git a/src/EasyCon.Core/Config/HostProfiles.cs b/src/EasyCon.Core/Config/HostProfiles.cs index 7db94e09..53b4018f 100644 --- a/src/EasyCon.Core/Config/HostProfiles.cs +++ b/src/EasyCon.Core/Config/HostProfiles.cs @@ -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().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) { @@ -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, @@ -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; } diff --git a/src/EasyCon.Core/ECCore.cs b/src/EasyCon.Core/ECCore.cs index 529419ee..f14d8254 100644 --- a/src/EasyCon.Core/ECCore.cs +++ b/src/EasyCon.Core/ECCore.cs @@ -10,7 +10,7 @@ public partial class ECCore public static IEnumerable<(string, int)> GetCaptureTypes() => ECCapture.GetCaptureTypes(); - public static List GetDeviceNames() => EasyDevice.ECDevice.GetConnectionNames(); + public static List GetDeviceNames() => EasyDevice.ECDevice.GetPortNames(); public static IEnumerable GetSearchMethods() => ECSearch.GetEnableSearchMethods(); diff --git a/src/EasyCon.Core/Input/PadSession.cs b/src/EasyCon.Core/Input/PadSession.cs index bb6b3f0c..63ae7153 100644 --- a/src/EasyCon.Core/Input/PadSession.cs +++ b/src/EasyCon.Core/Input/PadSession.cs @@ -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 { @@ -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) diff --git a/src/EasyCon.Device/Connection/ConnectionEndpoint.cs b/src/EasyCon.Device/Connection/ConnectionEndpoint.cs new file mode 100644 index 00000000..4f8288d3 --- /dev/null +++ b/src/EasyCon.Device/Connection/ConnectionEndpoint.cs @@ -0,0 +1,56 @@ +using System.Net; + +namespace EasyDevice.Connection; + +public enum ConnectionTransport { Serial, TcpBridge } + +/// 传输地址和资源标识。固件协议不参与地址解析或来源占用锁。 +public sealed record ConnectionEndpoint +{ + private ConnectionEndpoint(ConnectionTransport transport, string address, string? host = null, int? port = null) + { + Transport = transport; + Address = address; + Host = host; + Port = port; + } + + public ConnectionTransport Transport { get; } + public string Address { get; } + public string? Host { get; } + public int? Port { get; } + public string OwnershipKey => Transport == ConnectionTransport.Serial ? "serial:" + Address : Address; + + public static ConnectionEndpoint Parse(string address) + { + ArgumentException.ThrowIfNullOrWhiteSpace(address); + string endpoint = address.Trim(); + if (endpoint.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || uri.Scheme != "tcp" + || uri.Port is < 1 or > 65535 || string.IsNullOrEmpty(uri.Host) + || uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0 + || uri.AbsolutePath is not ("" or "/")) + throw new ArgumentException("桥接地址应为 tcp://主机:端口,不能包含用户信息、路径或参数。", nameof(address)); + string host = uri.DnsSafeHost; + if (IPAddress.TryParse(host, out IPAddress? ip)) + host = ip.ToString(); + else + host = uri.IdnHost.ToLowerInvariant(); + string canonical = new UriBuilder("tcp", host, uri.Port).Uri.AbsoluteUri.TrimEnd('/'); + return new ConnectionEndpoint(ConnectionTransport.TcpBridge, canonical, host, uri.Port); + } + + if (OperatingSystem.IsWindows()) + { + endpoint = endpoint.StartsWith(@"\\.\", StringComparison.Ordinal) ? endpoint[4..] : endpoint; + if (endpoint.StartsWith("COM", StringComparison.OrdinalIgnoreCase) && endpoint.Length > 3 && endpoint[3..].All(char.IsAsciiDigit)) + endpoint = endpoint.ToUpperInvariant(); + } + else if (Path.IsPathRooted(endpoint)) + { + endpoint = new FileInfo(endpoint).ResolveLinkTarget(true)?.FullName ?? Path.GetFullPath(endpoint); + } + return new ConnectionEndpoint(ConnectionTransport.Serial, endpoint); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/IConnectionProvider.cs b/src/EasyCon.Device/Connection/IConnectionProvider.cs new file mode 100644 index 00000000..72316977 --- /dev/null +++ b/src/EasyCon.Device/Connection/IConnectionProvider.cs @@ -0,0 +1,37 @@ +namespace EasyDevice.Connection; + +/// 按传输地址提供候选协议;工厂仅在轮到该候选时调用,避免提前占用硬件。 +public interface IConnectionProvider +{ + IEnumerable> GetCandidates(ConnectionEndpoint endpoint); +} + +public sealed record SerialProtocol(int BaudRate, Func CreateConnection); + +/// 串口的协议/波特率探测顺序由注册项决定,宿主无需识别固件名称。 +public sealed class SerialConnectionProvider(IEnumerable protocols) : IConnectionProvider +{ + private readonly SerialProtocol[] _protocols = [.. protocols]; + + public IEnumerable> GetCandidates(ConnectionEndpoint endpoint) + { + if (endpoint.Transport != ConnectionTransport.Serial) + yield break; + foreach (SerialProtocol protocol in _protocols) + yield return () => protocol.CreateConnection(endpoint.Address, protocol.BaudRate); + } +} + +/// TCP 桥接的协议适配器集合。蓝牙扫描、配对和连接由桥接服务实现。 +public sealed class BridgeConnectionProvider(IEnumerable> protocols) : IConnectionProvider +{ + private readonly Func[] _protocols = [.. protocols]; + + public IEnumerable> GetCandidates(ConnectionEndpoint endpoint) + { + if (endpoint.Transport != ConnectionTransport.TcpBridge) + yield break; + foreach (Func protocol in _protocols) + yield return () => protocol(endpoint); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs b/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs index 64c30dfe..ad176125 100644 --- a/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs +++ b/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs @@ -6,7 +6,7 @@ namespace EasyDevice.Connection.Pokeldn; /// 官方 pokeldn.pad.service 的本机 JSON 行协议,由该服务持有 ESP32-S3 的 BLE 连接。 -internal sealed class PokeldnServiceTransport(int port = 47800) : IPokeldnTransport +internal sealed class PokeldnServiceTransport(string host, int port) : IPokeldnTransport { private readonly TcpClient _client = new() { NoDelay = true }; private StreamReader? _reader; @@ -20,12 +20,12 @@ public PokeldnStatus Connect(CancellationToken token) timeout.CancelAfter(TimeSpan.FromSeconds(2)); try { - _client.ConnectAsync(IPAddress.Loopback, port, timeout.Token).AsTask().GetAwaiter().GetResult(); + _client.ConnectAsync(host, port, timeout.Token).AsTask().GetAwaiter().GetResult(); } catch (Exception ex) when (ex is SocketException or OperationCanceledException) { token.ThrowIfCancellationRequested(); - throw new IOException("无法连接 pokeldn 本地手柄服务。请先在 pokeldn 的 Control 页面连接 ESP32-S3,或运行 python -m pokeldn.pad.service。", ex); + throw new IOException($"无法连接桥接服务 {host}:{port};请检查桥接地址及服务是否运行。", ex); } _stream = _client.GetStream(); _reader = new StreamReader(_stream, Encoding.UTF8, false, 1024, leaveOpen: true); diff --git a/src/EasyCon.Device/ECDevice.cs b/src/EasyCon.Device/ECDevice.cs index 380e73f5..0967ad4c 100644 --- a/src/EasyCon.Device/ECDevice.cs +++ b/src/EasyCon.Device/ECDevice.cs @@ -4,10 +4,5 @@ namespace EasyDevice; public sealed class ECDevice { - public const string PokeldnServiceName = "pokeldn (BLE 服务)"; - public const string PokeldnServiceEndpoint = "pokeldn://service"; - public static List GetPortNames() => [.. SerialPort.GetPortNames()]; - - public static List GetConnectionNames() => [.. GetPortNames(), PokeldnServiceName]; } \ No newline at end of file diff --git a/src/EasyCon.Device/JoyStickDevice.cs b/src/EasyCon.Device/JoyStickDevice.cs index 2903f42a..43589bf8 100644 --- a/src/EasyCon.Device/JoyStickDevice.cs +++ b/src/EasyCon.Device/JoyStickDevice.cs @@ -49,15 +49,20 @@ private void CancelStaleLoop() public ConnectResult TryConnect(string constr) { - if (string.IsNullOrWhiteSpace(constr)) + ConnectionEndpoint endpoint; + try + { + endpoint = ConnectionEndpoint.Parse(constr); + } + catch (ArgumentException ex) + { + LastConnectionError = ex.Message; return ConnectResult.InvalidArgument; - bool explicitPokeldn = constr.StartsWith("pokeldn:", StringComparison.OrdinalIgnoreCase) - || constr == ECDevice.PokeldnServiceName; - int[] baudrates = explicitPokeldn ? [PokeldnSerialClient.DefaultBaudRate] : [115200, 9600, PokeldnSerialClient.DefaultBaudRate]; + } ConnectResult result = ConnectResult.None; - foreach (int baudrate in baudrates) + foreach (Func candidate in GetConnectionProviders().SelectMany(provider => provider.GetCandidates(endpoint))) { - result = _TryConnect(constr, baudrate, pokeldn: baudrate == PokeldnSerialClient.DefaultBaudRate); + result = _TryConnect(candidate); if (result == ConnectResult.Success) { source = new(); @@ -81,6 +86,11 @@ public ConnectResult TryConnect(string constr) if (result == ConnectResult.InvalidArgument) break; } + if (result == ConnectResult.None) + { + LastConnectionError = $"没有适用于 {endpoint.Transport} 的连接协议。"; + return ConnectResult.InvalidArgument; + } return result; } diff --git a/src/EasyCon.Device/NintendoSwitchCmd.cs b/src/EasyCon.Device/NintendoSwitchCmd.cs index 54ed20d2..3f9191e9 100644 --- a/src/EasyCon.Device/NintendoSwitchCmd.cs +++ b/src/EasyCon.Device/NintendoSwitchCmd.cs @@ -1,5 +1,4 @@ using EasyDevice.Connection; -using EasyDevice.Connection.Pokeldn; namespace EasyDevice; @@ -20,33 +19,14 @@ public partial class NintendoSwitch public bool? ConsoleConnected => clientCon?.ConsoleConnected; public string? LastConnectionError { get; private set; } - /// 创建底层连接;测试可覆写以注入假连接(不改变协议与队列逻辑)。 - protected virtual IConnection CreateConnection(string connStr, int baudrate) - => new TTLSerialClient(connStr, baudrate); - - /// 创建 pokeldn 串口或官方服务连接;保留第三档探测的测试接缝。 - protected virtual IConnection CreatePokeldnConnection(string connStr) - { - if (connStr.Equals(ECDevice.PokeldnServiceEndpoint, StringComparison.OrdinalIgnoreCase) - || connStr == ECDevice.PokeldnServiceName) - return new PokeldnConnection(connStr, new PokeldnServiceTransport()); - if (connStr.StartsWith("pokeldn:", StringComparison.OrdinalIgnoreCase)) - { - connStr = connStr["pokeldn:".Length..]; - if (string.IsNullOrWhiteSpace(connStr) || connStr.StartsWith("//", StringComparison.Ordinal)) - throw new ArgumentException("pokeldn 连接地址应为 pokeldn:COM3、pokeldn:/dev/ttyUSB0 或 pokeldn://service。"); - } - return new PokeldnSerialClient(connStr); - } - - private ConnectResult _TryConnect(string connStr, int baudrate = 115200, bool pokeldn = false) + private ConnectResult _TryConnect(Func createConnection) { Disconnect(); LastConnectionError = null; IConnection connection; try { - connection = pokeldn ? CreatePokeldnConnection(connStr) : CreateConnection(connStr, baudrate); + connection = createConnection(); } catch (ArgumentException ex) { @@ -357,7 +337,7 @@ private bool RequireLegacyCommands() if (SupportsLegacyCommands) return true; if (IsConnected()) - Log?.Invoke("pokeldn-pad 仅支持在线手柄控制;不支持伊机控离线脚本烧录、远程运行、Amiibo、颜色及解除配对命令。"); + Log?.Invoke($"{FirmwareName} 仅支持在线手柄控制;不支持伊机控离线脚本烧录、远程运行、Amiibo、颜色及解除配对命令。"); return false; } } \ No newline at end of file diff --git a/src/EasyCon.Device/NintendoSwitchConnections.cs b/src/EasyCon.Device/NintendoSwitchConnections.cs new file mode 100644 index 00000000..e043736e --- /dev/null +++ b/src/EasyCon.Device/NintendoSwitchConnections.cs @@ -0,0 +1,38 @@ +using EasyDevice.Connection; +using EasyDevice.Connection.Pokeldn; + +namespace EasyDevice; + +public partial class NintendoSwitch +{ + private readonly IConnectionProvider[]? _connectionProviders; + + public NintendoSwitch() { } + + public NintendoSwitch(IEnumerable connectionProviders) + { + ArgumentNullException.ThrowIfNull(connectionProviders); + _connectionProviders = [.. connectionProviders]; + } + + /// 默认协议组合;自定义宿主可通过构造函数提供其他串口或桥接协议。 + protected virtual IEnumerable GetConnectionProviders() => _connectionProviders ?? + [ + new SerialConnectionProvider( + [ + new SerialProtocol(115200, CreateConnection), + new SerialProtocol(9600, CreateConnection), + new SerialProtocol(PokeldnSerialClient.DefaultBaudRate, (port, _) => CreatePokeldnConnection(port)) + ]), + new BridgeConnectionProvider( + [ + endpoint => new PokeldnConnection(endpoint.Address, new PokeldnServiceTransport(endpoint.Host!, endpoint.Port!.Value)) + ]) + ]; + + /// 保留原生串口连接的测试接缝。 + protected virtual IConnection CreateConnection(string connStr, int baudrate) => new TTLSerialClient(connStr, baudrate); + + /// 保留已公开的 pokeldn 串口测试接缝;它不解析桥接或固件专用地址。 + protected virtual IConnection CreatePokeldnConnection(string connStr) => new PokeldnSerialClient(connStr); +} \ No newline at end of file diff --git a/src/EasyCon.Device/README.md b/src/EasyCon.Device/README.md index 17b112ee..164ade0f 100644 --- a/src/EasyCon.Device/README.md +++ b/src/EasyCon.Device/README.md @@ -2,14 +2,14 @@ ## 模块概述 -Device模块负责与伊机控下位机(单片机)的串口通信,将上层的手柄操作指令发送到硬件设备。模块实现了完整的Nintendo Switch控制器协议,支持按键、摇杆、Amiibo等所有操控功能。 +Device 模块负责通过串口或桥接服务连接手柄设备,将上层的手柄操作发送到硬件。各连接实现声明固件信息和能力;按键、摇杆通用,Amiibo 等扩展命令取决于设备协议。 ## 核心功能 ### 设备连接 - 串口设备发现(`ECDevice` 枚举本机串口) -- TTL 串口连接(`TTLSerialClient`:115200 失败回退 9600) -- pokeldn-pad:旧协议探测失败后尝试 921600;ESP32-S3 可选择 `pokeldn (BLE 服务)` +- 通用串口协议注册(`SerialConnectionProvider`):默认依次探测原生协议 115200、9600 和 pokeldn-pad 921600 +- 通用 TCP 桥接注册(`BridgeConnectionProvider`):显式指定 `tcp://主机:端口`,默认适配 pokeldn 官方 BLE 服务 - 断线经 `StatusChanged` 上报,由上层(DeviceService)负责重连 ### Switch控制器协议 @@ -29,8 +29,10 @@ Device模块负责与伊机控下位机(单片机)的串口通信,将上 ## 关键类 -- **NintendoSwitch** — 高层设备API(partial类,JoyStickDevice/NintendoSwitchPriv/NintendoSwitchCmd 三分),提供按键、摇杆、Amiibo等操作方法 +- **NintendoSwitch** — 高层设备 API(partial 类),提供按键、摇杆等操作;协议组合集中于 `NintendoSwitchConnections.cs` - **ECDevice** — 设备发现,枚举可用串口 +- **ConnectionEndpoint** — 规范化串口/TCP 地址及来源占用标识,不识别固件名称 +- **IConnectionProvider** — 按传输类型提供延迟创建的候选协议,支持宿主注入 - **ECKey / KeyStroke** — 按键模型,封装按键编码和按下/释放动作 - **SwitchReport** — 7 字节控制器状态(2 字节 Button + HAT + LX/LY/RX/RY),支持原生固件的 7 位打包序列化 - **TTLSerialClient** — 原生固件连接:后台写循环、状态机(Connecting/Connected/Error)、一次性使用(断开后由上层重建) @@ -38,16 +40,18 @@ Device模块负责与伊机控下位机(单片机)的串口通信,将上 ## 连接层设计 -连接抽象为 `IConnection` 基类(public,作为测试接缝:测试可子类化 `NintendoSwitch` 并覆写 -`protected virtual CreateConnection` 注入假连接,见 test/EasyCon.SDLInput.Tests)。 +连接抽象为 `IConnection` 基类(public)。`NintendoSwitch` 构造函数可注入 +`IConnectionProvider` 集合,分别注册串口和桥接协议,不改变报告队列和上层调用。 +原有 `protected virtual CreateConnection` 测试接缝保留,见 test/EasyCon.SDLInput.Tests。 原生固件使用 **TTLSerialClient**:`Connect()` 启动后台写循环,`Disconnect()` 取消并 有界等待循环退出(避免新旧串口句柄竞争同一 COM 口)。上层通过 `NintendoSwitch` 使用, 无需关心底层连接实现。 `IConnection.WriteReport` 在连接层选择 HID 编码;原生固件保持 7 位打包,pokeldn-pad 使用 8 字节原始 HID。`SupportsLegacyCommands` 阻止向 pokeldn 发送烧录、Amiibo 等专有指令。 -连接列表还包含本机 pokeldn 服务入口,纯串口枚举 `GetPortNames()` 保持原语义。 -详细协议和限制见 [pokeldn 适配说明](../../docs/Pokeldn.md)。 +`GetPortNames()` 只枚举物理串口;GUI 保留用户配置的桥接地址,不添加固件专用服务列表项。 +地址配置、协议扩展和占用规则见 [通用设备连接](../../docs/DeviceConnections.md), +具体协议和硬件限制见 [pokeldn 适配说明](../../docs/Pokeldn.md)。 ## 依赖项 diff --git a/src/EasyCon2.Avalonia/Connection/FlashFirmwareViewModel.cs b/src/EasyCon2.Avalonia/Connection/FlashFirmwareViewModel.cs index ae89152c..7704436f 100644 --- a/src/EasyCon2.Avalonia/Connection/FlashFirmwareViewModel.cs +++ b/src/EasyCon2.Avalonia/Connection/FlashFirmwareViewModel.cs @@ -70,7 +70,7 @@ private async Task CompileFlashAsync() if (!_deviceService.SupportsLegacyCommands) { - _logService.AddLog("pokeldn-pad 不支持伊机控离线脚本烧录,请使用电脑端运行脚本。"); + _logService.AddLog("当前连接不支持伊机控离线脚本烧录,请使用电脑端运行脚本。"); return; } diff --git a/src/EasyCon2.Avalonia/Connection/SwitchConnectionViewModel.cs b/src/EasyCon2.Avalonia/Connection/SwitchConnectionViewModel.cs index 7597358f..eef1e4b3 100644 --- a/src/EasyCon2.Avalonia/Connection/SwitchConnectionViewModel.cs +++ b/src/EasyCon2.Avalonia/Connection/SwitchConnectionViewModel.cs @@ -3,6 +3,7 @@ using EasyCon.Core.Services; using EasyCon2.Avalonia.Localization; using EasyCon2.Avalonia.Services; +using EasyDevice.Connection; using System; using System.Collections.ObjectModel; using System.Windows.Input; @@ -29,7 +30,14 @@ public partial class SwitchConnectionViewModel : ObservableObject public string SelectionNotice => SelectionUnavailable ? "所选通信端口当前不可用,请刷新后重新选择" : ""; partial void OnSelectedSerialPortChanged(string? value) { - SelectionUnavailable = value != null && !_availablePorts.Contains(value); + // 桥接地址来自显式配置,不参与物理串口枚举;是否可连接由连接尝试确认。 + bool bridge = false; + if (value != null) + { + try { bridge = ConnectionEndpoint.Parse(value).Transport == ConnectionTransport.TcpBridge; } + catch (ArgumentException) { } + } + SelectionUnavailable = value != null && !bridge && !_availablePorts.Contains(value); OnPropertyChanged(nameof(SelectionNotice)); } diff --git a/src/EasyCon2.Avalonia/Services/DeviceService.cs b/src/EasyCon2.Avalonia/Services/DeviceService.cs index 3b093f96..21ba1d95 100644 --- a/src/EasyCon2.Avalonia/Services/DeviceService.cs +++ b/src/EasyCon2.Avalonia/Services/DeviceService.cs @@ -143,7 +143,7 @@ private bool RequireLegacyCommands() { if (!IsConnected) return false; if (SupportsLegacyCommands) return true; - _logService.AddLog("pokeldn-pad 支持在线按键和电脑端脚本;不支持伊机控离线烧录、远程运行及解除配对。请使用运行脚本/停止脚本,配对在 NS 上完成。"); + _logService.AddLog("当前连接支持在线按键和电脑端脚本;不支持伊机控离线烧录、远程运行及解除配对。请使用运行脚本/停止脚本,配对在 NS 上完成。"); return false; } diff --git a/src/EasyCon2.Avalonia/Shell/MainWindowViewModel.cs b/src/EasyCon2.Avalonia/Shell/MainWindowViewModel.cs index be58761e..7d6208da 100644 --- a/src/EasyCon2.Avalonia/Shell/MainWindowViewModel.cs +++ b/src/EasyCon2.Avalonia/Shell/MainWindowViewModel.cs @@ -595,7 +595,7 @@ private void ApplyUserSettings(ConfigState cfg) private void CollectUserSettings(ConfigState cfg) { cfg.CaptureType = Capture.SelectedCaptureType ?? "ANY"; - cfg.ControllerProfile = new ControllerProfile(Switch.SelectedSerialPort); + cfg.ControllerProfile = ControllerProfile.FromEndpoint(Switch.SelectedSerialPort); CaptureSourceDescriptor? source = _captureService.DescribeSelection(Capture.SelectedCaptureSource); if (source != null || cfg.CaptureProfile?.DisplayKey != Capture.SelectedCaptureSource || cfg.CaptureProfile?.Backend != cfg.CaptureType) cfg.CaptureProfile = new CaptureProfile(source, source?.Index, cfg.CaptureType, _captureService.Normalization, Capture.SelectedCaptureSource); diff --git a/src/EasyCon2.CLI/Program.cs b/src/EasyCon2.CLI/Program.cs index ca83af79..aff7e501 100644 --- a/src/EasyCon2.CLI/Program.cs +++ b/src/EasyCon2.CLI/Program.cs @@ -134,7 +134,7 @@ backend: parseResult.GetResult(deviceTypeOption)?.Implicit == false ? refs.ToString() : null); if (profiles?.Controller != null) { - if (profiles.Controller.Transport != "Serial") throw new InvalidDataException("仅支持 Serial 手柄配置"); + profiles.Controller.Validate(); COM = profiles.Controller.Endpoint ?? ""; } diff --git a/test/EasyCon.Device.Tests/ConnectionProviderTests.cs b/test/EasyCon.Device.Tests/ConnectionProviderTests.cs new file mode 100644 index 00000000..93c36d97 --- /dev/null +++ b/test/EasyCon.Device.Tests/ConnectionProviderTests.cs @@ -0,0 +1,119 @@ +using EasyDevice; +using EasyDevice.Connection; + +namespace EasyCon.Device.Tests; + +[TestFixture] +public class ConnectionProviderTests +{ + [Test] + public void SerialProtocols_AreLazyOrderedAndStopAtFirstSuccess() + { + List events = []; + SerialProtocol Protocol(int baud, bool accepts) => new(baud, (port, speed) => + { + events.Add($"create:{port}:{speed}"); + return new TestConnection(accepts, () => events.Add($"close:{speed}")); + }); + NintendoSwitch device = new( + [ + new SerialConnectionProvider([Protocol(19200, false), Protocol(38400, true), Protocol(57600, true)]) + ]); + try + { + Assert.That(events, Is.Empty); + Assert.That(device.TryConnect("custom-port"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(events, Is.EqualTo(new[] { "create:custom-port:19200", "close:19200", "create:custom-port:38400" })); + } + finally { device.Disconnect(); } + } + + [Test] + public async Task CustomBridge_UsesConfiguredAddressWithoutProbingSerial() + { + ConnectionEndpoint? selected = null; + NintendoSwitch device = new( + [ + new SerialConnectionProvider([new SerialProtocol(115200, (_, _) => throw new AssertionException("Bridge must not open a serial port"))]), + new BridgeConnectionProvider([endpoint => + { + selected = endpoint; + return new TestConnection(true); + }]) + ]); + try + { + Assert.That(device.TryConnect("TCP://EXAMPLE.TEST:31789/"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(selected!.Host, Is.EqualTo("example.test")); + Assert.That(selected.Port, Is.EqualTo(31789)); + Assert.That(selected.Address, Is.EqualTo("tcp://example.test:31789")); + Assert.That(await device.ResetAndWaitForWriteAsync(TimeSpan.FromSeconds(2)), Is.True); + } + finally { device.Disconnect(); } + } + + [Test] + public void MissingBridgeProtocol_DoesNotFallBackToSerial() + { + NintendoSwitch device = new( + [ + new SerialConnectionProvider([new SerialProtocol(115200, (_, _) => throw new AssertionException("Unexpected serial probe"))]) + ]); + try + { + Assert.That(device.TryConnect("tcp://localhost:31789"), Is.EqualTo(NintendoSwitch.ConnectResult.InvalidArgument)); + Assert.That(device.LastConnectionError, Does.Contain("TcpBridge")); + } + finally { device.Disconnect(); } + } + + [TestCase("TCP://EXAMPLE.TEST:31789/", "tcp://example.test:31789")] + [TestCase("tcp://[0:0:0:0:0:0:0:1]:31789/", "tcp://[::1]:31789")] + public void EquivalentBridgeAddresses_HaveOneOwnershipKey(string input, string expected) + { + ConnectionEndpoint endpoint = ConnectionEndpoint.Parse(input); + Assert.That(endpoint.Address, Is.EqualTo(expected)); + Assert.That(endpoint.OwnershipKey, Is.EqualTo(expected)); + Assert.That(endpoint.Transport, Is.EqualTo(ConnectionTransport.TcpBridge)); + } + + [TestCase("tcp://localhost")] + [TestCase("tcp:/localhost:1234")] + [TestCase("tcp://localhost:0")] + [TestCase("tcp://localhost:65536")] + [TestCase("tcp://user@localhost:1234")] + [TestCase("tcp://localhost:1234/path")] + [TestCase("tcp://localhost:1234?protocol=test")] + [TestCase("tcp://localhost:1234#name")] + public void InvalidBridgeAddress_IsRejectedBeforeAnyIo(string endpoint) + => Assert.Throws(() => ConnectionEndpoint.Parse(endpoint)); + + [Test] + public void SerialAddresses_AreOpaqueAndNeverInterpretFirmwarePrefixes() + { + ConnectionEndpoint endpoint = ConnectionEndpoint.Parse("vendor:COM3"); + Assert.That(endpoint.Transport, Is.EqualTo(ConnectionTransport.Serial)); + Assert.That(endpoint.Address, Is.EqualTo("vendor:COM3")); + if (OperatingSystem.IsWindows()) + Assert.That(ConnectionEndpoint.Parse(@"\\.\com3").OwnershipKey, Is.EqualTo(ConnectionEndpoint.Parse("COM3").OwnershipKey)); + } + + private sealed class TestConnection(bool accepts, Action? onDisconnect = null) : IConnection + { + public override Status CurrentStatus { get; protected set; } = Status.Connecting; + public override event StatusChangedHandler StatusChanged = delegate { }; + public override event BytesTransferedHandler BytesSent = delegate { }; + public override void Connect() + { + CurrentStatus = accepts ? Status.Connected : Status.Error; + StatusChanged(CurrentStatus); + } + public override void Disconnect() + { + CurrentStatus = Status.Connecting; + onDisconnect?.Invoke(); + } + public override void Write(params byte[] val) => BytesSent("test", val); + public override void ClearQueue() { } + } +} \ No newline at end of file diff --git a/test/EasyCon.Device.Tests/PokeldnConnectionTests.cs b/test/EasyCon.Device.Tests/PokeldnConnectionTests.cs index 8b9a2aa8..64ff72f9 100644 --- a/test/EasyCon.Device.Tests/PokeldnConnectionTests.cs +++ b/test/EasyCon.Device.Tests/PokeldnConnectionTests.cs @@ -15,7 +15,7 @@ public void RealDeviceLoop_SendsButtonsAndSticks_AndResetReleasesEverything() TestSwitch device = new(new PokeldnSerialClient("test", transport)); try { - Assert.That(device.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(device.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); Assert.That(transport.TakeReport(), Is.EqualTo(Convert.FromHexString("0000088080808000"))); device.Down(ECKeyUtil.Button(SwitchButton.A)); Assert.That(transport.TakeReport(), Is.EqualTo(Convert.FromHexString("0400088080808000"))); @@ -44,7 +44,7 @@ public async Task ResetConfirmation_WaitsForTransportAck_AndUsesPackedReportEven TestSwitch device = new(connection); try { - Assert.That(device.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(device.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); transport.TakeReport(); transport.BlockWrites = true; Task confirmed = device.ResetAndWaitForWriteAsync(TimeSpan.FromSeconds(3)); @@ -68,7 +68,7 @@ public void LegacyCommands_AreRejectedWithoutWritingToPad() TestSwitch device = new(new PokeldnConnection("test", transport)); try { - Assert.That(device.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(device.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); transport.TakeReport(); Assert.Multiple(() => { @@ -98,7 +98,7 @@ public void IdleDisconnect_IsDetectedByStatusPolling() TestSwitch device = new(new PokeldnConnection("test", transport)); try { - Assert.That(device.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(device.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); transport.FailStatus = true; Assert.That(SpinWait.SpinUntil(() => !device.IsConnected(), 3000), Is.True); Assert.That(device.LastConnectionError, Does.Contain("unplugged")); @@ -107,25 +107,21 @@ public void IdleDisconnect_IsDetectedByStatusPolling() } [Test] - public void AutoDetection_KeepsLegacyBaudsBeforePokeldn_ExplicitSkipsLegacy() + public void AutoDetection_KeepsLegacyBaudsBeforePokeldn() { ProbeSwitch legacy = new(115200); ProbeSwitch pad = new(921600); - ProbeSwitch explicitPad = new(921600); try { Assert.That(legacy.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); Assert.That(legacy.Bauds, Is.EqualTo(new[] { 115200 })); Assert.That(pad.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); Assert.That(pad.Bauds, Is.EqualTo(new[] { 115200, 9600, 921600 })); - Assert.That(explicitPad.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); - Assert.That(explicitPad.Bauds, Is.EqualTo(new[] { 921600 })); } finally { legacy.Disconnect(); pad.Disconnect(); - explicitPad.Disconnect(); } } @@ -165,7 +161,7 @@ public void DisconnectWhileHoldingA_ReleasesBeforeClosing_AndIsIdempotent() { FakeTransport transport = new(); TestSwitch device = new(new PokeldnConnection("test", transport)); - Assert.That(device.TryConnect("pokeldn:fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + Assert.That(device.TryConnect("fake"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); transport.TakeReport(); device.Down(ECKeyUtil.Button(SwitchButton.A)); transport.TakeReport(); @@ -193,8 +189,8 @@ public void LegacyOutput_RemainsSevenBitPacked() [TestCase("")] [TestCase(" ")] - [TestCase("pokeldn:")] - [TestCase("pokeldn://typo")] + [TestCase("tcp://localhost")] + [TestCase("tcp://localhost:0")] public void InvalidEndpoint_DoesNotAttemptHardware(string endpoint) { NintendoSwitch device = new(); diff --git a/test/EasyCon.Device.Tests/PokeldnServiceTests.cs b/test/EasyCon.Device.Tests/PokeldnServiceTests.cs index ec96661b..ce3c03c5 100644 --- a/test/EasyCon.Device.Tests/PokeldnServiceTests.cs +++ b/test/EasyCon.Device.Tests/PokeldnServiceTests.cs @@ -16,8 +16,8 @@ public class PokeldnServiceTests public async Task S3Service_UsesOfficialJsonProtocolThroughRealDeviceLoop() { using PadService server = new(); - ServiceSwitch device = new(server.Port); - Assert.That(device.TryConnect(ECDevice.PokeldnServiceEndpoint), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + NintendoSwitch device = new(); + Assert.That(device.TryConnect($"tcp://127.0.0.1:{server.Port}"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); try { Assert.That(device.FirmwareVersion, Is.EqualTo("1.3.0")); @@ -37,7 +37,7 @@ public async Task S3Service_UsesOfficialJsonProtocolThroughRealDeviceLoop() public void RefusedCommand_PropagatesServiceError() { using PadService server = new() { RefuseSend = true }; - using PokeldnServiceTransport transport = new(server.Port); + using PokeldnServiceTransport transport = new("127.0.0.1", server.Port); transport.Connect(CancellationToken.None); IOException? error = Assert.Throws(() => transport.SendReport(new byte[8], CancellationToken.None)); Assert.That(error!.Message, Does.Contain("board refused")); @@ -47,7 +47,7 @@ public void RefusedCommand_PropagatesServiceError() public void ServiceWithNoBoard_DoesNotClaimConnected() { using PadService server = new() { BoardConnected = false }; - using PokeldnServiceTransport transport = new(server.Port); + using PokeldnServiceTransport transport = new("127.0.0.1", server.Port); Assert.Throws(() => transport.Connect(CancellationToken.None)); } @@ -55,7 +55,7 @@ public void ServiceWithNoBoard_DoesNotClaimConnected() public void CanceledBleScan_DoesNotLeaveAConnectionWorkerWaiting() { using PadService server = new() { HangConnect = true }; - using PokeldnServiceTransport transport = new(server.Port); + using PokeldnServiceTransport transport = new("127.0.0.1", server.Port); using CancellationTokenSource cancel = new(200); Assert.Throws(() => transport.Connect(cancel.Token)); } @@ -67,17 +67,17 @@ public void MissingService_GivesActionableMessage() listener.Start(); int unusedPort = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); - using PokeldnServiceTransport transport = new(unusedPort); + using PokeldnServiceTransport transport = new("127.0.0.1", unusedPort); IOException? error = Assert.Throws(() => transport.Connect(CancellationToken.None)); - Assert.That(error!.Message, Does.Contain("pokeldn.pad.service")); + Assert.That(error!.Message, Does.Contain($"127.0.0.1:{unusedPort}")); } [Test] public void DisconnectDuringFragmentedAck_DrainsReplyBeforeNeutralAndDisconnect() { using PadService server = new() { DelayLiveReportAck = true }; - ServiceSwitch device = new(server.Port); - Assert.That(device.TryConnect(ECDevice.PokeldnServiceEndpoint), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); + NintendoSwitch device = new(); + Assert.That(device.TryConnect($"tcp://127.0.0.1:{server.Port}"), Is.EqualTo(NintendoSwitch.ConnectResult.Success)); device.Down(ECKeyUtil.Button(SwitchButton.A)); Assert.That(server.LiveReportAckStarted.Wait(2000), Is.True); device.Disconnect(); @@ -86,12 +86,6 @@ public void DisconnectDuringFragmentedAck_DrainsReplyBeforeNeutralAndDisconnect( Assert.That(SpinWait.SpinUntil(() => server.DisconnectAcknowledged, 1000), Is.True); } - private sealed class ServiceSwitch(int port) : NintendoSwitch - { - protected override IConnection CreatePokeldnConnection(string connStr) - => new PokeldnConnection(connStr, new PokeldnServiceTransport(port)); - } - private sealed class PadService : IDisposable { private readonly TcpListener _listener = new(IPAddress.Loopback, 0); diff --git a/test/EasyCon.Tests/Maa/HostProfileRegressionTests.cs b/test/EasyCon.Tests/Maa/HostProfileRegressionTests.cs index b415a5ec..8a5b069a 100644 --- a/test/EasyCon.Tests/Maa/HostProfileRegressionTests.cs +++ b/test/EasyCon.Tests/Maa/HostProfileRegressionTests.cs @@ -17,6 +17,39 @@ private static HostProfiles Saved() return new HostProfiles { Controller = new("COM3"), Capture = new(source, 1, "DSHOW", DisplayKey: source.DisplayKey) }; } + [Test] + public void BridgeOverride_UpdatesTransport_AndPreservesCapture() + { + HostProfiles original = Saved(); + HostProfiles bridge = original.WithOverrides(port: "tcp://localhost:31789"); + Assert.That(bridge.Controller!.Transport, Is.EqualTo("TcpBridge")); + Assert.That(bridge.Capture, Is.SameAs(original.Capture)); + HostProfiles serial = bridge.WithOverrides(port: "COM5"); + Assert.That(serial.Controller!.Transport, Is.EqualTo("Serial")); + Assert.That(serial.Capture, Is.SameAs(original.Capture)); + } + + [Test] + public void BridgeProfile_RoundTripsGuiConfigurationAndCliLoader() + { + string path = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid() + ".json"); + ConfigState settings = new() { ControllerProfile = ControllerProfile.FromEndpoint("tcp://localhost:31789") }; + try + { + File.WriteAllText(path, JsonSerializer.Serialize(settings)); + HostProfiles profiles = HostProfiles.Load(path); + Assert.That(profiles.Controller, Is.EqualTo(settings.ControllerProfile)); + } + finally { File.Delete(path); } + } + + [TestCase("tcp://localhost:31789", "Serial")] + [TestCase("COM3", "TcpBridge")] + [TestCase("tcp://localhost", "TcpBridge")] + [TestCase("COM3", "unknown")] + public void ControllerProfile_RejectsTransportMismatch(string endpoint, string transport) + => Assert.Throws(() => new ControllerProfile(endpoint, transport).Validate()); + [TestCase("DSHOW")] [TestCase("dshow")] public void SameBackendOverride_PreservesIdentity_AndResolvesReordering(string backend) diff --git a/test/EasyCon.Tests/Maa/PokeldnHostTests.cs b/test/EasyCon.Tests/Maa/PokeldnHostTests.cs index cd24fe76..d8a4818d 100644 --- a/test/EasyCon.Tests/Maa/PokeldnHostTests.cs +++ b/test/EasyCon.Tests/Maa/PokeldnHostTests.cs @@ -12,7 +12,7 @@ public void ControllerSession_ExposesFirmwareCapabilities_AndConfirmsInitialRele { Device device = new(); using MaaControllerSession controller = new(device); - Assert.That(controller.TryConnect("pokeldn:fake:metadata"), Is.EqualTo(ControllerConnectResult.Success)); + Assert.That(controller.TryConnect("fake:metadata"), Is.EqualTo(ControllerConnectResult.Success)); Assert.Multiple(() => { Assert.That(controller.InputConnected, Is.True); @@ -25,9 +25,9 @@ public void ControllerSession_ExposesFirmwareCapabilities_AndConfirmsInitialRele }); } - [TestCase("fake:pokeldn-owned", "pokeldn:fake:pokeldn-owned")] - [TestCase(ECDevice.PokeldnServiceName, ECDevice.PokeldnServiceEndpoint)] - [TestCase("POKELDN://SERVICE", ECDevice.PokeldnServiceName)] + [TestCase("fake:owned", " fake:owned ")] + [TestCase("tcp://localhost:31999", "TCP://LOCALHOST:31999/")] + [TestCase("tcp://[::1]:31999", "tcp://[0:0:0:0:0:0:0:1]:31999/")] public void EndpointAliases_CannotBypassExclusiveOwnership(string firstEndpoint, string secondEndpoint) { using MaaControllerSession first = new(new Device()); @@ -43,6 +43,11 @@ public void EndpointAliases_CannotBypassExclusiveOwnership(string firstEndpoint, private sealed class Device : NintendoSwitch { public int OpenAttempts { get; private set; } + protected override IEnumerable GetConnectionProviders() => + [ + new SerialConnectionProvider([new SerialProtocol(115200, CreateConnection)]), + new BridgeConnectionProvider([endpoint => CreateConnection(endpoint.Address, 0)]) + ]; protected override IConnection CreateConnection(string connStr, int baudrate) { OpenAttempts++; diff --git a/test/EasyCon2.Avalonia.Core.Tests/ScriptServiceTests.cs b/test/EasyCon2.Avalonia.Core.Tests/ScriptServiceTests.cs index ab23cee8..ffceed06 100644 --- a/test/EasyCon2.Avalonia.Core.Tests/ScriptServiceTests.cs +++ b/test/EasyCon2.Avalonia.Core.Tests/ScriptServiceTests.cs @@ -429,6 +429,22 @@ public void PendingSelectionsAndRefresh_DoNotRetargetConnectedModules_OrSelectFa }); } + [Test] + public void ConfiguredBridgeSelection_IsPreservedWithoutPretendingItWasDiscovered() + { + FakeDeviceService device = new() { AvailablePorts = [] }; + _devices.Add(device); + SwitchConnectionViewModel input = new(device, new FakeLogService(), SynchronousUiDispatcher.Instance); + Assert.That(input.SerialPortOptions, Is.Empty); + input.SelectedSerialPort = "tcp://localhost:31789"; + input.RefreshSerialPortsCommand.Execute(null); + Assert.That(input.SelectedSerialPort, Is.EqualTo("tcp://localhost:31789")); + Assert.That(input.SerialPortOptions, Is.EqualTo(new[] { "tcp://localhost:31789" })); + Assert.That(input.SelectionUnavailable, Is.False); + input.SelectedSerialPort = "tcp://localhost"; + Assert.That(input.SelectionUnavailable, Is.True); + } + private sealed class RecordingAlert : IAlertService { public TaskCompletionSource Message { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);