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 0000000..888ac41 Binary files /dev/null and b/android/libs/CH934XLib.jar differ 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('设备被拔出')); }); }