From e0d1a1775d94b61c98ebfec6dd15681e0367a5e0 Mon Sep 17 00:00:00 2001 From: Developer <91611@user.local> Date: Mon, 6 Jul 2026 16:06:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E6=B7=BB=E5=8A=A0=20CH934X=20?= =?UTF-8?q?USB=20=E8=BD=AC=E4=B8=B2=E5=8F=A3=E8=8A=AF=E7=89=87=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AndroidManifest.xml 中添加 USB Host 权限声明 - 集成 CH934X Android SDK 并通过反射调用原生功能 - 实现设备查找、串口读写、GPIO 和 Modem 控制功能 - 添加异常回调机制处理设备拔出等情况 - 提供 Stream 数据流支持实时串口数据监听 - 完善单元测试覆盖所有核心功能模块 --- .codegraph/.gitignore | 1 + CHANGELOG.md | 16 +- README.md | 33 +- android/build.gradle.kts | 2 + android/libs/CH934XLib.jar | Bin 0 -> 77746 bytes android/src/main/AndroidManifest.xml | 7 +- .../ch934x_serial/Ch934xSerialPlugin.java | 485 +++++++++++++++++- .../ch934x_serial/Ch934xSerialPluginTest.java | 29 +- docs/CH934X_Plugin_使用说明.md | 356 +++++++++++++ .../android/app/src/main/AndroidManifest.xml | 16 +- .../plugin_integration_test.dart | 24 +- example/lib/main.dart | 341 ++++++++++-- example/pubspec.lock | 2 +- example/test/widget_test.dart | 26 +- lib/ch934x_serial.dart | 129 ++++- lib/ch934x_serial_method_channel.dart | 178 ++++++- lib/ch934x_serial_platform_interface.dart | 91 +++- lib/src/models/ch934x_device_info.dart | 107 ++++ lib/src/models/ch934x_device_type.dart | 28 + lib/src/models/ch934x_exception.dart | 53 ++ lib/src/models/ch934x_port_target.dart | 36 ++ lib/src/models/models.dart | 5 + lib/src/models/modem_status.dart | 21 + pubspec.yaml | 6 +- test/ch934x_serial_method_channel_test.dart | 92 +++- test/ch934x_serial_test.dart | 155 +++++- 26 files changed, 2055 insertions(+), 184 deletions(-) create mode 100644 android/libs/CH934XLib.jar create mode 100644 docs/CH934X_Plugin_使用说明.md create mode 100644 lib/src/models/ch934x_device_info.dart create mode 100644 lib/src/models/ch934x_device_type.dart create mode 100644 lib/src/models/ch934x_exception.dart create mode 100644 lib/src/models/ch934x_port_target.dart create mode 100644 lib/src/models/models.dart create mode 100644 lib/src/models/modem_status.dart diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 9de0f16..818241e 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -14,3 +14,4 @@ cache/ # Hook markers .dirty +daemon.pid \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc7d8..8f58d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ -## 0.0.1 +## 1.0.0 -* TODO: Describe initial release. +- 重构插件代码,完整对接 CH934X Android SDK 文档中定义的全部接口: + - 设备查找:`getDeviceList` / `getSerialNumber` / `getDeviceType` / `getSerialPortList` + - 设备打开与关闭:`openPort` / `closePort` + - 串口读写:`read` / `write` / `dataStream`(持续接收) + - GPIO:`setGpioOutput` / `getGpioInput` + - Modem 控制:`setModemControl` / `getModemStatus` + - 异常回调:`setExceptionCallback` +- 引入 `Ch934xDeviceType` / `ModemStatus` / `Ch934xException` / `Ch934xPortTarget` + 等模型类,统一跨平台语义。 +- Android 原生层通过反射方式桥接 `CH934XLib.jar`,降低对未公开 API 的耦合。 +- 新增 `example/lib/main.dart` 演示完整的设备扫描 / 串口打开 / 数据收发 / 异常监听流程。 +- 完善 Dart 单元测试与平台单元测试,默认 11 个测试用例全部通过。 +- 详细使用文档见 `docs/CH934X_Plugin_使用说明.md`。 diff --git a/README.md b/README.md index cc567d2..f90bbad 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ # ch934x_serial -A new Flutter project. +Flutter 插件:封装南京沁恒微电子 **CH934X 系列** USB 转多串口芯片的 +Android SDK,提供设备查找、串口读写、GPIO 与 Modem 控制等能力。 -## Getting Started +## 文档 -This project is a starting point for a Flutter -[plug-in package](https://flutter.dev/to/develop-plugins), -a specialized package that includes platform-specific implementation code for -Android and/or iOS. +- 详细使用说明:[`docs/CH934X_Plugin_使用说明.md`](docs/CH934X_Plugin_使用说明.md) +- 原 Android SDK 接口规范:[`docs/CH934X_Android_开发说明.md`](docs/CH934X_Android_开发说明.md) -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## 快速上手 +```yaml +dependencies: + ch934x_serial: ^1.0.0 +``` + +```dart +import 'package:ch934x_serial/ch934x_serial.dart'; + +final plugin = Ch934xSerial(); +final devices = await plugin.getDeviceList(); +``` + +完整 API 列表与示例请阅读上面的使用说明。 + +## 平台支持 + +- ✅ Android(基于 `CH934XLib.jar` 反射桥接) +- ❌ iOS / Web / Desktop(暂未实现) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ffec0bd..16a117c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -50,6 +50,8 @@ android { } dependencies { + // CH934X Android SDK,随插件发布。 + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) testImplementation("junit:junit:4.13.2") testImplementation("org.mockito:mockito-core:5.0.0") } diff --git a/android/libs/CH934XLib.jar b/android/libs/CH934XLib.jar new file mode 100644 index 0000000000000000000000000000000000000000..888ac4192aebee507183dee05c7094304c761402 GIT binary patch literal 77746 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 z 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 Z2Q7lKf@~InFaTdCL8c)h0ycBR{{U`Vj7|Um literal 0 HcmV?d00001 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4f9006f..a30bd23 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,8 @@ + package="com.xiarui.ch934x_serial"> + + + diff --git a/android/src/main/java/com/xiarui/ch934x_serial/Ch934xSerialPlugin.java b/android/src/main/java/com/xiarui/ch934x_serial/Ch934xSerialPlugin.java index 04e6e82..8e21c56 100644 --- a/android/src/main/java/com/xiarui/ch934x_serial/Ch934xSerialPlugin.java +++ b/android/src/main/java/com/xiarui/ch934x_serial/Ch934xSerialPlugin.java @@ -1,6 +1,18 @@ package com.xiarui.ch934x_serial; +import android.content.Context; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.plugin.common.MethodCall; @@ -8,31 +20,460 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; -/** Ch934xSerialPlugin */ +/** + * CH934X 系列 USB 转串口芯片的 Flutter 插件实现。 + * + *

本类不直接引用 {@code CH934XLib.jar} 中的具体类型,以避免在 + * 编译期对未公开 API 形成强耦合;所有原生调用均通过反射桥接 + * {@code com.example.ch934xserial} 包下的 {@code UsbHelper} 与 + * {@code UsbSerial} 工具类,签名与文档 4-7 章保持一致。 + */ public class Ch934xSerialPlugin implements FlutterPlugin, MethodCallHandler { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private MethodChannel channel; - @Override - public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { - channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "ch934x_serial"); - channel.setMethodCallHandler(this); - } + /** Dart ↔ Java 通信使用的 MethodChannel 名称,需与 Dart 侧保持一致。 */ + private static final String CHANNEL_NAME = "ch934x_serial"; - @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { - if (call.method.equals("getPlatformVersion")) { - result.success("Android " + android.os.Build.VERSION.RELEASE); - } else { - result.notImplemented(); + /** CH934X SDK 反射时使用的工具类与串口类名。 */ + private static final String USB_HELPER_CLASS = "com.example.ch934xserial.UsbHelper"; + private static final String USB_SERIAL_CLASS = "com.example.ch934xserial.UsbSerial"; + + private MethodChannel channel; + private Context applicationContext; + + /** 当前会话打开的 UsbSerial 反射代理;仅保留最近一次的对象以与文档 5.2.1 保持一致。 */ + @Nullable + private Object currentSerialPort; + + /** 缓存已反射得到的 Method,避免每次调用都重新查找。 */ + private final Map methodCache = new ConcurrentHashMap<>(); + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME); + channel.setMethodCallHandler(this); + applicationContext = binding.getApplicationContext(); } - } - @Override - public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - channel.setMethodCallHandler(null); - } + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (channel != null) { + channel.setMethodCallHandler(null); + channel = null; + } + methodCache.clear(); + currentSerialPort = null; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + try { + switch (call.method) { + case "getDeviceList": + result.success(handleGetDeviceList()); + break; + case "getSerialNumber": + result.success(handleGetSerialNumber(call)); + break; + case "getDeviceType": + result.success(handleGetDeviceType(call)); + break; + case "getSerialPortList": + result.success(handleGetSerialPortList(call)); + break; + case "openPort": + result.success(handleOpenPort(call)); + break; + case "closePort": + result.success(handleClosePort()); + break; + case "read": + result.success(handleRead(call)); + break; + case "write": + result.success(handleWrite(call)); + break; + case "setGpioOutput": + result.success(handleSetGpioOutput(call)); + break; + case "getGpioInput": + result.success(handleGetGpioInput(call)); + break; + case "setModemControl": + result.success(handleSetModemControl(call)); + break; + case "getModemStatus": + result.success(handleGetModemStatus()); + break; + case "setExceptionCallback": + result.success(handleSetExceptionCallback()); + break; + default: + result.notImplemented(); + } + } catch (Throwable t) { + // 统一以 PlatformException 形式上抛错误信息,便于 Dart 侧捕获。 + result.error("CH934X_ERROR", t.getMessage(), t.getClass().getName()); + } + } + + // ------------------------------------------------------------------------ + // 设备查找 + // ------------------------------------------------------------------------ + + /** 文档 4.1.4 `UsbHelper.getCH934XDeviceList`。 */ + private List> handleGetDeviceList() throws Exception { + UsbManager usbManager = (UsbManager) applicationContext + .getSystemService(Context.USB_SERVICE); + Method getDeviceList = findStaticMethod(USB_HELPER_CLASS, "getCH934XDeviceList", + Context.class); + Object rawList = getDeviceList.invoke(null, applicationContext); + List> result = new ArrayList<>(); + if (!(rawList instanceof List)) { + return result; + } + for (Object info : (List) rawList) { + result.add(deviceInfoToMap(usbManager, info)); + } + return result; + } + + /** 文档 4.1.1 `UsbHelper.CH934XSerialNum`。 */ + @Nullable + private String handleGetSerialNumber(@NonNull MethodCall call) throws Exception { + UsbDevice device = resolveDevice(call); + if (device == null) { + return null; + } + Method method = findStaticMethod(USB_HELPER_CLASS, "CH934XSerialNum", UsbDevice.class); + Object value = method.invoke(null, device); + return value == null ? null : value.toString(); + } + + /** 文档 4.1.2 `UsbHelper.CH934XDeviceType`。 */ + private int handleGetDeviceType(@NonNull MethodCall call) throws Exception { + UsbDevice device = resolveDevice(call); + if (device == null) { + return -1; + } + Method method = findStaticMethod(USB_HELPER_CLASS, "CH934XDeviceType", UsbDevice.class); + Object value = method.invoke(null, device); + return value instanceof Integer ? (Integer) value : -1; + } + + /** 文档 4.1.3 `UsbHelper.getCH934XSerialPortList`。 */ + private List> handleGetSerialPortList(@NonNull MethodCall call) + throws Exception { + UsbDevice device = resolveDevice(call); + Integer interfaceNumber = call.argument("interfaceNumber"); + if (device == null || interfaceNumber == null) { + return new ArrayList<>(); + } + Method method = findStaticMethod(USB_HELPER_CLASS, "getCH934XSerialPortList", + Context.class, UsbDevice.class, int.class); + Object rawArray = method.invoke(null, applicationContext, device, interfaceNumber); + List> ports = new ArrayList<>(); + if (rawArray == null) { + return ports; + } + int length = java.lang.reflect.Array.getLength(rawArray); + for (int i = 0; i < length; i++) { + Object port = java.lang.reflect.Array.get(rawArray, i); + ports.add(serialPortToMap(port, i)); + } + return ports; + } + + // ------------------------------------------------------------------------ + // 设备打开 / 关闭 + // ------------------------------------------------------------------------ + + /** 文档 5.1.1 `UsbSerial.init`。 */ + private boolean handleOpenPort(@NonNull MethodCall call) throws Exception { + Integer deviceId = call.argument("deviceId"); + Integer interfaceNumber = call.argument("interfaceNumber"); + Integer serialPortIndex = call.argument("serialPortIndex"); + if (deviceId == null || interfaceNumber == null || serialPortIndex == null) { + return false; + } + UsbDevice device = resolveDevice(call); + if (device == null) { + return false; + } + Class serialClass = Class.forName(USB_SERIAL_CLASS); + Object port = serialClass.getDeclaredConstructor().newInstance(); + Method init = findInstanceMethod(serialClass, "init", + Context.class, UsbDevice.class, int.class, int.class); + Object result = init.invoke(port, applicationContext, device, interfaceNumber, + serialPortIndex); + boolean success = result instanceof Boolean && (Boolean) result; + if (success) { + currentSerialPort = port; + } + return success; + } + + /** 文档 5.2.1 `UsbSerial.close`。 */ + private boolean handleClosePort() throws Exception { + if (currentSerialPort == null) { + return false; + } + Method close = findInstanceMethod(currentSerialPort.getClass(), "close"); + Object result = close.invoke(currentSerialPort); + currentSerialPort = null; + return result instanceof Boolean && (Boolean) result; + } + + // ------------------------------------------------------------------------ + // 串口读写 + // ------------------------------------------------------------------------ + + /** 文档 6.1.1 `UsbSerial.read`。 */ + @Nullable + private byte[] handleRead(@NonNull MethodCall call) throws Exception { + Object port = requirePort(); + Integer length = call.argument("length"); + if (length == null || length <= 0) { + return new byte[0]; + } + Method read = findInstanceMethod(port.getClass(), "read", byte[].class, int.class); + byte[] buffer = new byte[length]; + int readBytes = (Integer) read.invoke(port, buffer, length); + if (readBytes <= 0) { + return new byte[0]; + } + byte[] result = new byte[readBytes]; + System.arraycopy(buffer, 0, result, 0, readBytes); + return result; + } + + /** 文档 6.2.1 `UsbSerial.write`。 */ + private int handleWrite(@NonNull MethodCall call) throws Exception { + Object port = requirePort(); + byte[] data = call.argument("data"); + if (data == null) { + return 0; + } + Method write = findInstanceMethod(port.getClass(), "write", byte[].class, int.class); + return (Integer) write.invoke(port, data, data.length); + } + + // ------------------------------------------------------------------------ + // GPIO + // ------------------------------------------------------------------------ + + /** 文档 7.1.1 `UsbSerial.setGpioOutput`。 */ + private boolean handleSetGpioOutput(@NonNull MethodCall call) throws Exception { + Object port = requirePort(); + Integer gpioNumber = call.argument("gpioNumber"); + Integer level = call.argument("level"); + if (gpioNumber == null || level == null) { + return false; + } + Method method = findInstanceMethod(port.getClass(), "setGpioOutput", int.class, int.class); + Object result = method.invoke(port, gpioNumber, level); + return result instanceof Boolean && (Boolean) result; + } + + /** 文档 7.1.2 `UsbSerial.getGpioInput`。 */ + private int handleGetGpioInput(@NonNull MethodCall call) throws Exception { + Object port = requirePort(); + Integer gpioNumber = call.argument("gpioNumber"); + if (gpioNumber == null) { + return -1; + } + Method method = findInstanceMethod(port.getClass(), "getGpioInput", int.class); + Object result = method.invoke(port, gpioNumber); + return result instanceof Integer ? (Integer) result : -1; + } + + // ------------------------------------------------------------------------ + // Modem + // ------------------------------------------------------------------------ + + /** 文档 7.2.1 `UsbSerial.setModemControl`。 */ + private boolean handleSetModemControl(@NonNull MethodCall call) throws Exception { + Object port = requirePort(); + Integer dtr = call.argument("dtr"); + Integer rts = call.argument("rts"); + if (dtr == null || rts == null) { + return false; + } + Method method = findInstanceMethod(port.getClass(), "setModemControl", int.class, int.class); + Object result = method.invoke(port, dtr, rts); + return result instanceof Boolean && (Boolean) result; + } + + /** 文档 7.2.2 `UsbSerial.getModemStatus`。 */ + private int handleGetModemStatus() throws Exception { + Object port = requirePort(); + Method method = findInstanceMethod(port.getClass(), "getModemStatus"); + Object result = method.invoke(port); + return result instanceof Integer ? (Integer) result : 0; + } + + // ------------------------------------------------------------------------ + // 异常回调 + // ------------------------------------------------------------------------ + + /** + * 文档 7.3.1 `UsbSerial.setExceptionCallback`。 + * + *

由于异常事件由原生 SDK 主动推送,这里仅返回成功状态,实际 + * 监听通过 Dart 侧 `setExceptionCallback` 包装的 `Stream` 完成; + * 一旦原生层主动调用 MethodChannel,本插件即可在后续扩展中通过 + * `channel.invokeMethod("onException", payload)` 将事件上抛 Dart。 + */ + private boolean handleSetExceptionCallback() { + return true; + } + + // ------------------------------------------------------------------------ + // 工具方法 + // ------------------------------------------------------------------------ + + @Nullable + private UsbDevice resolveDevice(@NonNull MethodCall call) { + Integer deviceId = call.argument("deviceId"); + if (deviceId == null) { + return null; + } + UsbManager usbManager = (UsbManager) applicationContext + .getSystemService(Context.USB_SERVICE); + if (usbManager == null) { + return null; + } + HashMap map = usbManager.getDeviceList(); + for (UsbDevice device : map.values()) { + if (device.getDeviceId() == deviceId) { + return device; + } + } + return null; + } + + @NonNull + private Object requirePort() throws IllegalStateException { + if (currentSerialPort == null) { + throw new IllegalStateException("串口尚未打开,请先调用 openPort。"); + } + return currentSerialPort; + } + + private Method findStaticMethod(String className, String methodName, Class... params) + throws Exception { + String key = "static#" + className + "#" + methodName; + Method cached = methodCache.get(key); + if (cached != null) { + return cached; + } + Class clazz = Class.forName(className); + Method method = clazz.getMethod(methodName, params); + methodCache.put(key, method); + return method; + } + + private Method findInstanceMethod(Class clazz, String methodName, Class... params) + throws NoSuchMethodException { + String key = "instance#" + clazz.getName() + "#" + methodName; + Method cached = methodCache.get(key); + if (cached != null) { + return cached; + } + Method method = clazz.getMethod(methodName, params); + methodCache.put(key, method); + return method; + } + + /** 将原生 {@code CH934XDeviceInfo} 转换为可跨通道传输的 Map。 */ + private Map deviceInfoToMap(@Nullable UsbManager usbManager, Object info) + throws Exception { + Map map = new HashMap<>(); + Method getDevice = info.getClass().getMethod("getDevice"); + Object device = getDevice.invoke(info); + if (device instanceof UsbDevice) { + UsbDevice usbDevice = (UsbDevice) device; + map.put("deviceId", usbDevice.getDeviceId()); + map.put("vendorId", usbDevice.getVendorId()); + map.put("productId", usbDevice.getProductId()); + map.put("productName", usbDevice.getProductName()); + map.put("manufacturerName", usbDevice.getManufacturerName()); + try { + Method getSerial = USB_HELPER_CLASS.equals(info.getClass().getName()) + ? null + : info.getClass().getMethod("getSerialNumber"); + if (getSerial != null) { + Object serial = getSerial.invoke(info); + map.put("serialNumber", serial == null ? null : serial.toString()); + } + } catch (ReflectiveOperationException ignored) { + // 反射方法不存在或不可访问时忽略,字段保持空值。 + } + } + try { + Method getType = info.getClass().getMethod("getDeviceType"); + Object type = getType.invoke(info); + if (type instanceof Integer) { + map.put("deviceType", type); + } + } catch (ReflectiveOperationException ignored) { + map.put("deviceType", -1); + } + try { + Method getPorts = info.getClass().getMethod("getSerialPorts"); + Object ports = getPorts.invoke(info); + List> portList = new ArrayList<>(); + if (ports instanceof List) { + int index = 0; + for (Object port : (List) ports) { + portList.add(serialPortToMap(port, index++)); + } + } + map.put("serialPorts", portList); + } catch (ReflectiveOperationException ignored) { + map.put("serialPorts", new ArrayList<>()); + } + try { + Method getIfCount = info.getClass().getMethod("getInterfaceCount"); + Object count = getIfCount.invoke(info); + if (count instanceof Integer) { + map.put("interfaceCount", count); + } + } catch (ReflectiveOperationException ignored) { + map.put("interfaceCount", 0); + } + return map; + } + + /** 将原生 {@code UsbSerial} 元素转为 Map,仅暴露 Dart 侧需要的信息。 */ + private Map serialPortToMap(@Nullable Object port, int fallbackIndex) { + Map map = new HashMap<>(); + map.put("portIndex", fallbackIndex); + if (port == null) { + return map; + } + try { + Method getIndex = port.getClass().getMethod("getSerialPortIndex"); + Object index = getIndex.invoke(port); + if (index instanceof Integer) { + map.put("portIndex", index); + } + } catch (ReflectiveOperationException ignored) { + // 字段可选,保持 fallbackIndex。 + } + try { + Method getPath = port.getClass().getMethod("getDevicePath"); + Object path = getPath.invoke(port); + map.put("devicePath", path == null ? null : path.toString()); + } catch (ReflectiveOperationException ignored) { + // 字段可选,忽略。 + } + try { + Method getName = port.getClass().getMethod("getDriverName"); + Object name = getName.invoke(port); + map.put("driverName", name == null ? null : name.toString()); + } catch (ReflectiveOperationException ignored) { + // 字段可选,忽略。 + } + return map; + } } diff --git a/android/src/test/java/com/xiarui/ch934x_serial/Ch934xSerialPluginTest.java b/android/src/test/java/com/xiarui/ch934x_serial/Ch934xSerialPluginTest.java index efc5ee9..b4104de 100644 --- a/android/src/test/java/com/xiarui/ch934x_serial/Ch934xSerialPluginTest.java +++ b/android/src/test/java/com/xiarui/ch934x_serial/Ch934xSerialPluginTest.java @@ -1,29 +1,26 @@ package com.xiarui.ch934x_serial; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; + +import org.junit.Test; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -import org.junit.Test; /** - * This demonstrates a simple unit test of the Java portion of this plugin's implementation. + * 验证 [Ch934xSerialPlugin] 在收到未实现方法时返回 notImplemented。 * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + *

当前插件通过反射调用 CH934X SDK,在标准 JVM 单元测试环境 + * 下没有真实 USB 设备,因此我们仅校验"未实现"分支,以保证 + * 编译期 API 表面稳定。集成测试需要在真机上运行。 */ - public class Ch934xSerialPluginTest { - @Test - public void onMethodCall_getPlatformVersion_returnsExpectedValue() { - Ch934xSerialPlugin plugin = new Ch934xSerialPlugin(); - final MethodCall call = new MethodCall("getPlatformVersion", null); - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - plugin.onMethodCall(call, mockResult); - - verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE); - } + @Test + public void unknownMethodReturnsNotImplemented() { + Ch934xSerialPlugin plugin = new Ch934xSerialPlugin(); + final MethodCall call = new MethodCall("__not_exists__", null); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + plugin.onMethodCall(call, mockResult); + } } diff --git a/docs/CH934X_Plugin_使用说明.md b/docs/CH934X_Plugin_使用说明.md new file mode 100644 index 0000000..c21647f --- /dev/null +++ b/docs/CH934X_Plugin_使用说明.md @@ -0,0 +1,356 @@ +# ch934x_serial 插件使用说明 + +`ch934x_serial` 是基于南京沁恒微电子 **CH934X 系列** USB 转多串口芯片 +Android SDK 封装的 Flutter 插件,文档面向其他 Flutter 开发者,介绍 +如何把它集成到自己的应用中并使用全部对外 API。 + +> 全部接口语义与官方 Android 文档 `docs/CH934X_Android_开发说明.md` +> 保持一致,本说明不再重复其原始描述,而是说明在 Flutter 中如何调用。 + +--- + +## 1. 插件概述 + +- **支持的平台:** Android(已实现,iOS / Web / Desktop 暂不支持)。 +- **主要能力:** + - CH934X 设备枚举、序列号读取、芯片类型识别 + - 多串口打开 / 关闭 + - 字节级串口读写 + - GPIO 输出 / 输入 + - Modem 控制 (DTR/RTS) 与状态 (CTS/DSR/RI/DCD) 读取 + - 设备拔出等异常事件回调 +- **底层依赖:** 沁恒官方 `CH934XLib.jar`(插件随包发布,位于 + `android/libs/CH934XLib.jar`),通过反射方式桥接,无需在调用方 + 业务代码中额外处理。 + +--- + +## 2. 集成步骤 + +### 2.1 在 `pubspec.yaml` 中加入依赖 + +```yaml +dependencies: + flutter: + sdk: flutter + ch934x_serial: ^1.0.0 +``` + +执行 `flutter pub get` 完成依赖拉取。 + +### 2.2 Android 工程准备 + +1. 确认 `android/app/build.gradle` 中 `minSdk >= 24`,CH934X SDK 不支持 + 更低版本。 +2. 在 `android/app/src/main/AndroidManifest.xml` 中声明 USB Host 能力: + + ```xml + + ``` + +3. **运行时申请 USB 权限**。本插件不主动申请权限,需业务方调用 + Android `UsbManager` 申请并接收广播,例如在 `MainActivity.onCreate` + 中: + + ```kotlin + private val actionDevicePermission = "com.example.USB_PERMISSION" + private val usbPermissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != actionDevicePermission) return + val device: UsbDevice? = + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + val granted = + intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (granted && device != null) { + // 此处可继续打开串口 + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + val pendingIntent = PendingIntent.getBroadcast( + this, 0, Intent(actionDevicePermission), 0 + ) + registerReceiver(usbPermissionReceiver, IntentFilter(actionDevicePermission)) + usbManager.deviceList.values.forEach { device -> + usbManager.requestPermission(device, pendingIntent) + } + } + ``` + + 也可以监听 `UsbManager.ACTION_USB_DEVICE_ATTACHED` 让系统在插入设备 + 时主动弹出授权框。 + +### 2.3 第一次调用 + +```dart +import 'package:ch934x_serial/ch934x_serial.dart'; + +Future scan() async { + final plugin = Ch934xSerial(); + final devices = await plugin.getDeviceList(); + for (final d in devices) { + debugPrint('发现设备: VID=0x${d.vendorId.toRadixString(16)} ' + 'SN=${d.serialNumber}'); + } +} +``` + +> 如果调用后列表为空,请先确认已经完成第 2.2 步的 USB 权限申请。 + +--- + +## 3. API 接口说明 + +所有 API 都挂在 `Ch934xSerial` 单例上,命名风格与原 SDK 文档保持一致, +参数使用 `int` 表示芯片/引脚编号,数据以 `Uint8List` 形式传递。 + +### 3.1 设备查找 + +| Dart 方法 | 原 SDK 接口 | 说明 | +| --------- | ----------- | ---- | +| `getDeviceList()` | `UsbHelper.getCH934XDeviceList` | 获取全部 CH934X 设备 | +| `getSerialNumber(deviceId)` | `UsbHelper.CH934XSerialNum` | 获取指定设备的序列号 | +| `getDeviceType(deviceId)` | `UsbHelper.CH934XDeviceType` | 获取设备类型常量 | +| `getSerialPortList(deviceId, interfaceNumber: n)` | `UsbHelper.getCH934XSerialPortList` | 获取设备串口列表 | + +返回类型: + +- `getDeviceList()` → `List` +- `getSerialPortList(...)` → `List` +- `Ch934xDeviceInfo` 包含 `deviceId / vendorId / productId / deviceType / + serialNumber / productName / manufacturerName / interfaceCount / + serialPorts`,可通过 `isCh934x` 判定是否被识别为 CH934X 设备。 +- `deviceType` 取值为 `Ch934xDeviceType` 中的常量(`ch9344`、`ch9344L`、 + `ch9350`、`ch9348Q`、`ch9342`、`ch934xOther`,未识别为 `unknown = -1`)。 + +### 3.2 设备打开与关闭 + +```dart +final target = Ch934xPortTarget( + deviceId: device.deviceId, + interfaceNumber: 0, // 多数设备只有 1 个接口 + serialPortIndex: port.portIndex, +); +final ok = await plugin.openPort(target); +if (!ok) { + debugPrint('打开失败'); + return; +} +// ... 进行业务通信 +await plugin.closePort(); +``` + +- `Ch934xPortTarget` 封装了 `deviceId` / `interfaceNumber` / + `serialPortIndex` 三个参数,可通过 `toMap()` 调试其字段。 +- 同一会话仅保留最近一次打开的串口对象,与原 SDK 行为一致;打开新 + 串口前请先 `closePort()` 或在 finally 块中清理。 + +### 3.3 串口读写 + +```dart +// 写入 +final bytes = Uint8List.fromList([0x01, 0x02, 0x03]); +final written = await plugin.write(bytes); +debugPrint('写入字节数: $written'); + +// 读取(单次) +final recv = await plugin.read(1024); +if (recv.isEmpty) debugPrint('暂无数据'); + +// 持续接收:使用 dataStream +final sub = plugin.dataStream(chunkSize: 1024).listen((chunk) { + debugPrint('收到: $chunk'); +}); +// 取消订阅 +await sub.cancel(); +``` + +- `read(length)` 返回 `Uint8List`,无数据或失败时为空。 +- `write(data)` 返回实际写入字节数,失败时为 0。 +- `dataStream` 默认每 20ms 轮询一次,可调整 `interval` 与 `chunkSize`。 + 业务方在 widget dispose 时记得 `cancel` 订阅并 `closePort`。 + +### 3.4 GPIO 接口 + +```dart +final ok = await plugin.setGpioOutput(gpioNumber: 0, level: 1); +final value = await plugin.getGpioInput(0); // 负值表示失败 +``` + +- `gpioNumber` 与 `level` 与原文档保持一致(0/1)。 +- `getGpioInput` 失败时返回 -1,业务方需要自行处理。 + +### 3.5 Modem 控制接口 + +```dart +await plugin.setModemControl(dtr: 1, rts: 0); +final status = await plugin.getModemStatus(); +if (ModemStatus.isSet(status, ModemStatus.cts)) { + debugPrint('CTS 高电平'); +} +``` + +- `ModemStatus` 暴露位掩码常量 `cts / dsr / ri / dcd` 与工具方法 + `isSet(status, mask)`,取值与文档表格一致。 +- `getModemStatus()` 返回 0 时表示无有效状态,业务方注意判空。 + +### 3.6 异常回调 + +```dart +final sub = await plugin.setExceptionCallback((event) { + debugPrint('设备异常: ${event.type} ${event.message}'); + // 业务方应主动关闭串口、刷新设备列表或提示用户重新插拔 +}); + +// 主动取消监听 +await sub.cancel(); +``` + +- 异常类型见 `Ch934xExceptionType`(`deviceDetached / ioError / sdk / + unknown`)。 +- 返回的 `StreamSubscription` 需要在合适时机 cancel,以便释放监听 + 与平台资源。 + +--- + +## 4. 完整示例 + +下面给出一个最小可运行示例,演示"扫描 → 打开 → 收发 → 关闭"的完整 +流程,完整可交互的 demo 见 `example/lib/main.dart`。 + +```dart +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:ch934x_serial/ch934x_serial.dart'; + +class SerialBridge { + SerialBridge() : _plugin = Ch934xSerial(); + + final Ch934xSerial _plugin; + StreamSubscription? _exceptionSub; + StreamSubscription? _dataSub; + + Future connect(Ch934xDeviceInfo device, Ch934xSerialPortInfo port) async { + final opened = await _plugin.openPort( + Ch934xPortTarget( + deviceId: device.deviceId, + interfaceNumber: 0, + serialPortIndex: port.portIndex, + ), + ); + if (!opened) throw StateError('串口打开失败'); + + _exceptionSub = await _plugin.setExceptionCallback((e) { + // 设备拔出时通常会触发 deviceDetached。 + print('异常: $e'); + }); + + _dataSub = _plugin.dataStream().listen((chunk) { + print('接收: $chunk'); + }); + } + + Future sendString(String s) async { + final bytes = Uint8List.fromList(s.codeUnits); + await _plugin.write(bytes); + } + + Future dispose() async { + await _dataSub?.cancel(); + await _exceptionSub?.cancel(); + await _plugin.closePort(); + } +} +``` + +--- + +## 5. 常见问题(FAQ) + +**Q1. `getDeviceList()` 返回空数组。** +- 确认已声明 ``。 +- 确认 Android `UsbManager` 已对目标设备授权(系统会弹出对话框,需要 + 用户点击"允许")。 +- 确认 OTG 数据线连接稳固,并尝试调用 `setExceptionCallback` 监听 + `deviceDetached` 事件,排查设备是否被系统频繁弹出。 + +**Q2. `openPort()` 返回 false。** +- 多数情况是 USB 权限未授予;请在 `UsbManager.requestPermission` + 返回 true 后再调用 `openPort`。 +- 如果目标设备具有多个接口,请尝试修改 `interfaceNumber`。 +- 确认设备中至少有一个 `Ch934xSerialPortInfo`(`getSerialPortList` + 返回),否则原 SDK 也无法打开。 + +**Q3. `read()` 一直返回空数组。** +- 确认对端设备正在发送数据,且波特率/校验位等参数与原 SDK 默认值 + 一致(`CH934XLib` 提供独立的 `setConfig` 接口,本插件当前未做 + 封装,需要时可扩展原 SDK 反射调用)。 +- 检查线序:RX/TX 是否接反,以及硬件流控是否正确。 + +**Q4. `setExceptionCallback` 没有触发。** +- 本插件仅在原生层主动推送时才会触发回调,目前沁恒 SDK 在设备热拔 + 插场景下会自动调用,其他异常(超时、CRC 错误等)可能不会触发。 + 如需丰富事件类型,可在原生层 `Ch934xSerialPlugin.java` 中扩展 + `MethodChannel.invokeMethod("onException", payload)` 上报。 + +**Q5. 是否支持 iOS / 桌面 / Web?** +- 当前仅在 Android 端验证通过;`CH934XLib.jar` 由沁恒官方提供 + Android 端 SDK,其他平台需要厂商另行提供或自行实现。 + +**Q6. 如何做单元测试?** +- 注入自定义的 `Ch934xSerialPlatform` 即可: + + ```dart + class FakePlatform extends Ch934xSerialPlatform + with MockPlatformInterfaceMixin { + @override + Future> getDeviceList() async => const []; + // ... 其它方法按需返回 + } + + Ch934xSerialPlatform.instance = FakePlatform(); + final plugin = Ch934xSerial(); + ``` + + 插件仓库的 `test/ch934x_serial_test.dart` 给出了完整 mock 示例。 + +--- + +## 6. 接口总览 + +下表汇总了插件中暴露的全部 API,具体调用示例见上文第 3 节。 + +| Dart 方法 | 文档编号 | 分类 | +| --------- | -------- | ---- | +| `getDeviceList` | 4.1.4 | 设备查找 | +| `getSerialNumber` | 4.1.1 | 设备查找 | +| `getDeviceType` | 4.1.2 | 设备查找 | +| `getSerialPortList` | 4.1.3 | 设备查找 | +| `openPort` | 5.1.1 | 设备打开 | +| `closePort` | 5.2.1 | 设备关闭 | +| `read` | 6.1.1 | 串口读写 | +| `write` | 6.2.1 | 串口读写 | +| `setGpioOutput` | 7.1.1 | GPIO | +| `getGpioInput` | 7.1.2 | GPIO | +| `setModemControl` | 7.2.1 | Modem | +| `getModemStatus` | 7.2.2 | Modem | +| `setExceptionCallback` | 7.3.1 | 异常 | +| `dataStream` | 6.1 增强 | 串口流(插件新增) | + +--- + +## 7. 反馈与贡献 + +遇到问题请提供: + +- 复现步骤(设备型号、Android 版本、是否开启 USB 调试) +- 完整日志(建议使用 `adb logcat` 过滤 `ch934x_serial` 标签) +- 期望结果 vs 实际结果 + +提交 Issue 时附上以上信息可以大幅加快排查速度。 diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9bc36a9..bd3120a 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + - - - diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index f7e0dc2..902ce90 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -1,24 +1,20 @@ -// This is a basic Flutter integration test. +// CH934X 插件的集成测试入口。 // -// Since integration tests run in a full Flutter application, they can interact -// with the host side of a plugin implementation, unlike Dart unit tests. -// -// For more information about Flutter integration tests, please see -// https://flutter.dev/to/integration-testing - -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; +// 集成测试运行在完整的 Flutter 应用中,可以与原生层通信; +// 当前插件主要覆盖 Android 平台,因此以下用例仅在连接真实 +// USB 设备时才有意义。CI 中通常会跳过该测试。 import 'package:ch934x_serial/ch934x_serial.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('getPlatformVersion test', (WidgetTester tester) async { + testWidgets('plugin 暴露 deviceList API', (tester) async { final Ch934xSerial plugin = Ch934xSerial(); - final String? version = await plugin.getPlatformVersion(); - // The version string depends on the host platform running the test, so - // just assert that some non-empty string is returned. - expect(version?.isNotEmpty, true); + final devices = await plugin.getDeviceList(); + // 集成测试环境下可能没有真实设备,允许为空。 + expect(devices, isA>()); }); } diff --git a/example/lib/main.dart b/example/lib/main.dart index 989a7e4..c541086 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,58 +1,311 @@ -import 'package:flutter/material.dart'; import 'dart:async'; +import 'dart:typed_data'; -import 'package:flutter/services.dart'; import 'package:ch934x_serial/ch934x_serial.dart'; +import 'package:flutter/material.dart'; +/// CH934X 插件示例应用,演示: +/// 1. 设备查找; +/// 2. 串口打开/关闭; +/// 3. 数据发送与接收(GPIO/Modem 控制以按钮形式呈现)。 void main() { - runApp(const MyApp()); + runApp(const Ch934xSerialExampleApp()); } -class MyApp extends StatefulWidget { - const MyApp({super.key}); - - @override - State createState() => _MyAppState(); -} - -class _MyAppState extends State { - String _platformVersion = 'Unknown'; - final _ch934xSerialPlugin = Ch934xSerial(); - - @override - void initState() { - super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. - try { - platformVersion = - await _ch934xSerialPlugin.getPlatformVersion() ?? 'Unknown platform version'; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); - } +class Ch934xSerialExampleApp extends StatelessWidget { + const Ch934xSerialExampleApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - home: Scaffold( - appBar: AppBar(title: const Text('Plugin example app')), - body: Center(child: Text('Running on: $_platformVersion\n')), + title: 'CH934X Serial Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: const Ch934xSerialExamplePage(), + ); + } +} + +class Ch934xSerialExamplePage extends StatefulWidget { + const Ch934xSerialExamplePage({super.key}); + + @override + State createState() => + _Ch934xSerialExamplePageState(); +} + +class _Ch934xSerialExamplePageState extends State { + final Ch934xSerial _plugin = Ch934xSerial(); + final TextEditingController _commandController = + TextEditingController(text: '*IDN?\n'); + + List _devices = const []; + Ch934xDeviceInfo? _selectedDevice; + Ch934xSerialPortInfo? _selectedPort; + StreamSubscription? _exceptionSubscription; + StreamSubscription? _dataSubscription; + + String _status = '未连接'; + String _receivedBuffer = ''; + bool _portOpened = false; + + @override + void initState() { + super.initState(); + _bindExceptionCallback(); + _refreshDeviceList(); + } + + @override + void dispose() { + _exceptionSubscription?.cancel(); + _dataSubscription?.cancel(); + if (_portOpened) { + // 关闭串口,保证释放 USB 接口。 + _plugin.closePort(); + } + _commandController.dispose(); + super.dispose(); + } + + /// 注册异常回调(对应 7.3.1)。 + Future _bindExceptionCallback() async { + _exceptionSubscription = await _plugin.setExceptionCallback((event) { + if (!mounted) return; + setState(() { + _status = '设备异常: $event'; + _portOpened = false; + _dataSubscription?.cancel(); + _dataSubscription = null; + }); + }); + } + + /// 触发设备列表刷新(对应 4.1.4)。 + Future _refreshDeviceList() async { + try { + final devices = await _plugin.getDeviceList(); + setState(() { + _devices = devices; + _status = '已扫描到 ${devices.length} 台 CH934X 设备'; + }); + } on Exception catch (e) { + setState(() => _status = '设备扫描失败: $e'); + } + } + + Future _openPort() async { + final device = _selectedDevice; + final port = _selectedPort; + if (device == null || port == null) { + setState(() => _status = '请先选择设备与串口'); + return; + } + final target = Ch934xPortTarget( + deviceId: device.deviceId, + interfaceNumber: device.interfaceCount > 0 ? 0 : 0, + serialPortIndex: port.portIndex, + ); + final ok = await _plugin.openPort(target); + if (!ok) { + setState(() => _status = '打开串口失败,请确认已授予 USB 权限'); + return; + } + setState(() { + _portOpened = true; + _status = '串口 ${port.portIndex} 已打开'; + }); + _startReceiving(); + } + + Future _closePort() async { + await _dataSubscription?.cancel(); + _dataSubscription = null; + final ok = await _plugin.closePort(); + setState(() { + _portOpened = false; + _status = ok ? '串口已关闭' : '关闭串口失败'; + }); + } + + /// 启动数据流订阅(基于 [Ch934xSerial.dataStream])。 + void _startReceiving() { + _dataSubscription?.cancel(); + _dataSubscription = _plugin.dataStream().listen((chunk) { + final text = String.fromCharCodes(chunk); + setState(() => _receivedBuffer = '$_receivedBuffer$text'); + }); + } + + /// 发送命令,展示 [Ch934xSerial.write] 的用法。 + Future _sendCommand() async { + if (!_portOpened) { + setState(() => _status = '请先打开串口'); + return; + } + final data = Uint8List.fromList(_commandController.text.codeUnits); + final written = await _plugin.write(data); + setState(() => _status = '已写入 $written 字节'); + } + + /// 查询 Modem 状态,展示 7.2.2 接口。 + Future _queryModemStatus() async { + if (!_portOpened) return; + final status = await _plugin.getModemStatus(); + setState(() { + _status = 'Modem 状态位: 0x${status.toRadixString(16).padLeft(2, '0')} ' + '(CTS=${ModemStatus.isSet(status, ModemStatus.cts)}, ' + 'DSR=${ModemStatus.isSet(status, ModemStatus.dsr)})'; + }); + } + + /// 控制 DTR/RTS,展示 7.2.1 接口。 + Future _toggleModem() async { + if (!_portOpened) return; + final ok = await _plugin.setModemControl(dtr: 1, rts: 1); + setState(() => _status = ok ? 'DTR/RTS 置高' : 'Modem 控制失败'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('CH934X Serial Example'), + actions: [ + IconButton( + tooltip: '刷新设备列表', + icon: const Icon(Icons.refresh), + onPressed: _refreshDeviceList, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + Text('状态: $_status'), + const SizedBox(height: 8), + if (_devices.isEmpty) + const Card( + child: ListTile( + leading: Icon(Icons.usb_off), + title: Text('未发现 CH934X 设备'), + subtitle: Text('请确认 OTG 已连接,并授予 USB 权限'), + ), + ) + else + DropdownButton( + isExpanded: true, + value: _selectedDevice, + hint: const Text('选择 CH934X 设备'), + items: _devices + .map( + (d) => DropdownMenuItem( + value: d, + child: Text( + 'VID=0x${d.vendorId.toRadixString(16)} ' + 'PID=0x${d.productId.toRadixString(16)} ' + 'SN=${d.serialNumber ?? "-"}', + ), + ), + ) + .toList(), + onChanged: (device) { + setState(() { + _selectedDevice = device; + _selectedPort = device?.serialPorts.isNotEmpty == true + ? device!.serialPorts.first + : null; + }); + }, + ), + if (_selectedDevice?.serialPorts.isNotEmpty == true) ...[ + const SizedBox(height: 8), + DropdownButton( + isExpanded: true, + value: _selectedPort, + hint: const Text('选择串口'), + items: _selectedDevice!.serialPorts + .map( + (p) => DropdownMenuItem( + value: p, + child: Text('port #${p.portIndex}'), + ), + ) + .toList(), + onChanged: (p) => setState(() => _selectedPort = p), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _portOpened ? null : _openPort, + icon: const Icon(Icons.power_settings_new), + label: const Text('打开'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _portOpened ? _closePort : null, + icon: const Icon(Icons.close), + label: const Text('关闭'), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _commandController, + decoration: const InputDecoration( + labelText: '发送数据', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _sendCommand, + icon: const Icon(Icons.send), + label: const Text('写入'), + ), + OutlinedButton.icon( + onPressed: _queryModemStatus, + icon: const Icon(Icons.info_outline), + label: const Text('查询 Modem'), + ), + OutlinedButton.icon( + onPressed: _toggleModem, + icon: const Icon(Icons.cable), + label: const Text('DTR/RTS=1'), + ), + OutlinedButton.icon( + onPressed: () async { + if (!_portOpened) return; + final v = await _plugin.getGpioInput(0); + if (!mounted) return; + setState(() => _status = 'GPIO0 = $v'); + }, + icon: const Icon(Icons.input), + label: const Text('读取 GPIO0'), + ), + ], + ), + const SizedBox(height: 16), + Text( + '接收缓冲区:\n$_receivedBuffer', + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ), ), ); } diff --git a/example/pubspec.lock b/example/pubspec.lock index a569775..190501a 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -23,7 +23,7 @@ packages: path: ".." relative: true source: path - version: "0.0.1" + version: "1.0.0" characters: dependency: transitive description: diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 9e04ad6..1cbab1d 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,27 +1,15 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// 示例应用 Widget 测试,验证应用能正常构建出主入口。 +import 'package:ch934x_serial_example/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ch934x_serial_example/main.dart'; - void main() { - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('App boots and shows the example title', (tester) async { + await tester.pumpWidget(const Ch934xSerialExampleApp()); + await tester.pump(); - // Verify that platform version is retrieved. - expect( - find.byWidgetPredicate( - (Widget widget) => - widget is Text && widget.data!.startsWith('Running on:'), - ), - findsOneWidget, - ); + expect(find.text('CH934X Serial Example'), findsWidgets); + expect(find.byType(MaterialApp), findsOneWidget); }); } diff --git a/lib/ch934x_serial.dart b/lib/ch934x_serial.dart index 4340a1b..8666dc0 100644 --- a/lib/ch934x_serial.dart +++ b/lib/ch934x_serial.dart @@ -1,8 +1,133 @@ +export 'src/models/models.dart'; +import 'dart:async'; +import 'dart:typed_data'; + +import 'ch934x_serial_method_channel.dart'; import 'ch934x_serial_platform_interface.dart'; +import 'src/models/models.dart'; +/// CH934X 插件的对外门面类。 +/// +/// 内部委托 [Ch934xSerialPlatform] 真正执行平台调用, +/// 在 Android 上默认走 [MethodChannelCh934xSerial]。 +/// +/// 命名/语义与官方 Android SDK 文档保持一致;若希望 +/// 监听串口数据流,可使用 [dataStream] 配合 [read]。 class Ch934xSerial { - Future getPlatformVersion() { - return Ch934xSerialPlatform.instance.getPlatformVersion(); + /// 使用默认平台实现构造。 + Ch934xSerial() : _platform = Ch934xSerialPlatform.instance; + + /// 注入自定义平台实现,常用于单元测试。 + Ch934xSerial.withPlatform(Ch934xSerialPlatform platform) + : _platform = platform; + + final Ch934xSerialPlatform _platform; + + // --------------------------------------------------------------------------- + // 设备查找 + // --------------------------------------------------------------------------- + + /// 获取所有已连接的 CH934X 设备信息。 + Future> getDeviceList() => + _platform.getDeviceList(); + + /// 获取指定设备序列号;非 CH934X 设备时返回 null。 + Future getSerialNumber(int deviceId) => + _platform.getSerialNumber(deviceId); + + /// 获取指定设备类型,取值见 [Ch934xDeviceType]。 + Future getDeviceType(int deviceId) => + _platform.getDeviceType(deviceId); + + /// 获取指定设备的串口列表。 + Future> getSerialPortList( + int deviceId, { + required int interfaceNumber, + }) => + _platform.getSerialPortList( + deviceId, + interfaceNumber: interfaceNumber, + ); + + // --------------------------------------------------------------------------- + // 设备打开 / 关闭 + // --------------------------------------------------------------------------- + + /// 打开指定串口。 + Future openPort(Ch934xPortTarget target) => _platform.openPort(target); + + /// 关闭当前会话最近一次打开的串口。 + Future closePort() => _platform.closePort(); + + // --------------------------------------------------------------------------- + // 串口读写 + // --------------------------------------------------------------------------- + + /// 阻塞式读取,直到拿到 [length] 字节或缓冲区被填满。 + /// + /// 返回值为实际读到的字节;若底层无数据或读取失败,返回空。 + Future read(int length) => _platform.read(length); + + /// 写入数据,返回实际写入的字节数。 + Future write(Uint8List data) => _platform.write(data); + + /// 构造一个持续从串口拉取数据的 `Stream`。 + /// + /// 内部以 [interval] 为周期反复调用 [read];当底层无数据 + /// 时返回空缓冲区,消费者可据此判定是否需要结束订阅。 + Stream dataStream({ + int chunkSize = 1024, + Duration interval = const Duration(milliseconds: 20), + }) async* { + if (chunkSize <= 0) { + throw ArgumentError.value(chunkSize, 'chunkSize', '必须大于 0'); + } + while (true) { + final chunk = await _platform.read(chunkSize); + if (chunk.isNotEmpty) { + yield chunk; + } + await Future.delayed(interval); + } + } + + // --------------------------------------------------------------------------- + // GPIO + // --------------------------------------------------------------------------- + + /// 设置 GPIO 输出电平(0 或 1)。 + Future setGpioOutput({required int gpioNumber, required int level}) => + _platform.setGpioOutput(gpioNumber: gpioNumber, level: level); + + /// 读取 GPIO 输入电平;负值表示读取失败。 + Future getGpioInput(int gpioNumber) => + _platform.getGpioInput(gpioNumber); + + // --------------------------------------------------------------------------- + // Modem + // --------------------------------------------------------------------------- + + /// 设置 DTR / RTS 信号。 + Future setModemControl({required int dtr, required int rts}) => + _platform.setModemControl(dtr: dtr, rts: rts); + + /// 获取 Modem 状态位,可通过 [ModemStatus] 工具类解析。 + Future getModemStatus() => _platform.getModemStatus(); + + // --------------------------------------------------------------------------- + // 异常回调 + // --------------------------------------------------------------------------- + + /// 注册异常回调(例如设备拔出)。 + /// + /// 返回一个 [StreamSubscription],可在外层 dispose 时取消。 + Future> setExceptionCallback( + void Function(Ch934xException exception) onException, + ) async { + final controller = StreamController(); + final subscription = controller.stream.listen(onException); + await _platform.setExceptionCallback(controller.add); + return subscription; } } diff --git a/lib/ch934x_serial_method_channel.dart b/lib/ch934x_serial_method_channel.dart index 25b4b89..73333a7 100644 --- a/lib/ch934x_serial_method_channel.dart +++ b/lib/ch934x_serial_method_channel.dart @@ -1,19 +1,183 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'ch934x_serial_platform_interface.dart'; +import 'src/models/models.dart'; -/// An implementation of [Ch934xSerialPlatform] that uses method channels. +/// MethodChannel 实现的 [Ch934xSerialPlatform]。 +/// +/// 在 Android 端通过同一 MethodChannel 与原生层通信, +/// 命名规则:方法名使用下划线小写,字段名使用驼峰式以便 +/// 直接对应 Java 侧 Map 的 key。 class MethodChannelCh934xSerial extends Ch934xSerialPlatform { - /// The method channel used to interact with the native platform. + /// 测试时可被替换的 MethodChannel。 @visibleForTesting - final methodChannel = const MethodChannel('ch934x_serial'); + final MethodChannel methodChannel = + const MethodChannel('ch934x_serial'); + + /// 通知 Dart 侧的异常事件流,用于支持 `setExceptionCallback`。 + final StreamController _exceptionController = + StreamController.broadcast(); @override - Future getPlatformVersion() async { - final version = await methodChannel.invokeMethod( - 'getPlatformVersion', + Future> getDeviceList() async { + final raw = await methodChannel.invokeMethod>('getDeviceList'); + if (raw == null) { + return const []; + } + return raw + .whereType() + .map((e) => Ch934xDeviceInfo.fromMap(Map.from(e))) + .toList(growable: false); + } + + @override + Future getSerialNumber(int deviceId) { + return methodChannel.invokeMethod( + 'getSerialNumber', + {'deviceId': deviceId}, ); - return version; + } + + @override + Future getDeviceType(int deviceId) async { + final result = await methodChannel.invokeMethod( + 'getDeviceType', + {'deviceId': deviceId}, + ); + return result ?? Ch934xDeviceType.unknown; + } + + @override + Future> getSerialPortList( + int deviceId, { + required int interfaceNumber, + }) async { + final raw = await methodChannel.invokeMethod>( + 'getSerialPortList', + { + 'deviceId': deviceId, + 'interfaceNumber': interfaceNumber, + }, + ); + if (raw == null) { + return const []; + } + return raw + .whereType() + .map((e) => Ch934xSerialPortInfo.fromMap(Map.from(e))) + .toList(growable: false); + } + + @override + Future openPort(Ch934xPortTarget target) async { + final result = await methodChannel.invokeMethod( + 'openPort', + target.toMap(), + ); + return result ?? false; + } + + @override + Future closePort() async { + final result = await methodChannel.invokeMethod('closePort'); + return result ?? false; + } + + @override + Future read(int length) async { + if (length <= 0) { + return Uint8List(0); + } + final raw = await methodChannel.invokeMethod( + 'read', + {'length': length}, + ); + return raw ?? Uint8List(0); + } + + @override + Future write(Uint8List data) async { + if (data.isEmpty) { + return 0; + } + final result = await methodChannel.invokeMethod( + 'write', + {'data': data}, + ); + return result ?? 0; + } + + @override + Future setGpioOutput({ + required int gpioNumber, + required int level, + }) async { + final result = await methodChannel.invokeMethod( + 'setGpioOutput', + { + 'gpioNumber': gpioNumber, + 'level': level, + }, + ); + return result ?? false; + } + + @override + Future getGpioInput(int gpioNumber) async { + final result = await methodChannel.invokeMethod( + 'getGpioInput', + {'gpioNumber': gpioNumber}, + ); + return result ?? -1; + } + + @override + Future setModemControl({required int dtr, required int rts}) async { + final result = await methodChannel.invokeMethod( + 'setModemControl', + { + 'dtr': dtr, + 'rts': rts, + }, + ); + return result ?? false; + } + + @override + Future getModemStatus() async { + final result = await methodChannel.invokeMethod('getModemStatus'); + return result ?? 0; + } + + @override + Future setExceptionCallback( + void Function(Ch934xException exception) onException, + ) async { + _exceptionController.stream.listen(onException); + await methodChannel.invokeMethod('setExceptionCallback'); + } + + /// 由原生层主动调用的入口,对应文档 7.3.1 中的 `onException` 回调。 + /// + /// 必须在原生层将 `MethodChannel` 的 `setMethodCallHandler` 调通后 + /// 才会被触发;此方法在测试中也可直接调用,用于模拟异常事件。 + @visibleForTesting + void dispatchException({ + required int type, + String? message, + String? cause, + }) { + _exceptionController.add( + Ch934xException(type: type, message: message, cause: cause), + ); + } + + /// 释放内部资源,通常仅在测试或热重载场景下使用。 + @visibleForTesting + void dispose() { + _exceptionController.close(); } } diff --git a/lib/ch934x_serial_platform_interface.dart b/lib/ch934x_serial_platform_interface.dart index 8187c8f..85fa96e 100644 --- a/lib/ch934x_serial_platform_interface.dart +++ b/lib/ch934x_serial_platform_interface.dart @@ -1,29 +1,100 @@ +import 'dart:typed_data'; + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'ch934x_serial_method_channel.dart'; +import 'src/models/models.dart'; +/// CH934X 插件的平台无关抽象接口。 +/// +/// Dart 侧应面向此接口编程,具体实现由 Android 平台 +/// (MethodChannel) 提供;`set mockMethodCallHandler` 的 +/// 单元测试可以替换该实现以验证上层逻辑。 abstract class Ch934xSerialPlatform extends PlatformInterface { - /// Constructs a Ch934xSerialPlatform. + /// 构造 [Ch934xSerialPlatform]。 Ch934xSerialPlatform() : super(token: _token); static final Object _token = Object(); static Ch934xSerialPlatform _instance = MethodChannelCh934xSerial(); - /// The default instance of [Ch934xSerialPlatform] to use. - /// - /// Defaults to [MethodChannelCh934xSerial]. + /// 平台无关实现当前持有的具体后端。 static Ch934xSerialPlatform get instance => _instance; - /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [Ch934xSerialPlatform] when - /// they register themselves. + /// 替换具体实现,常用于单元测试。 static set instance(Ch934xSerialPlatform instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } - Future getPlatformVersion() { - throw UnimplementedError('platformVersion() has not been implemented.'); - } + // --------------------------------------------------------------------------- + // 设备查找 + // --------------------------------------------------------------------------- + + /// 获取所有已连接的 CH934X 设备信息(对应 4.1.4 `getCH934XDeviceList`)。 + Future> getDeviceList(); + + /// 获取指定设备的 CH934X 序列号(对应 4.1.1 `CH934XSerialNum`)。 + Future getSerialNumber(int deviceId); + + /// 获取指定设备类型(对应 4.1.2 `CH934XDeviceType`)。 + Future getDeviceType(int deviceId); + + /// 获取指定设备的串口列表(对应 4.1.3 `getCH934XSerialPortList`)。 + Future> getSerialPortList( + int deviceId, { + required int interfaceNumber, + }); + + // --------------------------------------------------------------------------- + // 设备打开 / 关闭 + // --------------------------------------------------------------------------- + + /// 初始化并打开指定串口(对应 5.1.1 `UsbSerial.init`)。 + Future openPort(Ch934xPortTarget target); + + /// 关闭当前线程/会话最近一次打开的串口(对应 5.2.1 `UsbSerial.close`)。 + Future closePort(); + + // --------------------------------------------------------------------------- + // 串口读写 + // --------------------------------------------------------------------------- + + /// 从串口读取数据(对应 6.1.1 `UsbSerial.read`)。 + Future read(int length); + + /// 向串口写入数据(对应 6.2.1 `UsbSerial.write`)。 + Future write(Uint8List data); + + // --------------------------------------------------------------------------- + // GPIO + // --------------------------------------------------------------------------- + + /// 设置 GPIO 输出(对应 7.1.1 `UsbSerial.setGpioOutput`)。 + Future setGpioOutput({required int gpioNumber, required int level}); + + /// 读取 GPIO 输入(对应 7.1.2 `UsbSerial.getGpioInput`)。 + Future getGpioInput(int gpioNumber); + + // --------------------------------------------------------------------------- + // Modem + // --------------------------------------------------------------------------- + + /// 设置 Modem 控制(对应 7.2.1 `UsbSerial.setModemControl`)。 + Future setModemControl({required int dtr, required int rts}); + + /// 获取 Modem 状态(对应 7.2.2 `UsbSerial.getModemStatus`)。 + Future getModemStatus(); + + // --------------------------------------------------------------------------- + // 异常回调 + // --------------------------------------------------------------------------- + + /// 注册异常回调(对应 7.3.1 `UsbSerial.setExceptionCallback`)。 + /// + /// 当原生层触发异常(例如设备拔出)时,会通过 + /// [onException] 中传入的回调通知调用方。 + Future setExceptionCallback( + void Function(Ch934xException exception) onException, + ); } diff --git a/lib/src/models/ch934x_device_info.dart b/lib/src/models/ch934x_device_info.dart new file mode 100644 index 0000000..5504892 --- /dev/null +++ b/lib/src/models/ch934x_device_info.dart @@ -0,0 +1,107 @@ +import 'ch934x_device_type.dart'; + +/// 单个 CH934X 设备所挂载串口的信息。 +/// +/// 对应 Android 端 `UsbHelper.getCH934XSerialPortList` 返 +/// 回的 `UsbSerial` 数组中每个元素的 Dart 描述,通常足以 +/// 用来调用 `UsbSerial.init` 打开对应的串口通道。 +class Ch934xSerialPortInfo { + const Ch934xSerialPortInfo({ + required this.portIndex, + this.devicePath, + this.driverName, + }); + + /// 串口在所属设备上的索引(从 0 开始)。 + final int portIndex; + + /// 底层串口节点路径(若原生层提供)。 + final String? devicePath; + + /// 驱动或端口名(若原生层提供)。 + final String? driverName; + + /// 从原生层返回的 Map 还原对象,字段缺失时使用安全默认值。 + factory Ch934xSerialPortInfo.fromMap(Map map) { + final indexValue = map['portIndex'] ?? map['serialPortIndex']; + return Ch934xSerialPortInfo( + portIndex: indexValue is int ? indexValue : 0, + devicePath: map['devicePath'] as String?, + driverName: map['driverName'] as String?, + ); + } +} + +/// 文档 4.1.4 中 `UsbHelper.getCH934XDeviceList` 返回的设备信息。 +/// +/// 字段命名遵循 Java 端的驼峰式命名,通过 `fromMap` 解析原生层结果。 +class Ch934xDeviceInfo { + const Ch934xDeviceInfo({ + required this.deviceId, + required this.vendorId, + required this.productId, + required this.deviceType, + this.serialNumber, + this.productName, + this.manufacturerName, + this.interfaceCount = 0, + this.serialPorts = const [], + }); + + /// Android `UsbDevice.getDeviceId()`。 + final int deviceId; + + /// USB 厂商 ID。 + final int vendorId; + + /// USB 产品 ID。 + final int productId; + + /// 通过 [Ch934xDeviceType] 中的常量值标识。 + final int deviceType; + + /// 通过 `UsbHelper.CH934XSerialNum` 获取的序列号;非 CH934X 设备时为 null。 + final String? serialNumber; + + /// 设备产品名(若原生层提供)。 + final String? productName; + + /// 设备厂商名(若原生层提供)。 + final String? manufacturerName; + + /// 该设备暴露的 USB 接口数量。 + final int interfaceCount; + + /// 关联的串口列表,部分设备可能为空。 + final List serialPorts; + + /// 判断当前设备是否被原生层识别为 CH934X 系列。 + bool get isCh934x => deviceType >= Ch934xDeviceType.ch9344 && + deviceType <= Ch934xDeviceType.ch934xOther; + + /// 从原生层返回值反序列化,容错处理缺失字段。 + factory Ch934xDeviceInfo.fromMap(Map map) { + final rawPorts = map['serialPorts']; + final ports = []; + if (rawPorts is List) { + for (final entry in rawPorts) { + if (entry is Map) { + ports.add( + Ch934xSerialPortInfo.fromMap(Map.from(entry)), + ); + } + } + } + return Ch934xDeviceInfo( + deviceId: (map['deviceId'] as int?) ?? 0, + vendorId: (map['vendorId'] as int?) ?? 0, + productId: (map['productId'] as int?) ?? 0, + deviceType: (map['deviceType'] as int?) ?? Ch934xDeviceType.unknown, + serialNumber: map['serialNumber'] as String?, + productName: map['productName'] as String?, + manufacturerName: map['manufacturerName'] as String?, + interfaceCount: (map['interfaceCount'] as int?) ?? 0, + serialPorts: ports, + ); + } +} diff --git a/lib/src/models/ch934x_device_type.dart b/lib/src/models/ch934x_device_type.dart new file mode 100644 index 0000000..b5de334 --- /dev/null +++ b/lib/src/models/ch934x_device_type.dart @@ -0,0 +1,28 @@ +/// CH934X 设备类型常量。 +/// +/// 来自文档 4.1.2 `UsbHelper.CH934XDeviceType` 的返回值,描述 +/// 枚举到的 USB 设备属于沁恒 CH934X 家族中的哪一颗芯片。 +class Ch934xDeviceType { + const Ch934xDeviceType._(); + + /// CH9344 芯片。 + static const int ch9344 = 0; + + /// CH9344L 芯片。 + static const int ch9344L = 1; + + /// CH9350 芯片。 + static const int ch9350 = 2; + + /// CH9348Q 芯片。 + static const int ch9348Q = 3; + + /// CH9342 芯片。 + static const int ch9342 = 4; + + /// 其他 CH934X 设备。 + static const int ch934xOther = 5; + + /// 未知或非 CH934X 设备。 + static const int unknown = -1; +} diff --git a/lib/src/models/ch934x_exception.dart b/lib/src/models/ch934x_exception.dart new file mode 100644 index 0000000..80a3d7a --- /dev/null +++ b/lib/src/models/ch934x_exception.dart @@ -0,0 +1,53 @@ +/// 设备拔出等异常事件类型常量。 +/// +/// 透传自 Android 端 `UsbSerial.ExceptionCallback.onException` +/// 的 `type` 参数,插件使用者可根据此值进行不同处理。 +class Ch934xExceptionType { + const Ch934xExceptionType._(); + + /// 未知异常。 + static const int unknown = 0; + + /// 设备被拔出。 + static const int deviceDetached = 1; + + /// 读写过程中发生 IO 错误。 + static const int ioError = 2; + + /// 原生 SDK 主动抛出的其他异常。 + static const int sdk = 3; +} + +/// `setExceptionCallback` 回调中的载荷,描述一次异常事件。 +class Ch934xException { + const Ch934xException({required this.type, this.message, this.cause}); + + /// 异常类型,取值见 [Ch934xExceptionType] 常量。 + final int type; + + /// 异常的文本描述(若原生层提供)。 + final String? message; + + /// 底层异常类名(若原生层提供)。 + final String? cause; + + /// 便于在日志/UI 中显示的描述,自动将类型转换为常量名。 + @override + String toString() { + final typeName = switch (type) { + Ch934xExceptionType.deviceDetached => 'deviceDetached', + Ch934xExceptionType.ioError => 'ioError', + Ch934xExceptionType.sdk => 'sdk', + _ => 'unknown', + }; + final buffer = StringBuffer('Ch934xException(type: $typeName'); + if (message != null && message!.isNotEmpty) { + buffer.write(', message: $message'); + } + if (cause != null && cause!.isNotEmpty) { + buffer.write(', cause: $cause'); + } + buffer.write(')'); + return buffer.toString(); + } +} diff --git a/lib/src/models/ch934x_port_target.dart b/lib/src/models/ch934x_port_target.dart new file mode 100644 index 0000000..9a1687d --- /dev/null +++ b/lib/src/models/ch934x_port_target.dart @@ -0,0 +1,36 @@ +/// 用于打开 CH934X 串口的目标描述,封装 init 接口需要的全部参数。 +/// +/// 取代文档示例代码中散落的 `device`、`interfaceNum`、 +/// `serialPortIndex`,便于在异步链中安全传递。 +class Ch934xPortTarget { + const Ch934xPortTarget({ + required this.deviceId, + required this.interfaceNumber, + required this.serialPortIndex, + }); + + /// Android `UsbDevice.getDeviceId()`。 + final int deviceId; + + /// CH934X 设备的接口号(文档 4.1.3 中的 `interfaceNum`)。 + final int interfaceNumber; + + /// 串口索引(文档 5.1.1 中的 `serialPortIndex`)。 + final int serialPortIndex; + + /// 等价的可序列化 Map,用于在 MethodChannel 中传递。 + Map toMap() => { + 'deviceId': deviceId, + 'interfaceNumber': interfaceNumber, + 'serialPortIndex': serialPortIndex, + }; + + /// 从 MethodChannel 回传的 Map 还原对象,字段缺失时回退到 0。 + factory Ch934xPortTarget.fromMap(Map map) { + return Ch934xPortTarget( + deviceId: (map['deviceId'] as int?) ?? 0, + interfaceNumber: (map['interfaceNumber'] as int?) ?? 0, + serialPortIndex: (map['serialPortIndex'] as int?) ?? 0, + ); + } +} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart new file mode 100644 index 0000000..70071ba --- /dev/null +++ b/lib/src/models/models.dart @@ -0,0 +1,5 @@ +export 'ch934x_device_info.dart'; +export 'ch934x_device_type.dart'; +export 'ch934x_exception.dart'; +export 'ch934x_port_target.dart'; +export 'modem_status.dart'; diff --git a/lib/src/models/modem_status.dart b/lib/src/models/modem_status.dart new file mode 100644 index 0000000..bf1844b --- /dev/null +++ b/lib/src/models/modem_status.dart @@ -0,0 +1,21 @@ +/// Modem 状态位掩码常量,对应文档 7.2.2 中 `getModemStatus` 的返回值。 +/// +/// 可与 `getModemStatus()` 返回值按位与来判定具体引脚电平。 +class ModemStatus { + const ModemStatus._(); + + /// CTS 状态位,值为 0x01。 + static const int cts = 0x01; + + /// DSR 状态位,值为 0x02。 + static const int dsr = 0x02; + + /// RI 状态位,值为 0x04。 + static const int ri = 0x04; + + /// DCD 状态位,值为 0x08。 + static const int dcd = 0x08; + + /// 读取指定状态位是否为高电平。 + static bool isSet(int status, int mask) => (status & mask) != 0; +} diff --git a/pubspec.yaml b/pubspec.yaml index 6155e04..f94ee49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: ch934x_serial -description: "A new Flutter project." -version: 0.0.1 -homepage: +description: "Flutter 插件,封装南京沁恒 CH934X 系列 USB 转串口芯片的 Android SDK,提供设备查找、串口读写、GPIO 与 Modem 控制等能力。" +version: 1.0.0 +homepage: https://example.com/ch934x_serial environment: sdk: ^3.11.5 diff --git a/test/ch934x_serial_method_channel_test.dart b/test/ch934x_serial_method_channel_test.dart index fc040d1..0d763a9 100644 --- a/test/ch934x_serial_method_channel_test.dart +++ b/test/ch934x_serial_method_channel_test.dart @@ -1,26 +1,100 @@ +import 'package:ch934x_serial/ch934x_serial_method_channel.dart'; +import 'package:ch934x_serial/src/models/models.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ch934x_serial/ch934x_serial_method_channel.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - MethodChannelCh934xSerial platform = MethodChannelCh934xSerial(); - const MethodChannel channel = MethodChannel('ch934x_serial'); + const channel = MethodChannel('ch934x_serial'); + late MethodChannelCh934xSerial platform; setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return '42'; - }); + platform = MethodChannelCh934xSerial(); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); + platform.dispose(); }); - test('getPlatformVersion', () async { - expect(await platform.getPlatformVersion(), '42'); + /// 显式替换 mock handler 并返回,后续 `await platform.xxx` 会触发该 handler。 + void mockCall(Future Function(MethodCall) handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return handler(call); + }); + } + + test('getDeviceList 解析为 Ch934xDeviceInfo 列表', () async { + mockCall((call) async { + expect(call.method, 'getDeviceList'); + return >[ + { + 'deviceId': 1, + 'vendorId': 0x1a86, + 'productId': 0xfe0c, + 'deviceType': Ch934xDeviceType.ch9344, + 'serialNumber': 'ABC', + 'interfaceCount': 1, + 'serialPorts': >[ + {'portIndex': 0}, + ], + }, + ]; + }); + final result = await platform.getDeviceList(); + expect(result, hasLength(1)); + expect(result.first.serialNumber, 'ABC'); + expect(result.first.serialPorts.single.portIndex, 0); + }); + + test('read 透传 length 字段并返回字节', () async { + mockCall((call) async { + expect(call.method, 'read'); + expect(call.arguments, {'length': 4}); + return Uint8List.fromList([1, 2, 3, 4]); + }); + final result = await platform.read(4); + expect(result, Uint8List.fromList([1, 2, 3, 4])); + }); + + test('read 接收非正长度直接返回空数组', () async { + // 未注册 mock handler,验证短路逻辑不调用原生层。 + final result = await platform.read(0); + expect(result, isEmpty); + }); + + test('write 将数据写入原生层并回传字节数', () async { + mockCall((call) async { + expect(call.method, 'write'); + expect((call.arguments as Map)['data'], isA()); + return 3; + }); + final written = await platform.write(Uint8List.fromList([9, 8, 7])); + expect(written, 3); + }); + + test('getModemStatus 默认回退到 0', () async { + mockCall((_) async => null); + expect(await platform.getModemStatus(), 0); + }); + + test('dispatchException 推送给 setExceptionCallback 订阅者', () async { + mockCall((call) async { + expect(call.method, 'setExceptionCallback'); + return null; + }); + final received = []; + await platform.setExceptionCallback(received.add); + platform.dispatchException( + type: Ch934xExceptionType.deviceDetached, + message: 'detached', + ); + // 等待 microtask 队列执行。 + await Future.delayed(Duration.zero); + expect(received, hasLength(1)); + expect(received.first.message, 'detached'); }); } diff --git a/test/ch934x_serial_test.dart b/test/ch934x_serial_test.dart index 71cfafa..7f1b4ab 100644 --- a/test/ch934x_serial_test.dart +++ b/test/ch934x_serial_test.dart @@ -1,28 +1,155 @@ -import 'package:flutter_test/flutter_test.dart'; +import 'dart:typed_data'; + import 'package:ch934x_serial/ch934x_serial.dart'; -import 'package:ch934x_serial/ch934x_serial_platform_interface.dart'; import 'package:ch934x_serial/ch934x_serial_method_channel.dart'; +import 'package:ch934x_serial/ch934x_serial_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -class MockCh934xSerialPlatform - with MockPlatformInterfaceMixin - implements Ch934xSerialPlatform { +/// 用 mock 替代真实平台,以便在宿主测试中验证上层调用。 +class _MockCh934xSerialPlatform extends Ch934xSerialPlatform + with MockPlatformInterfaceMixin { + _MockCh934xSerialPlatform({ + // ignore: unused_element_parameter + this.deviceList = const [], + // ignore: unused_element_parameter + this.serialNumber, + // ignore: unused_element_parameter + this.deviceType = Ch934xDeviceType.ch9344, + // ignore: unused_element_parameter + this.ports = const [], + // ignore: unused_element_parameter + this.openResult = true, + // ignore: unused_element_parameter + this.closeResult = true, + // ignore: unused_element_parameter + this.readBytes, + // ignore: unused_element_parameter + this.writeResult = 0, + // ignore: unused_element_parameter + this.gpioOutputResult = true, + // ignore: unused_element_parameter + this.gpioInput = 1, + // ignore: unused_element_parameter + this.modemControlResult = true, + // ignore: unused_element_parameter + this.modemStatus = ModemStatus.cts, + }); + + List deviceList; + String? serialNumber; + int deviceType; + List ports; + bool openResult; + bool closeResult; + Uint8List? readBytes; + int writeResult; + bool gpioOutputResult; + int gpioInput; + bool modemControlResult; + int modemStatus; + @override - Future getPlatformVersion() => Future.value('42'); + Future> getDeviceList() async => deviceList; + + @override + Future getSerialNumber(int deviceId) async => serialNumber; + + @override + Future getDeviceType(int deviceId) async => deviceType; + + @override + Future> getSerialPortList( + int deviceId, { + required int interfaceNumber, + }) async => + ports; + + @override + Future openPort(Ch934xPortTarget target) async => openResult; + + @override + Future closePort() async => closeResult; + + @override + Future read(int length) async => readBytes ?? Uint8List(0); + + @override + Future write(Uint8List data) async => writeResult; + + @override + Future setGpioOutput({ + required int gpioNumber, + required int level, + }) async => + gpioOutputResult; + + @override + Future getGpioInput(int gpioNumber) async => gpioInput; + + @override + Future setModemControl({required int dtr, required int rts}) async => + modemControlResult; + + @override + Future getModemStatus() async => modemStatus; + + @override + Future setExceptionCallback( + void Function(Ch934xException exception) onException, + ) async {} } void main() { - final Ch934xSerialPlatform initialPlatform = Ch934xSerialPlatform.instance; + TestWidgetsFlutterBinding.ensureInitialized(); - test('$MethodChannelCh934xSerial is the default instance', () { - expect(initialPlatform, isInstanceOf()); + test('默认平台实现是 MethodChannelCh934xSerial', () { + expect(Ch934xSerialPlatform.instance, isInstanceOf()); }); - test('getPlatformVersion', () async { - Ch934xSerial ch934xSerialPlugin = Ch934xSerial(); - MockCh934xSerialPlatform fakePlatform = MockCh934xSerialPlatform(); - Ch934xSerialPlatform.instance = fakePlatform; + test('Ch934xSerial 委托 platform 调用 getDeviceList', () async { + final mock = _MockCh934xSerialPlatform( + deviceList: const [ + Ch934xDeviceInfo( + deviceId: 1, + vendorId: 0x1a86, + productId: 0xfe0c, + deviceType: Ch934xDeviceType.ch9344, + serialNumber: 'SN-1', + ), + ], + ); + final plugin = Ch934xSerial.withPlatform(mock); + final list = await plugin.getDeviceList(); + expect(list.single.serialNumber, 'SN-1'); + }); - expect(await ch934xSerialPlugin.getPlatformVersion(), '42'); + test('ModemStatus.isSet 按位与工作', () { + const status = ModemStatus.cts | ModemStatus.dcd; + expect(ModemStatus.isSet(status, ModemStatus.cts), isTrue); + expect(ModemStatus.isSet(status, ModemStatus.dsr), isFalse); + }); + + test('Ch934xPortTarget.toMap 字段可被 MethodChannel 直接消费', () { + const target = Ch934xPortTarget( + deviceId: 2, + interfaceNumber: 1, + serialPortIndex: 3, + ); + expect(target.toMap(), { + 'deviceId': 2, + 'interfaceNumber': 1, + 'serialPortIndex': 3, + }); + }); + + test('Ch934xException.toString 汇总关键字段', () { + final ex = Ch934xException( + type: Ch934xExceptionType.deviceDetached, + message: '设备被拔出', + ); + expect(ex.toString(), contains('deviceDetached')); + expect(ex.toString(), contains('设备被拔出')); }); }