From c000eb12f8504919abddc3f44c33eee3c38cd320 Mon Sep 17 00:00:00 2001 From: leon <916117771@qq.com> Date: Tue, 21 Apr 2026 12:57:33 +0800 Subject: [PATCH] 1 --- .gitignore | 31 + .metadata | 30 + .planning/codebase/ARCHITECTURE.md | 198 +++ .planning/codebase/CONCERNS.md | 167 +++ .planning/codebase/CONVENTIONS.md | 237 +++ .planning/codebase/INTEGRATIONS.md | 95 ++ .planning/codebase/STACK.md | 104 ++ .planning/codebase/STRUCTURE.md | 217 +++ .planning/codebase/TESTING.md | 232 +++ CHANGELOG.md | 3 + CLAUDE.md | 105 ++ LICENSE | 1 + README.md | 1 + analysis_options.yaml | 4 + android/.gitignore | 9 + android/build.gradle | 59 + android/libs/CH34XUartDriver.jar | Bin 0 -> 133882 bytes android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 7 + .../example/ch34/Ch34DataStreamHandler.java | 110 ++ .../example/ch34/Ch34ModemStreamHandler.java | 68 + .../java/com/example/ch34/Ch34Plugin.java | 892 ++++++++++++ .../com/example/ch34/Ch34TypeConverter.java | 134 ++ .../ch34/Ch34UsbStateStreamHandler.java | 114 ++ .../java/com/example/ch34/Ch34PluginTest.java | 29 + docs/CH34X-api_docs.md | 703 +++++++++ docs/USAGE_GUIDE.md | 1265 +++++++++++++++++ example/.gitignore | 43 + example/README.md | 16 + example/analysis_options.yaml | 28 + example/android/.gitignore | 13 + example/android/app/build.gradle | 58 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 72 + .../example/ch34_example/MainActivity.java | 6 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle | 18 + example/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + example/android/settings.gradle | 25 + .../plugin_integration_test.dart | 13 + example/lib/main.dart | 189 +++ example/pubspec.lock | 283 ++++ example/pubspec.yaml | 85 ++ example/test/widget_test.dart | 27 + lib/ch34.dart | 68 + lib/ch34_method_channel.dart | 5 + lib/ch34_platform_interface.dart | 5 + lib/src/ch34_manager.dart | 491 +++++++ lib/src/ch34_method_channel.dart | 589 ++++++++ lib/src/ch34_platform_interface.dart | 418 ++++++ lib/src/types/ch34_types.dart | 359 +++++ pubspec.yaml | 25 + test/ch34_method_channel_test.dart | 31 + test/ch34_test.dart | 205 +++ 64 files changed, 7970 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/build.gradle create mode 100644 android/libs/CH34XUartDriver.jar create mode 100644 android/settings.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/java/com/example/ch34/Ch34DataStreamHandler.java create mode 100644 android/src/main/java/com/example/ch34/Ch34ModemStreamHandler.java create mode 100644 android/src/main/java/com/example/ch34/Ch34Plugin.java create mode 100644 android/src/main/java/com/example/ch34/Ch34TypeConverter.java create mode 100644 android/src/main/java/com/example/ch34/Ch34UsbStateStreamHandler.java create mode 100644 android/src/test/java/com/example/ch34/Ch34PluginTest.java create mode 100644 docs/CH34X-api_docs.md create mode 100644 docs/USAGE_GUIDE.md create mode 100644 example/.gitignore create mode 100644 example/README.md create mode 100644 example/analysis_options.yaml create mode 100644 example/android/.gitignore create mode 100644 example/android/app/build.gradle create mode 100644 example/android/app/src/debug/AndroidManifest.xml create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/java/com/example/ch34_example/MainActivity.java create mode 100644 example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/values-night/styles.xml create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/app/src/profile/AndroidManifest.xml create mode 100644 example/android/build.gradle create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle create mode 100644 example/integration_test/plugin_integration_test.dart create mode 100644 example/lib/main.dart create mode 100644 example/pubspec.lock create mode 100644 example/pubspec.yaml create mode 100644 example/test/widget_test.dart create mode 100644 lib/ch34.dart create mode 100644 lib/ch34_method_channel.dart create mode 100644 lib/ch34_platform_interface.dart create mode 100644 lib/src/ch34_manager.dart create mode 100644 lib/src/ch34_method_channel.dart create mode 100644 lib/src/ch34_platform_interface.dart create mode 100644 lib/src/types/ch34_types.dart create mode 100644 pubspec.yaml create mode 100644 test/ch34_method_channel_test.dart create mode 100644 test/ch34_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b46d36a --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ + +.omc/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..a77bebb --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + - platform: android + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..aaf690d --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,198 @@ +# Architecture + +**Analysis Date:** 2026-04-16 + +## Pattern Overview + +**Overall:** Flutter Platform Plugin (Federated Plugin Pattern) + +This is an Android-only Flutter plugin that wraps the WCH CH34X UART library (`CH34XUARTDriver.jar`). It bridges Flutter/Dart code to native Android USB serial communication via `MethodChannel` and `EventChannel`. + +**Key Characteristics:** +- Static facade pattern for public API (`Ch34Manager`) +- Platform interface pattern with token-based verification (`Ch34Platform`) +- MethodChannel for command invocation, EventChannel for streaming callbacks +- Single platform implementation (Android only, Kotlin/Java) +- Singleton WCH UART manager on the native side + +## Layers + +### Dart Public API Layer +- **Purpose:** Static facade providing the consumer-facing API +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\src\ch34_manager.dart` +- **Contains:** Static methods delegating to `Ch34Platform.instance` +- **Depends on:** `Ch34Platform` +- **Used by:** Flutter app consuming the plugin + +### Dart Platform Interface Layer +- **Purpose:** Abstract interface defining all plugin methods with token verification +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\src\ch34_platform_interface.dart` +- **Contains:** `abstract class Ch34Platform extends PlatformInterface` with 33 method signatures +- **Depends on:** `plugin_platform_interface` package, `ch34_types.dart` +- **Used by:** `MethodChannelCh34` for concrete implementation + +### Dart MethodChannel Implementation Layer +- **Purpose:** Bridges abstract platform interface to native Android via Flutter's platform channels +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\src\ch34_method_channel.dart` +- **Contains:** `class MethodChannelCh34 extends Ch34Platform` with actual channel communication +- **Channels:** + - `MethodChannel('ch34')` - synchronous method calls + - `EventChannel('ch34/data')` - serial data stream + - `EventChannel('ch34/modem')` - modem status stream + - `EventChannel('ch34/usb_state')` - USB hotplug events +- **Depends on:** `dart:async`, `flutter/services.dart` +- **Used by:** `Ch34Manager` (via `Ch34Platform.instance`) + +### Dart Types Layer +- **Purpose:** Shared data types and enums for the plugin +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\src\types\ch34_types.dart` +- **Contains:** + - Enums: `DataBits`, `StopBits`, `Parity`, `GpioDirection`, `GpioValue`, `SerialErrorType` + - Data classes: `GpioStatus`, `SerialParameter`, `ModemStatus`, `UsbDeviceInfo` + - Exception: `Ch34Exception` (defined in `ch34_method_channel.dart`) + +### Android Native Layer +- **Purpose:** Actual WCH UART library interaction, USB device management +- **Location:** `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\` +- **Contains:** + - `Ch34Plugin.java` - Main plugin entry point, MethodCallHandler + - `Ch34DataStreamHandler.java` - Data EventChannel stream handler with WCH callback bridging + - `Ch34ModemStreamHandler.java` - Modem status EventChannel stream handler + - `Ch34UsbStateStreamHandler.java` - USB hotplug EventChannel with BroadcastReceiver + - `Ch34TypeConverter.java` - Type conversion between Dart and WCH types +- **Depends on:** `CH34XUARTDriver.jar` (bundled in `android/libs/`) +- **Used by:** Dart MethodChannel layer + +### Backward Compatibility Layer +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\ch34_platform_interface.dart`, `D:\code\new_git_code\flutter\ch34\lib\ch34_method_channel.dart` +- **Purpose:** Re-exports from `src/` for consumers using old import paths + +## Data Flow + +### Device Discovery Flow +1. Consumer calls `Ch34Manager.enumDevice()` +2. Delegates to `Ch34Platform.instance.enumDevice()` +3. `MethodChannelCh34.enumDevice()` invokes `methodChannel.invokeMethod('enumDevice')` +4. `Ch34Plugin.enumDevice()` calls `WCHUARTManager.enumDevice()` on native side +5. Native iterates devices, extracts VID/PID/serialCount/chipType +6. Returns `List` through MethodChannel +7. Dart side deserializes into `List` + +### Data Read Flow (Callback Mode) +1. Consumer calls `Ch34Manager.registerDataCallback(deviceName, serialNumber, onData)` +2. `MethodChannelCh34` subscribes to `EventChannel('ch34/data')` +3. Invokes `methodChannel.invokeMethod('registerDataCallback')` +4. `Ch34Plugin` registers `Ch34DataStreamHandler.getWchCallback()` with `WCHUARTManager.registerDataCallback()` +5. When data arrives, WCH triggers `IDataCallback.onData()` +6. `Ch34DataStreamHandler` bridges data to EventChannel +7. Dart receives via `StreamSubscription`, invokes user callback + +### Data Write Flow +1. Consumer calls `Ch34Manager.writeData(deviceName, serialNumber, data)` +2. Delegates through platform interface to `MethodChannelCh34` +3. `methodChannel.invokeMethod('writeData', {data: data, ...})` +4. `Ch34Plugin.writeData()` calls `WCHUARTManager.writeData(device, serial, data, length, timeout)` +5. Returns bytes written count + +### USB Hotplug Event Flow +1. `Ch34UsbStateStreamHandler` registers `BroadcastReceiver` for `ACTION_USB_DEVICE_ATTACHED/DETACHED` +2. Android system sends broadcast on USB events +3. BroadcastReceiver extracts `UsbDevice` and calls `notifyStateChanged()` +4. EventChannel sends `{deviceName, connected}` to Dart +5. `MethodChannelCh34` invokes `_usbStateCallback` + +### Device Open Flow +1. Consumer calls `Ch34Manager.openDevice(deviceName)` +2. `Ch34Plugin.openDevice()` attempts `WCHUARTManager.openDevice(device)` +3. If `NoPermissionException`, requests permission then retries after 2s delay +4. On success, adds to `openedDevices` map and notifies `usbStateStreamHandler` + +## State Management + +**Native State:** +- `WCHUARTManager` - Singleton instance, lazy-initialized in `ensureManagerInitialized()` +- `openedDevices: Map` - tracks currently opened devices +- Three `EventChannel.StreamHandler` instances - each manages its own `EventSink` + +**Dart State:** +- `Ch34Platform._instance` - Singleton platform instance +- `MethodChannelCh34` maintains `_dataSubscription`, `_modemSubscription`, `_usbStateSubscription` - StreamSubscriptions for event channels +- Callback references: `_dataCallback`, `_modemCallback`, `_usbStateCallback` + +**State Cleanup:** +- `Ch34Plugin.close()` calls `closeAllDevices()` to disconnect all and clear map +- `Ch34UsbStateStreamHandler.onCancel()` unregisters BroadcastReceiver +- `Ch34Manager.close()` triggers platform cleanup and cancels all subscriptions + +## Key Abstractions + +### Platform Interface (`Ch34Platform`) +- **Purpose:** Abstract contract that decouples Flutter code from native implementation +- **Example:** `D:\code\new_git_code\flutter\ch34\lib\src\ch34_platform_interface.dart` +- **Pattern:** Token-based platform interface verification from `plugin_platform_interface` package +- **Pattern:** +```dart +static final Object _token = Object(); +static Ch34Platform get instance { ... } +static void registerDefaultInstance(Ch34Platform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; +} +``` + +### Manager Facade (`Ch34Manager`) +- **Purpose:** Static facade simplifying API usage for consumers +- **Example:** `D:\code\new_git_code\flutter\ch34\lib\src\ch34_manager.dart` +- **Pattern:** All static methods, all delegate to `Ch34Platform.instance` +- **Pattern:** +```dart +static Future> enumDevice() { + return Ch34Platform.instance.enumDevice(); +} +``` + +### Type Converter (`Ch34TypeConverter`) +- **Purpose:** Bridge between Dart enum indices and WCH native enum values +- **Example:** `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34TypeConverter.java` +- **Pattern:** Static utility class with conversion methods for GPIO direction/value, serial error types + +## Entry Points + +### Dart Library Entry +- **Location:** `D:\code\new_git_code\flutter\ch34\lib\ch34.dart` +- **Triggers:** Consumer imports `package:ch34/ch34.dart` +- **Responsibilities:** Exports `Ch34Manager`, `Ch34Platform`, all types, and `Ch34Exception` + +### Plugin Initialization (Android) +- **Location:** `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` +- **Trigger:** Flutter engine attaches plugin via `onAttachedToEngine()` +- **Responsibilities:** Creates MethodChannel, 3 EventChannels, sets MethodCallHandler +- **Declaration:** `D:\code\new_git_code\flutter\ch34\android\build.gradle` + `pubspec.yaml` declares `package: com.example.ch34`, `pluginClass: Ch34Plugin` + +### MethodChannel Dispatcher +- **Location:** `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` line 80-187 +- **Trigger:** Dart invokes `methodChannel.invokeMethod()` +- **Responsibilities:** Switch dispatch to 33 method handlers, error handling wrapper + +## Error Handling + +**Strategy:** Native exceptions caught and converted to MethodChannel error responses with error codes; Dart side uses null-coalescing defaults and `Ch34Exception` for explicit failures. + +**Patterns:** +- **Native:** Each method handler has try-catch, returns `result.error(ERROR_CODE, message, null)` + - Error codes: `ENUM_DEVICE_FAILED`, `OPEN_DEVICE_FAILED`, `PERMISSION_DENIED`, `WRITE_DATA_FAILED`, etc. +- **Dart:** Default fallbacks on null results (e.g., `result ?? false`, `result ?? Uint8List(0)`) +- **Exception class:** `Ch34Exception` in `D:\code\new_git_code\flutter\ch34\lib\src\ch34_method_channel.dart` for throwing on Dart side +- **Logging:** All errors logged via `Log.e(TAG, "...")` on Android native side + +## Cross-Cutting Concerns + +**Logging:** Android `android.util.Log` with per-class TAGs (`Ch34Plugin`, `Ch34DataStream`, `Ch34ModemStream`, `Ch34UsbStateStream`). Dart side uses `flutter/foundation.dart` `debugPrint()` for callback errors. + +**Validation:** Device name resolution via `getDeviceOrThrow()` which searches `openedDevices` map first, then falls back to `manager.enumDevice()` lookup. + +**Authentication/Permission:** USB permission flow handled in `openDevice()` - attempts open, if `NoPermissionException`, calls `manager.requestPermission()` then retries after 2-second delay. + +--- + +*Architecture analysis: 2026-04-16* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..3900494 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,167 @@ +# Codebase Concerns + +**Analysis Date:** 2026-04-16 + +## Technical Debt + +### Placeholder Content in LICENSE and CHANGELOG +- Issue: `LICENSE` contains "TODO: Add your license here" and `CHANGELOG.md` contains "TODO: Describe initial release." No license or changelog content exists. +- Files: `D:\code\new_git_code\flutter\ch34\LICENSE`, `D:\code\new_git_code\flutter\ch34\CHANGELOG.md` +- Impact: Legal risk - the package has no defined license. No version history for consumers. +- Fix approach: Add an appropriate open-source license (e.g., MIT). Document release history in CHANGELOG. + +### Unimplemented Modem Error Callbacks +- Issue: In `registerModemStatusCallback`, the `IModemStatus` interface has three error callbacks (`onOverrunError`, `onParityError`, `onFrameError`) with empty bodies - they silently discard errors. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 667-677) +- Impact: Serial communication errors are invisible to the Flutter layer. Users cannot detect data corruption. +- Fix approach: Forward these errors through the EventChannel or the data callback, or expose a separate error stream. + +### GPIO Status Map Always Returns Hardcoded Values +- Issue: `Ch34TypeConverter.gpioStatusToMap()` hardcodes `index` and `value` to 0, regardless of the actual `GPIO_Status` object. Only `enabled` and `direction` are read from the status. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34TypeConverter.java` (lines 28-29) +- Impact: Dart side `queryGpioStatus()` and `queryAllGpioStatus()` always return incorrect `index` and `value` fields. +- Fix approach: Read `status.getIndex()` and `status.getValue()` from the WCH `GPIO_Status` object. + +## Known Bugs + +### Data Callback Only Supports Single Device Per Serial Number +- Issue: `Ch34DataStreamHandler` uses a single `_dataCallback` field. When multiple devices register data callbacks, each new registration overwrites the previous one. Only the last registered device's data is received. +- Files: `D:\code\new_git_code\flutter\ch34\lib\src\ch34_method_channel.dart` (line 44), `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 388-407) +- Impact: Multi-device scenarios lose data from all but the most recently registered device. +- Fix approach: Use a map keyed by `(deviceName, serialNumber)` to manage multiple independent subscriptions. + +### Hardcoded USB Permission Wait Time +- Issue: In `openDevice`, when the initial open fails, a `Handler.postDelayed` waits 2000ms and retries. This is a magic number that may be insufficient on slow devices or excessive on fast ones. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (line 278) +- Impact: Permission may not be granted in time on some devices, leading to false failure. +- Fix approach: Use a proper permission result callback or `PendingIntent` instead of a fixed delay. + +### `tryOpenDevice` Swallows Non-Permission Exceptions +- Issue: In `tryOpenDevice`, any non-`NoPermissionException` error logs the error but returns `true`, treating it as success. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 291-300) +- Impact: Failed device opens are reported as successful, leading to downstream errors that are harder to debug. +- Fix approach: Return `false` for any exception and let the caller handle the error properly. + +## Security Concerns + +### No Input Validation on Native Method Arguments +- Issue: The Java plugin directly casts method call arguments without null checks or type validation (e.g., `call.argument("deviceName")` cast to `String` without null guards). +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (throughout) +- Impact: Malformed or malicious method call arguments from Flutter can cause `ClassCastException` or `NullPointerException` on the Android side. +- Fix approach: Add null/type checks before using arguments. Return error results for invalid inputs. + +### `pubspec.lock` Ignored for Library Packages +- Issue: `pubspec.lock` is in `.gitignore`, which is correct for library packages. However, `example/pubspec.lock` is not explicitly tracked, meaning the example app's dependency versions are not pinned. +- Files: `D:\code\new_git_code\flutter\ch34\.gitignore` (line 26) +- Impact: Example app may resolve to different dependency versions on different machines, causing inconsistent test results. +- Fix approach: Consider committing `example/pubspec.lock` for reproducible example builds. + +## Performance Bottlenecks + +### Synchronous `enumDevice` in `getDeviceOrThrow` +- Issue: `getDeviceOrThrow` calls `manager.enumDevice()` to enumerate all devices just to find one by name. This is called for nearly every method invocation. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 772-787) +- Impact: USB enumeration is slow and repeated unnecessarily. Every method call (read, write, set parameters) triggers this scan if the device is not in `openedDevices`. +- Fix approach: Cache device references when they are discovered, or maintain a device lookup map. + +### Blocking `readData` Without Timeout +- Issue: `readData` calls `manager.readData(device, serialNumber)` synchronously with no timeout. If no data arrives, the method blocks indefinitely. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 373-385), `D:\code\new_git_code\flutter\ch34\lib\src\ch34_method_channel.dart` (lines 176-185) +- Impact: Flutter UI thread could freeze if a `readData` call blocks on a quiet serial line. +- Fix approach: Use the async callback-based approach for reading, or enforce a configurable timeout. + +## Fragile Areas + +### Single Global WCHUARTManager Instance +- Issue: The entire plugin uses a single `WCHUARTManager` instance stored as a field. It is lazily initialized in `ensureManagerInitialized()` and never reset except on engine detach. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 57, 757-770) +- Impact: If the WCH library is in a bad state (e.g., after an unhandled error), there is no recovery mechanism. Hot restart during development may cause initialization issues. +- Fix approach: Add a `reset()` or `reinitialize()` method for recovery scenarios. + +### EventChannel Single Instance for All Devices +- Issue: Each EventChannel (`ch34/data`, `ch34/modem`, `ch34/usb_state`) has a single StreamHandler instance for all devices. The data handler must multiplex all device data through one channel. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 66-76), `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34DataStreamHandler.java` +- Impact: Adding device identification to each data event is the responsibility of the consumer. No per-device stream isolation. +- Fix approach: Include `deviceName` and `serialNumber` in each data event payload, or create per-device EventChannels. + +### `removeDataCallback` Ignores `serialNumber` +- Issue: The Dart API `removeDataCallback` takes only `deviceName`, but `registerDataCallback` takes both `deviceName` and `serialNumber`. If a device has multiple serial ports, removing the callback for one serial number removes all. +- Files: `D:\code\new_git_code\flutter\ch34\lib\src\ch34_platform_interface.dart` (line 166), `D:\code\new_git_code\flutter\ch34\lib\src\ch34_manager.dart` (lines 160-162) +- Impact: Multi-port devices cannot independently manage callbacks per serial port. +- Fix approach: Add `serialNumber` parameter to `removeDataCallback`. + +### Swallowed Exceptions in `enumDevice` Loop +- Issue: In `enumDevice`, exceptions during `getSerialCount` or `getChipType` are silently caught and ignored. The device is still reported but with incomplete data. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (lines 215) +- Impact: Consumers may receive devices with `serialCount = -1` and `chipType = null` with no indication of what went wrong. +- Fix approach: At minimum log the exception, or distinguish between "no data available" and "error reading data". + +## Missing Patterns + +### No Structured Error Handling +- Issue: The plugin uses `result.error(code, message, null)` with string codes for error reporting. The Dart side never throws typed exceptions (except `Ch34Exception` which is only used for GPIO failures). Most errors silently return `false` or `0`. +- Files: `D:\code\new_git_code\flutter\ch34\lib\src\ch34_method_channel.dart` (throughout), `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` (throughout) +- Impact: Consumers cannot distinguish between different failure modes. Debugging requires enabling debug mode and reading logcat. +- Fix approach: Throw `Ch34Exception` with error code and message from all `result.error` responses. Define error code constants. + +### No Lifecycle Management +- Issue: The plugin does not handle Android lifecycle events (e.g., app going to background). Active USB connections may be left open or become invalid. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\main\java\com\example\ch34\Ch34Plugin.java` +- Impact: When the app resumes, existing connections may be in an undefined state. +- Fix approach: Implement `ActivityAware` or use `AppLifecycleState` in Dart to pause/resume or clean up connections. + +### No Unit Test Coverage for Core Logic +- Issue: The only unit tests cover `getPlatformVersion` and `enumDevice` with stub returns. None of the data conversion, parameter handling, or error paths are tested. +- Files: `D:\code\new_git_code\flutter\ch34\test\ch34_test.dart`, `D:\code\new_git_code\flutter\ch34\test\ch34_method_channel_test.dart`, `D:\code\new_git_code\flutter\ch34\android\src\test\java\com\example\ch34\Ch34PluginTest.java` +- Impact: Refactoring or bug fixes risk introducing regressions with no automated detection. +- Fix approach: Add tests for type conversion, error handling, and the mock platform covering all API methods. + +## Dependencies Risk + +### Bundled JAR Dependency Without Version Pinning +- Issue: The WCH `CH34XUARTDriver.jar` is included as a local JAR file in `android/libs/` with no version information in the build file. It is loaded via `flatDir` repository. +- Files: `D:\code\new_git_code\flutter\ch34\android\build.gradle` (line 47) +- Impact: No easy way to track which version of the WCH library is used. Cannot update to newer versions without manual replacement. No transitive dependency management. +- Fix approach: Document the JAR version used. Consider hosting in a Maven repository for proper version management. + +### Android Gradle Plugin Version is Outdated +- Issue: The build uses `com.android.tools.build:gradle:7.3.0` which is significantly outdated. Current versions are 8.x. +- Files: `D:\code\new_git_code\flutter\ch34\android\build.gradle` (line 11) +- Impact: May not be compatible with newer Flutter/Android SDK versions. Missing bug fixes and performance improvements. +- Fix approach: Update to AGP 8.x and test thoroughly. Update `compileSdk` and `targetSdk` accordingly. + +### Java 8 Target Compatibility +- Issue: The plugin compiles with `JavaVersion.VERSION_1_8` as source and target. +- Files: `D:\code\new_git_code\flutter\ch34\android\build.gradle` (lines 35-36) +- Impact: Cannot use modern Java language features. May limit compatibility with future Android tooling. +- Fix approach: Consider updating to Java 11+ if the minimum SDK and tooling support it. + +## Test Coverage Gaps + +### No Integration Tests for USB Operations +- Issue: The single integration test only checks `getPlatformVersion`. No tests for actual USB device operations. +- Files: `D:\code\new_git_code\flutter\ch34\example\integration_test\plugin_integration_test.dart` +- Risk: USB-specific bugs are only caught through manual testing. +- Priority: High + +### No Tests for Type Conversions +- Issue: `Ch34TypeConverter` has zero test coverage. The GPIO and error type conversions are not verified. +- Files: `D:\code\new_git_code\flutter\ch34\android\src\test\java\com\example\ch34\Ch34PluginTest.java` +- Risk: Conversion bugs (like the hardcoded GPIO values) go undetected. +- Priority: High + +### No Tests for EventChannel Streams +- Issue: No tests verify that data callbacks, modem status, or USB state events flow correctly from native to Dart. +- Files: `D:\code\new_git_code\flutter\ch34\test\ch34_method_channel_test.dart`, `D:\code\new_git_code\flutter\ch34\test\ch34_test.dart` +- Risk: Stream subscription and cancellation logic may have bugs that only manifest at runtime. +- Priority: Medium + +### No Tests for Exception Paths +- Issue: All tests use happy-path mock returns. No tests simulate errors, timeouts, or null responses. +- Files: `D:\code\new_git_code\flutter\ch34\test\ch34_test.dart` +- Risk: Error handling code is untested and may fail silently or crash. +- Priority: Medium + +--- + +*Concerns audit: 2026-04-16* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..a7dcd15 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,237 @@ +# Coding Conventions + +**Analysis Date:** 2026-04-16 + +## Overview + +This is a Flutter plugin package (`ch34`) that provides USB-to-serial communication for WCH CH34X series chips. The codebase is written in Dart with Android native code (Java). The plugin follows the standard Flutter plugin federated architecture pattern. + +## Naming Patterns + +**Files:** +- Dart files use `snake_case`: `ch34_manager.dart`, `ch34_platform_interface.dart`, `ch34_method_channel.dart`, `ch34_types.dart` +- Test files mirror source file names: `ch34_test.dart`, `ch34_method_channel_test.dart` +- Java native files use `PascalCase`: `Ch34Plugin.java`, `Ch34UsbStateStreamHandler.java` + +**Classes:** +- PascalCase with `Ch34` prefix for public API: `Ch34Manager`, `Ch34Platform`, `MethodChannelCh34`, `Ch34Exception` +- Domain models without prefix: `GpioStatus`, `SerialParameter`, `ModemStatus`, `UsbDeviceInfo` +- Enum naming: `DataBits`, `StopBits`, `Parity`, `GpioDirection`, `GpioValue`, `SerialErrorType` + +**Functions/Methods:** +- camelCase for all methods: `getPlatformVersion()`, `enumDevice()`, `openDevice()`, `setSerialParameter()` +- Callback-style methods use descriptive names: `setUsbStateListener()`, `registerDataCallback()`, `registerModemStatusCallback()` +- Private methods use underscore prefix: `_cancelDataSubscription()` +- Private constructor pattern: `Ch34Manager._()` (static-only class) + +**Variables:** +- camelCase: `deviceName`, `serialNumber`, `gpioIndex`, `onStateChanged` +- Private fields use underscore prefix: `_dataSubscription`, `_modemCallback` +- Constants use const: `const MethodChannel('ch34')` + +**Parameters:** +- Named parameters use curly braces for optional: `{int timeout = 0}` +- Required named parameters use `required` keyword in data classes + +## Code Style + +**Formatting:** +- Uses `flutter_lints` package (`^3.0.0`) with default `flutter.yaml` ruleset +- Config: `D:/code/new_git_code/flutter/ch34/analysis_options.yaml` +- Example app config: `D:/code/new_git_code/flutter/ch34/example/analysis_options.yaml` (same base, with commented-out custom rules) +- No custom lint rules are enabled beyond the default Flutter set +- No `.prettierrc` or other formatter config (uses Dart formatter defaults) + +**SDK Constraints:** +- SDK: `>=3.4.3 <4.0.0` (`D:/code/new_git_code/flutter/ch34/pubspec.yaml`) +- Flutter: `>=3.3.0` + +**Import Organization:** +1. Dart SDK imports first: `import 'dart:typed_data';`, `import 'dart:async';` +2. Flutter framework imports: `import 'package:flutter/services.dart';` +3. Third-party package imports: `import 'package:plugin_platform_interface/plugin_platform_interface.dart';` +4. Relative local imports last: `import 'ch34_platform_interface.dart';`, `import 'types/ch34_types.dart';` +- Groups separated by blank lines + +**Path Aliases:** +- No path aliases detected; all imports use relative paths within the package + +## Patterns + +**Static Manager Pattern:** +`D:/code/new_git_code/flutter/ch34/lib/src/ch34_manager.dart` uses a private constructor (`Ch34Manager._()`) with all static methods, delegating to `Ch34Platform.instance`. This provides a clean public API without requiring instantiation. + +```dart +class Ch34Manager { + Ch34Manager._(); + + static Future getPlatformVersion() { + return Ch34Platform.instance.getPlatformVersion(); + } +} +``` + +**Platform Interface (Federated Plugin):** +`D:/code/new_git_code/flutter/ch34/lib/src/ch34_platform_interface.dart` extends `PlatformInterface` from `plugin_platform_interface`. Uses token-based verification for instance safety: + +```dart +abstract class Ch34Platform extends PlatformInterface { + Ch34Platform() : super(token: _token); + static final Object _token = Object(); + static Ch34Platform? _instance; + + static Ch34Platform get instance { ... } + static set instance(Ch34Platform instance) { ... } +} +``` + +**MethodChannel + EventChannel Pattern:** +`D:/code/new_git_code/flutter/ch34/lib/src/ch34_method_channel.dart` uses: +- `MethodChannel` for request-response calls: `methodChannel.invokeMethod('openDevice', {...})` +- `EventChannel` for streaming data: `dataEventChannel.receiveBroadcastStream({'deviceName': deviceName}).listen(...)` +- `@visibleForTesting` annotation for testable fields (channel names) + +**Data Class Pattern:** +`D:/code/new_git_code/flutter/ch34/lib/src/types/ch34_types.dart` uses immutable classes with: +- `const` constructors +- `fromMap()` factory for deserialization +- `toMap()` method for serialization +- `toString()` override for debugging +- `copyWith()` for mutable updates (e.g., `SerialParameter.copyWith()`) +- `==`/`hashCode` override for equality (e.g., `UsbDeviceInfo`) + +```dart +class SerialParameter { + const SerialParameter({ + this.baud = 115200, + this.dataBits = DataBits.bits8, + ... + }); + + factory SerialParameter.fromMap(Map map) { ... } + Map toMap() { ... } + SerialParameter copyWith({ ... }) { ... } +} +``` + +**Enum with Associated Values:** +Enums use associated integer values with `fromValue()` factory: + +```dart +enum DataBits { + bits5(5), bits6(6), bits7(7), bits8(8); + const DataBits(this.value); + final int value; + static DataBits fromValue(int value) => + DataBits.values.firstWhere((e) => e.value == value, orElse: () => DataBits.bits8); +} +``` + +**Null-Safe Default Handling:** +Method channel results consistently use null-coalescing for safe defaults: +```dart +return result ?? false; // boolean methods +return result ?? 0; // integer methods +return result ?? []; // list methods +return result ?? Uint8List(0); // byte data +``` + +**Section Comments:** +Code is organized with clear separator comments: +```dart +/// ==================== 基础方法 ==================== +/// ==================== 设备枚举与识别 ==================== +/// ==================== 数据读写 ==================== +``` + +## Error Handling + +**Custom Exception:** +`D:/code/new_git_code/flutter/ch34/lib/src/ch34_method_channel.dart` defines `Ch34Exception`: +```dart +class Ch34Exception implements Exception { + const Ch34Exception(this.message); + final String message; + @override + String toString() => 'Ch34Exception: $message'; +} +``` + +**Usage Patterns:** +- Platform interface methods document `@throws Ch34Exception` in JSDoc comments for methods that may fail +- Actual throwing is limited: only GPIO methods throw `Ch34Exception` when native returns null +- Most boolean methods return `false` on failure rather than throwing +- Example app uses generic `catch (e)` for error display in UI: + ```dart + } catch (e) { + setState(() { + _status = '扫描失败: $e'; + }); + } + ``` +- `StateError` thrown when platform instance not initialized: + ```dart + throw StateError('Ch34Platform.instance has not been initialized...'); + ``` + +**Stream Error Handling:** +EventChannel streams use `onError` callback with `debugPrint`: +```dart +onError: (error) { + debugPrint('CH34 data callback error: $error'); +} +``` + +## Logging + +**Framework:** Flutter's `debugPrint` from `package:flutter/foundation.dart` + +**Patterns:** +- Used only in error callbacks within the method channel implementation +- Log messages are descriptive with context prefix: `'CH34 USB state error: $error'` +- No logging framework beyond `debugPrint` +- Debug mode toggle available via `Ch34Manager.setDebug(bool)` (passes to native side) + +## Comments & Documentation + +**API Documentation:** +- All public methods have `///` Dart doc comments in Chinese +- Consistent param/return documentation using `@param` and `@return` +- Exception documentation using `@throws` +- Library-level docs at `D:/code/new_git_code/flutter/ch34/lib/ch34.dart` include usage example in code block + +**Inline Comments:** +- Chinese section headers for method grouping +- Method-level comments explain purpose, parameters, and return values + +**JSDoc/TSDoc Style:** +- Uses `///` (not `/** */`) for doc comments +- Parameter docs: `/// @param deviceName 设备名称。` +- Return docs: `/// @return \`true\` 成功,\`false\` 失败。` + +## Module Design + +**Exports:** +- Main entry `D:/code/new_git_code/flutter/ch34/lib/ch34.dart` exports all public types: + ```dart + export 'src/ch34_manager.dart'; + export 'src/types/ch34_types.dart'; + export 'src/ch34_platform_interface.dart' show Ch34Platform; + export 'src/ch34_method_channel.dart' show Ch34Exception; + ``` +- Backward-compatible barrel files at `lib/` root: + - `D:/code/new_git_code/flutter/ch34/lib/ch34_platform_interface.dart` + - `D:/code/new_git_code/flutter/ch34/lib/ch34_method_channel.dart` + +**Layer Separation:** +``` +lib/ch34.dart -> Public API entry (exports) +lib/src/ch34_manager.dart -> Static facade (public-facing) +lib/src/ch34_platform_interface.dart -> Abstract interface +lib/src/ch34_method_channel.dart -> Concrete implementation +lib/src/types/ch34_types.dart -> Data types and enums +``` + +--- + +*Convention analysis: 2026-04-16* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..d872ba5 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,95 @@ +# External Integrations + +**Analysis Date:** 2026-04-16 + +## Hardware/Device + +**WCH CH34X Series USB-to-Serial Chips:** +- **CH340, CH341, CH342, CH343, CH344, CH347** - Standard USB-to-serial converters +- **CH9101, CH9102, CH9103, CH9104** - Newer generation USB-to-serial chips +- **CH9143** - Multi-port USB-to-serial chip + +**SDK/Driver:** +- Library: `CH34XUARTDriver.jar` (local JAR, 140KB) +- Location: `android/libs/CH34XUARTDriver.jar` +- Package: `cn.wch.uartlib` +- Manager: `WCHUARTManager.getInstance()` - Singleton pattern + +**USB Host Protocol:** +- Android USB Host API (`android.hardware.usb.UsbManager`) +- Feature declaration: `android.hardware.usb.host` (optional, in `android/src/main/AndroidManifest.xml`) +- Permission model: Runtime USB permission via `WCHUARTManager.requestPermission()` + +**VID/PID:** +- Default VID/PID managed by WCH driver +- Custom VID/PID supported via `Ch34Manager.addNewHardware(vid, pid)` - maps to `WCHUARTManager.addNewHardware()` + +## Communication Channels + +**MethodChannel (`'ch34'`):** +- Location: `lib/src/ch34_method_channel.dart` (Dart), `android/src/main/java/com/example/ch34/Ch34Plugin.java` (Java) +- 33 methods mapped: `enumDevice`, `openDevice`, `requestPermission`, `writeData`, `readData`, `setDtr`, `setRts`, `setBreak`, `enableGpio`, `setGpioVal`, `getGpioVal`, etc. +- Error codes: `ENUM_DEVICE_FAILED`, `OPEN_DEVICE_FAILED`, `PERMISSION_DENIED`, `WRITE_DATA_FAILED`, `GPIO_CHECK_FAILED`, etc. + +**EventChannel (`'ch34/data'`):** +- Location: `android/src/main/java/com/example/ch34/Ch34DataStreamHandler.java` +- Payload: `byte[]` serial data bytes +- Registered via `manager.registerDataCallback(device, callback)` + +**EventChannel (`'ch34/modem'`):** +- Location: `android/src/main/java/com/example/ch34/Ch34ModemStreamHandler.java` +- Payload: Map with `cts`, `dsr`, `ri`, `dcd` boolean values +- Registered via `manager.registerModemStatusCallback(device, IModemStatus)` + +**EventChannel (`'ch34/usb_state'`):** +- Location: `android/src/main/java/com/example/ch34/Ch34UsbStateStreamHandler.java` +- Payload: Map with `deviceName` (String) and `connected` (boolean) +- Triggered by Android broadcast receiver (`UsbManager.ACTION_USB_DEVICE_ATTACHED`, `UsbManager.ACTION_USB_DEVICE_DETACHED`) + +## Data Sources + +**Serial Data:** +- Read: `Ch34Manager.readData(deviceName, serialNumber)` -> `Uint8List` (blocking) +- Stream: `Ch34Manager.registerDataCallback(deviceName, serialNumber, onData)` -> EventChannel push +- Write: `Ch34Manager.writeData(deviceName, serialNumber, data)` -> `int` bytes written + +**GPIO Data:** +- Query: `Ch34Manager.queryGpioStatus(deviceName, gpioIndex)` -> `GpioStatus` +- Query all: `Ch34Manager.queryAllGpioStatus(deviceName)` -> `List` +- Control: `Ch34Manager.setGpioVal(deviceName, gpioIndex, value)` / `Ch34Manager.getGpioVal(deviceName, gpioIndex)` + +**Modem Status:** +- `ModemStatus` class with fields: `cts` (Clear To Send), `dsr` (Data Set Ready), `ri` (Ring Indicator), `dcd` (Data Carrier Detect) +- Registered via `Ch34Manager.registerModemStatusCallback(deviceName, onModemStatus)` + +**Error Types:** +- `SerialErrorType.framingError` - Framing error +- `SerialErrorType.parityError` - Parity error +- `SerialErrorType.overrunError` - Overrun error +- `SerialErrorType.breakInterrupt` - Break interrupt +- Query via `Ch34Manager.querySerialErrorCount(deviceName, serialNumber, errorType)` + +## Authentication + +**USB Device Permission:** +- Android runtime USB permission dialog via `WCHUARTManager.requestPermission(context, device)` +- No API keys, tokens, or credentials required +- Permission flow: `openDevice` -> if no permission, auto-request -> retry after 2000ms delay + +## Third-party Services + +**None.** This is a pure hardware driver plugin with no external API integrations, cloud services, or network dependencies. + +## CI/CD & Deployment + +**Hosting:** Not applicable (Flutter plugin package, published to pub.dev) +**CI Pipeline:** Not detected (no `.github/`, `.gitlab-ci.yml`, or `.circleci/` found) + +## Webhooks & Callbacks + +**Incoming:** None +**Outgoing:** None + +--- + +*Integration audit: 2026-04-16* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..607bdd7 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,104 @@ +# Technology Stack + +**Analysis Date:** 2026-04-16 + +## Languages + +**Primary:** +- **Dart** >=3.4.3 - Flutter plugin Dart-side code (`lib/`, `test/`) +- **Java** 8 - Android native plugin implementation (`android/src/main/java/`) + +**Secondary:** +- **Kotlin** 1.7.10 - Declared in example app Gradle plugins (`example/android/settings.gradle`) but not actively used in plugin code + +## Runtime + +**Environment:** +- **Flutter** >=3.3.0 +- **Android SDK** - compileSdk 34, minSdk 21 + +**Package Manager:** +- **pub** (Dart) - Lockfile: `pubspec.lock` present +- **Gradle** 7.3.0 - Android build system + +## Frameworks + +**Core:** +- **Flutter** >=3.3.0 - Cross-platform plugin framework +- **Flutter Plugin API** - `FlutterPlugin`, `MethodCallHandler`, `MethodChannel`, `EventChannel` + +**Testing:** +- **flutter_test** (Flutter SDK) - Unit testing framework +- **JUnit** 4.13.2 - Android native unit testing +- **Mockito** 5.0.0 - Java mocking for Android tests +- **integration_test** (Flutter SDK) - Integration testing + +**Build/Dev:** +- **flutter_lints** ^3.0.0 - Lint rules +- **Gradle** 7.3.0 - Android build automation + +## Key Dependencies + +**Critical:** +- **plugin_platform_interface** ^2.0.2 - Federated plugin platform interface pattern (`Ch34Platform` extends `PlatformInterface`) +- **CH34XUARTDriver** (local JAR, 140KB) - WCH official UART library, located at `android/libs/CH34XUARTDriver.jar`. Provides `WCHUARTManager`, `ChipType2`, `IDataCallback`, `IModemStatus`, `GPIO_Status` etc. + +**Infrastructure:** +- **cupertino_icons** ^1.0.6 - Example app only, Material/Cupertino icon support + +**Android Native Dependencies (WCH SDK):** +- `cn.wch.uartlib.WCHUARTManager` - Core manager singleton +- `cn.wch.uartlib.chipImpl.type.ChipType2` - Chip type identification +- `cn.wch.uartlib.callback.IDataCallback` - Serial data callback interface +- `cn.wch.uartlib.callback.IModemStatus` - Modem status callback interface +- `cn.wch.uartlib.callback.IUsbStateChange` - USB state change callback +- `cn.wch.uartlib.exception.*` - Exception classes (ChipException, NoPermissionException, UartLibException) + +## Configuration + +**Environment:** +- `analysis_options.yaml` - Includes `package:flutter_lints/flutter.yaml` +- No `.env` or environment variable configuration required +- Secrets stored in example `local.properties` (flutter.sdk path) + +**Build:** +- `pubspec.yaml` - Main plugin manifest, package: `ch34`, version `1.0.0` +- `pubspec.lock` - Dependency lockfile +- `android/build.gradle` - Android library build config (namespace: `com.example.ch34`) +- `android/settings.gradle` - Project name: `ch34` +- `example/android/build.gradle` - Example app root build +- `example/android/app/build.gradle` - Example app module (namespace: `com.example.ch34_example`) +- `example/android/settings.gradle` - Gradle plugins including dev.flutter.flutter-plugin-loader + +## Platform Requirements + +**Development:** +- Flutter SDK >=3.3.0 +- Dart SDK >=3.4.3 +- Android SDK compileSdk 34 +- Java 8+ (sourceCompatibility/targetCompatibility = JavaVersion.VERSION_1_8) + +**Production:** +- Android device with USB Host support (`android.hardware.usb.host` feature declared in `android/src/main/AndroidManifest.xml`) +- Android minSdk 21 (Android 5.0 Lollipop) +- Physical CH34X series USB-to-serial hardware connected via USB OTG + +## Supported Chip Models + +CH340, CH341, CH342, CH343, CH344, CH347, CH9101, CH9102, CH9103, CH9104, CH9143 + +## Build System + +**Flutter Plugin:** Standard Flutter plugin structure with Android platform implementation. + +**Communication Pattern:** +- `MethodChannel('ch34')` - 33 method calls for device control +- `EventChannel('ch34/data')` - Serial data streaming +- `EventChannel('ch34/modem')` - Modem status streaming +- `EventChannel('ch34/usb_state')` - USB plug/unplug events + +**Architecture Pattern:** Federated plugin with platform interface (`Ch34Platform`), method channel implementation (`MethodChannelCh34`), and manager facade (`Ch34Manager`). + +--- + +*Stack analysis: 2026-04-16* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..b318b7f --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,217 @@ +# Codebase Structure + +**Analysis Date:** 2026-04-16 + +## Directory Layout + +``` +ch34/ # Flutter plugin root +├── lib/ # Dart source code +│ ├── ch34.dart # Main library entry point (exports) +│ ├── ch34_platform_interface.dart # Backward compat: re-exports src/ +│ ├── ch34_method_channel.dart # Backward compat: re-exports src/ +│ └── src/ # Core implementation +│ ├── ch34_manager.dart # Public static API facade +│ ├── ch34_platform_interface.dart # Abstract platform interface +│ ├── ch34_method_channel.dart # MethodChannel implementation +│ └── types/ +│ └── ch34_types.dart # All data types and enums +├── android/ # Android native code +│ ├── build.gradle # Android library build config +│ ├── settings.gradle # Gradle settings +│ ├── libs/ # Native dependency JARs +│ │ └── CH34XUARTDriver.jar # WCH UART library +│ └── src/ +│ ├── main/java/com/example/ch34/ +│ │ ├── Ch34Plugin.java # Main plugin + MethodCallHandler +│ │ ├── Ch34DataStreamHandler.java # Data EventChannel handler +│ │ ├── Ch34ModemStreamHandler.java # Modem EventChannel handler +│ │ ├── Ch34UsbStateStreamHandler.java # USB hotplug handler +│ │ └── Ch34TypeConverter.java # Type conversion utilities +│ └── test/java/com/example/ch34/ +│ └── Ch34PluginTest.java # Unit tests +├── example/ # Example Flutter app +│ ├── lib/ +│ │ └── main.dart # Example app with device scan/connect/send +│ ├── test/ +│ │ └── widget_test.dart # Widget test (stub) +│ └── integration_test/ +│ └── plugin_integration_test.dart # Integration test +├── test/ # Plugin unit tests +│ ├── ch34_test.dart # Platform interface + manager tests +│ └── ch34_method_channel_test.dart # MethodChannel mock tests +├── docs/ +│ └── CH34X-api_docs.md # WCH API documentation reference +├── pubspec.yaml # Plugin package manifest +├── analysis_options.yaml # Dart linting config +├── CHANGELOG.md # Version history +├── LICENSE # License file +├── README.md # Plugin documentation +└── .gitignore # Git ignore rules +``` + +## Directory Purposes + +### `lib/` - Dart Source +- **Purpose:** All Dart code for the Flutter plugin +- **Key files:** + - `lib/ch34.dart` - Primary consumer import: `import 'package:ch34/ch34.dart';` + - `lib/src/ch34_manager.dart` - Static facade, all public APIs + - `lib/src/ch34_platform_interface.dart` - Abstract interface with 33 methods + - `lib/src/ch34_method_channel.dart` - MethodChannel + EventChannel implementation + - `lib/src/types/ch34_types.dart` - Shared data models and enums + +### `android/` - Android Native +- **Purpose:** Java implementation bridging to WCH CH34X UART library +- **Key files:** + - `android/src/main/java/com/example/ch34/Ch34Plugin.java` - Plugin entry point (788 lines) + - `android/build.gradle` - Library configuration (compileSdk 34, minSdk 21, Java 8) + - `android/libs/CH34XUARTDriver.jar` - WCH vendor SDK (binary dependency) + +### `example/` - Sample Application +- **Purpose:** Demonstrates plugin usage for development and testing +- **Key files:** + - `example/lib/main.dart` - Full working example: scan devices, open, set params, send/receive data + +### `test/` - Plugin Tests +- **Purpose:** Unit tests for the Dart plugin code +- **Contains:** MethodChannel mock tests, platform interface mock implementation + +## Key File Locations + +**Entry Points:** +- `lib/ch34.dart`: Main library entry, consumer-facing import +- `android/src/main/java/com/example/ch34/Ch34Plugin.java`: Android plugin entry, registered via `pubspec.yaml` + +**Configuration:** +- `pubspec.yaml`: Plugin declaration, Android platform registration (`package: com.example.ch34`, `pluginClass: Ch34Plugin`) +- `android/build.gradle`: Android library build configuration +- `analysis_options.yaml`: Dart linting (extends `flutter_lints`) + +**Core Logic:** +- `lib/src/ch34_manager.dart`: Static API facade (all consumer methods) +- `lib/src/ch34_platform_interface.dart`: Abstract interface contract +- `lib/src/ch34_method_channel.dart`: Channel communication implementation +- `android/src/main/java/com/example/ch34/Ch34Plugin.java`: Native bridge logic + +**Types:** +- `lib/src/types/ch34_types.dart`: All data classes (`UsbDeviceInfo`, `SerialParameter`, `ModemStatus`, `GpioStatus`) and enums (`DataBits`, `StopBits`, `Parity`, `GpioDirection`, `GpioValue`, `SerialErrorType`) + +**Testing:** +- `test/ch34_method_channel_test.dart`: Unit tests with mock platform +- `test/ch34_test.dart`: Integration-style tests with real MethodChannel mock +- `example/integration_test/plugin_integration_test.dart`: On-device integration test + +## Naming Conventions + +**Files:** +- Dart: `snake_case.dart` for all files (e.g., `ch34_manager.dart`, `ch34_types.dart`) +- Java: `PascalCase.java` for all classes (e.g., `Ch34Plugin.java`, `Ch34TypeConverter.java`) + +**Dart Classes:** +- Public API class: `Ch34Manager` (static methods, facade pattern) +- Platform interface: `Ch34Platform` (abstract, extends `PlatformInterface`) +- Channel implementation: `MethodChannelCh34` (extends `Ch34Platform`) +- Data types: `UsbDeviceInfo`, `SerialParameter`, `ModemStatus`, `GpioStatus` +- Enums: `DataBits`, `StopBits`, `Parity`, `GpioDirection`, `GpioValue`, `SerialErrorType` +- Exception: `Ch34Exception` + +**Java Classes:** +- Main plugin: `Ch34Plugin` (implements `FlutterPlugin`, `MethodCallHandler`) +- Stream handlers: `Ch34DataStreamHandler`, `Ch34ModemStreamHandler`, `Ch34UsbStateStreamHandler` +- Utility: `Ch34TypeConverter` (static methods only, private constructor) + +**MethodChannel names:** +- Method channel: `'ch34'` +- Data event channel: `'ch34/data'` +- Modem event channel: `'ch34/modem'` +- USB state event channel: `'ch34/usb_state'` + +**Method names:** Match Dart method names exactly (e.g., `enumDevice`, `openDevice`, `setSerialParameter`, `writeData`, `readData`) + +## Where to Add New Code + +**New Dart API Method:** +1. Add abstract method signature to `lib/src/ch34_platform_interface.dart` +2. Add concrete implementation to `lib/src/ch34_method_channel.dart` (using `methodChannel.invokeMethod()`) +3. Add static wrapper to `lib/src/ch34_manager.dart` (delegating to `Ch34Platform.instance`) +4. Add mock implementation in `test/ch34_method_channel_test.dart` for testing +5. Add native handler in `android/src/main/java/com/example/ch34/Ch34Plugin.java` + +**New EventChannel (streaming):** +1. Create new `*StreamHandler.java` in `android/src/main/java/com/example/ch34/` +2. Register in `Ch34Plugin.onAttachedToEngine()` +3. Add `EventChannel` and `StreamSubscription` in `lib/src/ch34_method_channel.dart` +4. Add platform interface methods to `Ch34Platform` +5. Add static wrappers to `Ch34Manager` + +**New Data Type:** +- Add to `lib/src/types/ch34_types.dart` +- Add corresponding conversion in `android/src/main/java/com/example/ch34/Ch34TypeConverter.java` + +**New Native Utility:** +- Add to `android/src/main/java/com/example/ch34/` as a new Java class or extend existing + +## Module Boundaries + +### Dart Layer +``` +lib/ch34.dart (exports) + | + v +lib/src/ch34_manager.dart (static facade) + | + v +lib/src/ch34_platform_interface.dart (abstract contract) + | + v +lib/src/ch34_method_channel.dart (concrete implementation) + | + +---> lib/src/types/ch34_types.dart (shared types) +``` + +### Native Layer +``` +Ch34Plugin.java (MethodCallHandler + 3 EventChannels) + | + +---> Ch34DataStreamHandler.java (data streaming) + +---> Ch34ModemStreamHandler.java (modem status streaming) + +---> Ch34UsbStateStreamHandler.java (USB hotplug streaming) + +---> Ch34TypeConverter.java (type conversion utilities) + | + v +CH34XUARTDriver.jar (WCH vendor SDK) +``` + +### Platform Bridge +``` +Dart: MethodChannel('ch34') <---> Java: Ch34Plugin (MethodCallHandler) +Dart: EventChannel('ch34/data') <---> Java: Ch34DataStreamHandler +Dart: EventChannel('ch34/modem') <---> Java: Ch34ModemStreamHandler +Dart: EventChannel('ch34/usb_state') <---> Java: Ch34UsbStateStreamHandler +``` + +## Special Directories + +**`android/libs/`:** +- Purpose: Contains binary JAR dependency (`CH34XUARTDriver.jar`) +- Generated: No, committed to repo +- Referenced by: `android/build.gradle` via `flatDir` repository + +**`example/`:** +- Purpose: Self-contained Flutter app demonstrating plugin usage +- Generated: No, committed to repo +- Note: The example app package is `ch34_example` + +**`docs/`:** +- Purpose: API documentation reference for WCH WCHUARTManager +- Contains: `CH34X-api_docs.md` - comprehensive API documentation in Markdown + +**`lib/` root-level files:** +- Purpose: Backward compatibility re-exports +- `lib/ch34_platform_interface.dart` exports `src/ch34_platform_interface.dart` +- `lib/ch34_method_channel.dart` exports `src/ch34_method_channel.dart` + +--- + +*Structure analysis: 2026-04-16* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..d8743fc --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,232 @@ +# Testing Patterns + +**Analysis Date:** 2026-04-16 + +## Framework + +**Runner:** +- `flutter_test` (Flutter SDK built-in) +- `integration_test` (Flutter SDK built-in, for E2E/integration tests) +- No explicit `test` package dependency (uses Flutter's bundled test framework) + +**Assertion Library:** +- Built-in `flutter_test` matchers (`expect`, `findsOneWidget`, `isEmpty`, `isInstanceOf`) + +**Dependencies:** +- `flutter_test` (sdk: flutter) - Unit and widget testing +- `flutter_lints` (^3.0.0) - Code analysis +- `plugin_platform_interface` (^2.0.2) - Required for mock platform interface mixin + +**Run Commands:** +```bash +flutter test # Run all unit tests +flutter test --coverage # Run tests with coverage +flutter test test/ch34_test.dart # Run a specific test file +flutter test integration_test/ # Run integration tests (from example/) +``` + +## Test Structure + +**Test File Organization:** +- Plugin tests in `D:/code/new_git_code/flutter/ch34/test/`: + - `ch34_test.dart` - Unit tests with mock platform + - `ch34_method_channel_test.dart` - MethodChannel-level tests with mock handler +- Example app tests in `D:/code/new_git_code/flutter/ch34/example/test/`: + - `widget_test.dart` - Widget tests for example app +- Integration tests in `D:/code/new_git_code/flutter/ch34/example/integration_test/`: + - `plugin_integration_test.dart` - End-to-end plugin tests on real device + +**Naming:** +- Test files: `{source_file}_test.dart` pattern +- Integration test files: `{feature}_integration_test.dart` + +**Test Entry Pattern:** +All tests call framework initialization before running: +```dart +// Unit tests +TestWidgetsFlutterBinding.ensureInitialized(); + +// Integration tests +IntegrationTestWidgetsFlutterBinding.ensureInitialized(); +``` + +**Suite Organization:** +Unit tests follow a consistent pattern: + +```dart +// D:/code/new_git_code/flutter/ch34/test/ch34_test.dart +void main() { + MethodChannelCh34.registerDefault(); + final Ch34Platform initialPlatform = Ch34Platform.instance; + + test('$MethodChannelCh34 is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + MockCh34Platform fakePlatform = MockCh34Platform(); + Ch34Platform.instance = fakePlatform; + + expect(await Ch34Manager.getPlatformVersion(), '42'); + }); + + test('enumDevice returns empty list', () async { + MockCh34Platform fakePlatform = MockCh34Platform(); + Ch34Platform.instance = fakePlatform; + + final devices = await Ch34Manager.enumDevice(); + expect(devices, isEmpty); + }); +} +``` + +## Mocking + +**Framework:** Manual mock classes (no mockito or mocktail dependency) + +**Platform Mock Pattern:** +`D:/code/new_git_code/flutter/ch34/test/ch34_test.dart` uses `MockPlatformInterfaceMixin` for creating test doubles: + +```dart +class MockCh34Platform + with MockPlatformInterfaceMixin + implements Ch34Platform { + + @override + Future getPlatformVersion() => Future.value('42'); + + @override + Future> enumDevice() => Future.value([]); + + // ... all 30+ methods must be overridden +} +``` + +**Mock Usage Pattern:** +Mock instances are swapped into the platform interface before each test: +```dart +MockCh34Platform fakePlatform = MockCh34Platform(); +Ch34Platform.instance = fakePlatform; + +expect(await Ch34Manager.getPlatformVersion(), '42'); +``` + +**MethodChannel Mock Pattern:** +`D:/code/new_git_code/flutter/ch34/test/ch34_method_channel_test.dart` uses `setMockMethodCallHandler` for low-level channel mocking: + +```dart +const MethodChannel channel = MethodChannel('ch34'); + +setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; // Returns same value for all method calls + }, + ); +}); + +tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); +}); +``` + +**What to Mock:** +- Platform interface layer (`Ch34Platform`) for testing `Ch34Manager` delegation +- `MethodChannel` for testing `MethodChannelCh34` channel mapping + +**What NOT to Mock:** +- Native Android code (tested via integration tests on real device) +- EventChannel streams (not currently mocked in any test) + +## Coverage + +**Requirements:** No coverage target enforced + +**Current State:** +- Only 2 unit test assertions beyond boilerplate (`getPlatformVersion`, `enumDevice returns empty list`) +- MethodChannel test has only 1 assertion (`getPlatformVersion`) +- Integration test has 1 assertion (`getPlatformVersion` on real device) +- Most platform methods have zero test coverage (GPIO, serial I/O, modem status, signal control, etc.) + +**View Coverage:** +```bash +flutter test --coverage +# Coverage written to coverage/lcov.info +``` + +## Test Locations + +**Unit Tests:** +- `D:/code/new_git_code/flutter/ch34/test/ch34_test.dart` - Ch34Manager + mock platform +- `D:/code/new_git_code/flutter/ch34/test/ch34_method_channel_test.dart` - MethodChannel tests + +**Integration Tests:** +- `D:/code/new_git_code/flutter/ch34/example/integration_test/plugin_integration_test.dart` - Real device tests + +**Widget Tests:** +- `D:/code/new_git_code/flutter/ch34/example/test/widget_test.dart` - Example app widget test (currently outdated - looks for "Running on:" text that no longer exists) + +## Test Types + +**Unit Tests:** +- Scope: Test `Ch34Manager` delegation to mocked platform +- Approach: Replace `Ch34Platform.instance` with `MockCh34Platform` +- Current coverage: 2 methods tested out of 30+ API methods + +**Integration Tests:** +- Framework: `integration_test` package +- Approach: Run on real Android device with actual USB hardware +- Current coverage: 1 test (`getPlatformVersion`) + +**E2E Tests:** +- No dedicated E2E test framework beyond `integration_test` + +## Common Patterns + +**Async Testing:** +All platform method tests use `async/await`: +```dart +test('enumDevice returns empty list', () async { + MockCh34Platform fakePlatform = MockCh34Platform(); + Ch34Platform.instance = fakePlatform; + + final devices = await Ch34Manager.enumDevice(); + expect(devices, isEmpty); +}); +``` + +**Error Testing:** +Mock throws exceptions to test error paths: +```dart +@override +Future queryGpioStatus(String deviceName, int gpioIndex) => + throw const Ch34Exception('Not supported'); +``` + +**Setup/Teardown Pattern:** +```dart +setUp(() { + // Configure mock method channel handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, handler); +}); + +tearDown(() { + // Clean up mock handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); +}); +``` + +## CI Testing + +**CI Pipeline:** Not detected - no `.github/workflows/`, `.gitlab-ci.yml`, or `azure-pipelines.yml` found. + +**Recommendation:** Tests should be run via `flutter analyze && flutter test` in any CI pipeline. + +--- + +*Testing analysis: 2026-04-16* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e13539 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +这是一个 Flutter 插件项目,为 WCH CH34X 系列 USB 转串口芯片提供 Flutter 接口支持。支持的芯片型号:CH340/CH341/CH342/CH343/CH344/CH347/CH9101/CH9102/CH9103/CH9104/CH9143。 + +## 常用命令 + +```bash +# 安装依赖 +flutter pub get + +# 运行所有测试 +flutter test + +# 运行单个测试文件 +flutter test test/ch34_test.dart +flutter test test/ch34_method_channel_test.dart + +# 代码分析(lint) +flutter analyze + +# 格式化代码 +dart format . + +# 构建示例 APK +cd example && flutter build apk + +# 运行示例应用 +cd example && flutter run +``` + +## 代码架构 + +### 目录结构 + +``` +lib/ +├── ch34.dart # 主入口,导出所有公共 API +├── ch34_method_channel.dart # 顶层导出(向后兼容) +├── ch34_platform_interface.dart # 顶层导出(向后兼容) +└── src/ + ├── ch34_manager.dart # 公共 API 管理器(静态方法门面) + ├── ch34_platform_interface.dart # 抽象平台接口定义 + ├── ch34_method_channel.dart # MethodChannel 实现 + 异常类 + └── types/ + └── ch34_types.dart # 所有类型和枚举定义 +``` + +### 核心类 + +- **`Ch34Manager`** - 公共 API 入口,所有方法为静态,委托给 `Ch34Platform.instance` +- **`Ch34Platform`** - 抽象平台接口,定义所有方法签名,继承自 `PlatformInterface` +- **`MethodChannelCh34`** - 平台接口的具体实现,通过 MethodChannel 和 EventChannel 与 Android 原生端通信 +- **`Ch34Exception`** - 插件自定义异常类 + +### 通信通道 + +| 通道类型 | 名称 | 用途 | +|---------|------|------| +| MethodChannel | `ch34` | 双向方法调用 | +| EventChannel | `ch34/data` | 串口数据推送 | +| EventChannel | `ch34/modem` | Modem 状态变化 | +| EventChannel | `ch34/usb_state` | USB 插拔状态 | + +### Android 原生端 + +``` +android/src/main/java/com/example/ch34/ +├── Ch34Plugin.java # Flutter 插件主类,注册 MethodChannel/EventChannel +├── Ch34DataStreamHandler.java # 数据 EventChannel 的 StreamHandler +├── Ch34ModemStreamHandler.java # Modem EventChannel 的 StreamHandler +├── Ch34UsbStateStreamHandler.java # USB 状态 EventChannel 的 StreamHandler +└── Ch34TypeConverter.java # Dart/Java 类型转换工具 +``` + +### 数据流 + +``` +Flutter App → Ch34Manager → Ch34Platform.instance → MethodChannelCh34 → MethodChannel → Android (Ch34Plugin) +``` + +### 类型系统 (`ch34_types.dart`) + +- **枚举**: `DataBits`, `StopBits`, `Parity`, `GpioDirection`, `GpioValue`, `SerialErrorType` +- **数据类**: `GpioStatus`, `SerialParameter`, `ChipMasterFrequency`, `ModemStatus`, `UsbDeviceInfo` +- 所有数据类提供 `fromMap`/`toMap` 方法用于平台间序列化 + +## 测试策略 + +- `test/ch34_test.dart` - 使用 Mock 平台实例测试 `Ch34Manager` 接口 +- `test/ch34_method_channel_test.dart` - 使用 BinaryMessenger 模拟测试 MethodChannel 调用 +- 测试遵循 flutter plugin 的标准测试模式 + +## 示例应用 + +`example/` 目录包含完整的示例应用,展示设备扫描、打开、数据发送/接收的基本流程。 + +## 用户约束 + +- Flutter 项目中禁止使用 `Get.snackbar`,使用 `easyloading` 代替 +- 所有方法需添加方法级注释 +- 遵循高内聚低耦合设计原则 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b04c571 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# CH34 Flutter \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..d5397f5 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,59 @@ +group = "com.example.ch34" +version = "1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:7.3.0") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + flatDir{ + dirs project(':ch34').file('libs') + } + } +} + +apply plugin: "com.android.library" + +android { + if (project.android.hasProperty("namespace")) { + namespace = "com.example.ch34" + } + + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.0.0") + + implementation(name: 'CH34XUARTDriver',ext: 'jar') + } + + testOptions { + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/android/libs/CH34XUartDriver.jar b/android/libs/CH34XUartDriver.jar new file mode 100644 index 0000000000000000000000000000000000000000..fa2340e95b9d766f1f1f448117669fed860cda42 GIT binary patch literal 133882 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 z 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 T9z2i({=@)3FR=#%{Dc1iUzc5O literal 0 HcmV?d00001 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..aed0b67 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ch34' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..05751e4 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/src/main/java/com/example/ch34/Ch34DataStreamHandler.java b/android/src/main/java/com/example/ch34/Ch34DataStreamHandler.java new file mode 100644 index 0000000..cc24b3c --- /dev/null +++ b/android/src/main/java/com/example/ch34/Ch34DataStreamHandler.java @@ -0,0 +1,110 @@ +package com.example.ch34; + +import io.flutter.plugin.common.EventChannel; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import android.hardware.usb.UsbDevice; +import cn.wch.uartlib.callback.IDataCallback; + +import java.util.HashMap; +import java.util.Map; + +/** + * 数据 EventChannel StreamHandler。 + * + * 负责将 WCH IDataCallback 接收到的数据通过 EventChannel 发送给 Flutter 端。 + */ +public class Ch34DataStreamHandler implements EventChannel.StreamHandler { + + private static final String TAG = "Ch34DataStream"; + + private EventChannel.EventSink eventSink; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + /** 设备名 -> WCH 回调实例 */ + private final Map wchCallbacks = new HashMap<>(); + + /** 数据回调接口,用于桥接 WCH 回调到 EventChannel */ + public interface DataBridge { + void onData(int serialNumber, byte[] buffer, int length); + } + + /** 设备名 -> 数据桥接回调 */ + private final Map dataBridges = new HashMap<>(); + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + this.eventSink = events; + Log.d(TAG, "Data stream listening"); + } + + @Override + public void onCancel(Object arguments) { + this.eventSink = null; + Log.d(TAG, "Data stream cancelled"); + } + + /** + * 发送数据到 Flutter 端。 + * + * 会在主线程上执行 eventSink.success(),确保线程安全。 + * + * @param data 字节数组数据。 + */ + public void sendData(byte[] data) { + mainHandler.post(() -> { + EventChannel.EventSink sink = eventSink; + if (sink != null) { + sink.success(data); + } + }); + } + + /** + * 注册设备的数据回调。 + * + * @param device USB 设备。 + * @param deviceName 设备名称。 + * @param serialNumber 串口号。 + * @param bridge 数据桥接回调。 + */ + public void registerCallback(UsbDevice device, String deviceName, int serialNumber, DataBridge bridge) { + dataBridges.put(deviceName, bridge); + } + + /** + * 获取设备的 WCH 回调实例。 + * + * @param deviceName 设备名称。 + * @return WCH IDataCallback 实例。 + */ + public IDataCallback getWchCallback(String deviceName) { + if (wchCallbacks.containsKey(deviceName)) { + return wchCallbacks.get(deviceName); + } + + IDataCallback callback = new IDataCallback() { + @Override + public void onData(int serialNumber, byte[] buffer, int length) { + DataBridge bridge = dataBridges.get(deviceName); + if (bridge != null) { + bridge.onData(serialNumber, buffer, length); + } + } + }; + wchCallbacks.put(deviceName, callback); + return callback; + } + + /** + * 移除设备的数据回调。 + * + * @param deviceName 设备名称。 + */ + public void removeCallback(String deviceName) { + wchCallbacks.remove(deviceName); + dataBridges.remove(deviceName); + } +} diff --git a/android/src/main/java/com/example/ch34/Ch34ModemStreamHandler.java b/android/src/main/java/com/example/ch34/Ch34ModemStreamHandler.java new file mode 100644 index 0000000..d6e5883 --- /dev/null +++ b/android/src/main/java/com/example/ch34/Ch34ModemStreamHandler.java @@ -0,0 +1,68 @@ +package com.example.ch34; + +import io.flutter.plugin.common.EventChannel; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.util.Map; + +/** + * Modem 状态 EventChannel StreamHandler。 + * + * 负责将 Modem 状态变化通过 EventChannel 发送给 Flutter 端。 + */ +public class Ch34ModemStreamHandler implements EventChannel.StreamHandler { + + private static final String TAG = "Ch34ModemStream"; + + private EventChannel.EventSink eventSink; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private boolean isListening = false; + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + this.eventSink = events; + Log.d(TAG, "Modem stream listening"); + } + + @Override + public void onCancel(Object arguments) { + this.eventSink = null; + this.isListening = false; + Log.d(TAG, "Modem stream cancelled"); + } + + /** + * 开始监听 Modem 状态。 + */ + public void startListening() { + this.isListening = true; + } + + /** + * 停止监听 Modem 状态。 + */ + public void stopListening() { + this.isListening = false; + this.eventSink = null; + } + + /** + * 发送 Modem 状态到 Flutter 端。 + * + * 会在主线程上执行 eventSink.success(),确保线程安全。 + * + * @param status 状态 Map,包含 cts/dsr/ri/dcd 布尔值。 + */ + public void sendStatus(Map status) { + if (!isListening) return; + mainHandler.post(() -> { + EventChannel.EventSink sink = eventSink; + if (sink != null) { + sink.success(status); + } + }); + } +} diff --git a/android/src/main/java/com/example/ch34/Ch34Plugin.java b/android/src/main/java/com/example/ch34/Ch34Plugin.java new file mode 100644 index 0000000..f551376 --- /dev/null +++ b/android/src/main/java/com/example/ch34/Ch34Plugin.java @@ -0,0 +1,892 @@ +package com.example.ch34; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +import android.app.Application; +import android.content.Context; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import cn.wch.uartlib.WCHUARTManager; +import cn.wch.uartlib.chip.type.ChipType2; +import cn.wch.uartlib.callback.IDataCallback; +import cn.wch.uartlib.callback.IModemStatus; +import cn.wch.uartlib.exception.ChipException; +import cn.wch.uartlib.exception.NoPermissionException; +import cn.wch.uartlib.exception.UartLibException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * CH34X USB 转串口驱动 Flutter 插件。 + * + * 实现 WCH WCHUARTManager 文档 4.3–4.38 共 36 个公开 API(getInstance/init 由内部自动调用) + * 的 MethodChannel 映射,并通过 3 个 EventChannel 提供数据回调、Modem 状态和 USB 插拔事件流。 + */ +public class Ch34Plugin implements FlutterPlugin, MethodCallHandler { + + private static final String TAG = "Ch34Plugin"; + + private MethodChannel channel; + private Context context; + + private EventChannel dataEventChannel; + private EventChannel modemEventChannel; + private EventChannel usbStateEventChannel; + + private Ch34DataStreamHandler dataStreamHandler; + private Ch34ModemStreamHandler modemStreamHandler; + private Ch34UsbStateStreamHandler usbStateStreamHandler; + + /** 已打开的设备映射:deviceName -> UsbDevice */ + private final Map openedDevices = new HashMap<>(); + + /** 全局 WCHUARTManager 实例 */ + private WCHUARTManager manager; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + channel = new MethodChannel(binding.getBinaryMessenger(), "ch34"); + channel.setMethodCallHandler(this); + + context = binding.getApplicationContext(); + + dataEventChannel = new EventChannel(binding.getBinaryMessenger(), "ch34/data"); + dataStreamHandler = new Ch34DataStreamHandler(); + dataEventChannel.setStreamHandler(dataStreamHandler); + + modemEventChannel = new EventChannel(binding.getBinaryMessenger(), "ch34/modem"); + modemStreamHandler = new Ch34ModemStreamHandler(); + modemEventChannel.setStreamHandler(modemStreamHandler); + + usbStateEventChannel = new EventChannel(binding.getBinaryMessenger(), "ch34/usb_state"); + usbStateStreamHandler = new Ch34UsbStateStreamHandler(); + usbStateEventChannel.setStreamHandler(usbStateStreamHandler); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + try { + switch (call.method) { + case "getPlatformVersion": + result.success("Android " + android.os.Build.VERSION.RELEASE); + break; + case "enumDevice": + enumDevice(result); + break; + case "getChipType": + getChipType(call, result); + break; + case "openDevice": + openDevice(call, result); + break; + case "requestPermission": + requestPermission(call, result); + break; + case "getSerialCount": + getSerialCount(call, result); + break; + case "getSerialBaud": + getSerialBaud(call, result); + break; + case "getChipMasterFrequency": + getChipMasterFrequency(call, result); + break; + case "enableSerial": + enableSerial(call, result); + break; + case "setSerialParameter": + setSerialParameter(call, result); + break; + case "writeData": + writeData(call, result); + break; + case "asyncWriteData": + asyncWriteData(call, result); + break; + case "readData": + readData(call, result); + break; + case "readDataWithTimeout": + readDataWithTimeout(call, result); + break; + case "registerDataCallback": + registerDataCallback(call, result); + break; + case "removeDataCallback": + removeDataCallback(call, result); + break; + case "isConnected": + isConnected(call, result); + break; + case "getConnectedDevices": + getConnectedDevices(result); + break; + case "disconnect": + disconnect(call, result); + break; + case "close": + close(result); + break; + case "isSupportGpio": + isSupportGpio(call, result); + break; + case "queryGpioCount": + queryGpioCount(call, result); + break; + case "queryGpioStatus": + queryGpioStatus(call, result); + break; + case "queryAllGpioStatus": + queryAllGpioStatus(call, result); + break; + case "enableGpio": + enableGpio(call, result); + break; + case "setGpioVal": + setGpioVal(call, result); + break; + case "getGpioVal": + getGpioVal(call, result); + break; + case "setDtr": + setDtr(call, result); + break; + case "setRts": + setRts(call, result); + break; + case "setBreak": + setBreak(call, result); + break; + case "registerModemStatusCallback": + registerModemStatusCallback(call, result); + break; + case "removeModemStatusCallback": + removeModemStatusCallback(call, result); + break; + case "querySerialErrorCount": + querySerialErrorCount(call, result); + break; + case "setReadTimeout": + setReadTimeout(call, result); + break; + case "addNewHardware": + addNewHardware(call, result); + break; + case "setDebug": + setDebug(call, result); + break; + case "isDebugMode": + isDebugMode(result); + break; + default: + result.notImplemented(); + break; + } + } catch (Exception e) { + Log.e(TAG, "Unhandled method call: " + call.method, e); + result.error("INTERNAL_ERROR", e.getMessage(), null); + } + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + closeAllDevices(); + if (dataEventChannel != null) dataEventChannel.setStreamHandler(null); + if (modemEventChannel != null) modemEventChannel.setStreamHandler(null); + if (usbStateEventChannel != null) usbStateEventChannel.setStreamHandler(null); + } + + // ==================== 设备枚举与识别 ==================== + + private void enumDevice(@NonNull Result result) { + ensureManagerInitialized(); + try { + ArrayList devices = manager.enumDevice(); + List> list = new ArrayList<>(); + for (UsbDevice device : devices) { + String deviceName = device.getDeviceName(); + int serialCount = -1; + String chipType = null; + try { + serialCount = manager.getSerialCount(device); + ChipType2 type = manager.getChipType(device); + if (type != null) { + chipType = type.getDescription(); + } + } catch (Exception ignored) { + } + Map map = new HashMap<>(); + map.put("deviceName", deviceName); + map.put("productId", device.getProductId()); + map.put("vendorId", device.getVendorId()); + map.put("serialCount", serialCount); + map.put("chipType", chipType); + list.add(map); + } + result.success(list); + } catch (Exception e) { + Log.e(TAG, "enumDevice error", e); + result.error("ENUM_DEVICE_FAILED", e.getMessage(), null); + } + } + + private void getChipType(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + ChipType2 type = manager.getChipType(device); + if (type != null) { + result.success(type.getDescription()); + } else { + result.success(null); + } + } catch (Exception e) { + Log.e(TAG, "getChipType error", e); + result.error("GET_CHIP_TYPE_FAILED", e.getMessage(), null); + } + } + + // ==================== 设备打开与权限 ==================== + + private void openDevice(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + + if (openedDevices.containsKey(deviceName)) { + result.success(true); + return; + } + + boolean opened = tryOpenDevice(device); + if (!opened) { + manager.requestPermission(context, device); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + try { + boolean retryResult = tryOpenDevice(device); + if (retryResult) { + openedDevices.put(deviceName, device); + usbStateStreamHandler.notifyStateChanged(deviceName, true); + result.success(true); + } else { + result.error("PERMISSION_DENIED", "USB permission not granted", null); + } + } catch (Exception e) { + result.error("OPEN_DEVICE_FAILED", e.getMessage(), null); + } + }, 2000); + return; + } + + openedDevices.put(deviceName, device); + usbStateStreamHandler.notifyStateChanged(deviceName, true); + result.success(true); + } catch (Exception e) { + Log.e(TAG, "openDevice error", e); + result.error("OPEN_DEVICE_FAILED", e.getMessage(), null); + } + } + + private boolean tryOpenDevice(@NonNull UsbDevice device) { + try { + manager.openDevice(device); + return true; + } catch (NoPermissionException e) { + return false; + } catch (Exception e) { + Log.e(TAG, "tryOpenDevice non-permission error", e); + return true; + } + } + + private void requestPermission(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + manager.requestPermission(context, device); + result.success(true); + } catch (Exception e) { + Log.e(TAG, "requestPermission error", e); + result.error("REQUEST_PERMISSION_FAILED", e.getMessage(), null); + } + } + + // ==================== 串口信息 ==================== + + private void getSerialCount(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + result.success(manager.getSerialCount(device)); + } catch (Exception e) { + Log.e(TAG, "getSerialCount error", e); + result.error("GET_SERIAL_COUNT_FAILED", e.getMessage(), null); + } + } + + private void getSerialBaud(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + result.success(manager.getSerialBaud(device, serialNumber)); + } catch (Exception e) { + Log.e(TAG, "getSerialBaud error", e); + result.error("GET_SERIAL_BAUD_FAILED", e.getMessage(), null); + } + } + + private void getChipMasterFrequency(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + cn.wch.uartlib.base.other.ChipMasterFrequency freq = + manager.getChipMasterFrequency(device); + if (freq != null) { + Map map = new HashMap<>(); + map.put("frequency", freq.getFrequency()); + map.put("switchEnable", freq.isSwitchEnable()); + map.put("coStatus", freq.getCoStatus()); + result.success(map); + } else { + result.error("GET_CHIP_FREQ_FAILED", "Failed to get chip master frequency", null); + } + } catch (Exception e) { + Log.e(TAG, "getChipMasterFrequency error", e); + result.error("GET_CHIP_FREQ_FAILED", e.getMessage(), null); + } + } + + private void enableSerial(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + boolean enable = call.argument("enable"); + result.success(manager.enableSerial(device, serialNumber, enable)); + } catch (Exception e) { + Log.e(TAG, "enableSerial error", e); + result.error("ENABLE_SERIAL_FAILED", e.getMessage(), null); + } + } + + // ==================== 串口参数设置 ==================== + + private void setSerialParameter(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + int baud = call.argument("baud"); + int dataBits = call.argument("dataBits"); + int stopBits = call.argument("stopBits"); + int parity = call.argument("parity"); + boolean flow = call.argument("hardwareFlowControl"); + + boolean success = manager.setSerialParameter( + device, serialNumber, baud, dataBits, stopBits, parity, flow); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "setSerialParameter error", e); + result.error("SET_SERIAL_PARAM_FAILED", e.getMessage(), null); + } + } + + // ==================== 数据读写 ==================== + + private void writeData(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + byte[] data = call.argument("data"); + int length = data != null ? data.length : 0; + int timeout = call.argument("timeout"); + + int written = manager.syncWriteData(device, serialNumber, data, length, timeout); + result.success(written); + } catch (Exception e) { + Log.e(TAG, "writeData error", e); + result.error("WRITE_DATA_FAILED", e.getMessage(), null); + } + } + + private void asyncWriteData(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + byte[] data = call.argument("data"); + + manager.asyncWriteData(device, serialNumber, data); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "asyncWriteData error", e); + result.error("ASYNC_WRITE_FAILED", e.getMessage(), null); + } + } + + private void readData(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + + byte[] data = manager.readData(device, serialNumber); + result.success(data); + } catch (Exception e) { + Log.e(TAG, "readData error", e); + result.error("READ_DATA_FAILED", e.getMessage(), null); + } + } + + private void readDataWithTimeout(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + int vTime = call.argument("vTime"); + int vMin = call.argument("vMin"); + + byte[] data = manager.readData(device, serialNumber, vTime, vMin); + result.success(data); + } catch (Exception e) { + Log.e(TAG, "readDataWithTimeout error", e); + result.error("READ_DATA_TIMEOUT_FAILED", e.getMessage(), null); + } + } + + private void registerDataCallback(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + + dataStreamHandler.registerCallback(device, deviceName, serialNumber, (serial, buffer, length) -> { + byte[] data = new byte[length]; + System.arraycopy(buffer, 0, data, 0, length); + dataStreamHandler.sendData(data); + }); + + manager.registerDataCallback(device, dataStreamHandler.getWchCallback(deviceName)); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "registerDataCallback error", e); + result.error("REGISTER_CALLBACK_FAILED", e.getMessage(), null); + } + } + + private void removeDataCallback(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + + dataStreamHandler.removeCallback(deviceName); + manager.removeDataCallback(device); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "removeDataCallback error", e); + result.error("REMOVE_CALLBACK_FAILED", e.getMessage(), null); + } + } + + // ==================== 连接状态 ==================== + + private void isConnected(@NonNull MethodCall call, @NonNull Result result) { + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = openedDevices.get(deviceName); + result.success(device != null); + } catch (Exception e) { + result.success(false); + } + } + + private void getConnectedDevices(@NonNull Result result) { + result.success(new ArrayList<>(openedDevices.keySet())); + } + + // ==================== 断开与关闭 ==================== + + private void disconnect(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = openedDevices.get(deviceName); + if (device != null) { + manager.disconnect(device); + openedDevices.remove(deviceName); + usbStateStreamHandler.notifyStateChanged(deviceName, false); + } + result.success(null); + } catch (Exception e) { + Log.e(TAG, "disconnect error", e); + result.error("DISCONNECT_FAILED", e.getMessage(), null); + } + } + + private void close(@NonNull Result result) { + ensureManagerInitialized(); + try { + closeAllDevices(); + if (context instanceof Application) { + manager.close((Application) context); + } + result.success(null); + } catch (Exception e) { + Log.e(TAG, "close error", e); + result.error("CLOSE_FAILED", e.getMessage(), null); + } + } + + private void closeAllDevices() { + ensureManagerInitialized(); + for (Map.Entry entry : openedDevices.entrySet()) { + try { + manager.disconnect(entry.getValue()); + usbStateStreamHandler.notifyStateChanged(entry.getKey(), false); + } catch (Exception e) { + Log.e(TAG, "disconnect error during closeAll", e); + } + } + openedDevices.clear(); + } + + // ==================== GPIO 功能 ==================== + + private void isSupportGpio(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + result.success(manager.isSupportGPIOFeature(device)); + } catch (Exception e) { + Log.e(TAG, "isSupportGpio error", e); + result.error("GPIO_CHECK_FAILED", e.getMessage(), null); + } + } + + private void queryGpioCount(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + result.success(manager.queryGPIOCount(device)); + } catch (Exception e) { + Log.e(TAG, "queryGpioCount error", e); + result.error("QUERY_GPIO_COUNT_FAILED", e.getMessage(), null); + } + } + + private void queryGpioStatus(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int gpioIndex = call.argument("gpioIndex"); + + cn.wch.uartlib.base.gpio.GPIO_Status status = manager.queryGPIOStatus(device, gpioIndex); + result.success(Ch34TypeConverter.gpioStatusToMap(status, gpioIndex)); + } catch (Exception e) { + Log.e(TAG, "queryGpioStatus error", e); + result.error("QUERY_GPIO_STATUS_FAILED", e.getMessage(), null); + } + } + + private void queryAllGpioStatus(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + + List statuses = manager.queryAllGPIOStatus(device); + List> list = new ArrayList<>(); + for (int i = 0; i < statuses.size(); i++) { + list.add(Ch34TypeConverter.gpioStatusToMap(statuses.get(i), i)); + } + result.success(list); + } catch (Exception e) { + Log.e(TAG, "queryAllGpioStatus error", e); + result.error("QUERY_ALL_GPIO_FAILED", e.getMessage(), null); + } + } + + private void enableGpio(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int gpioIndex = call.argument("gpioIndex"); + boolean enable = call.argument("enable"); + int directionIndex = call.argument("direction"); + + cn.wch.uartlib.base.gpio.GPIO_DIR dir = Ch34TypeConverter.toGpioDirection(directionIndex); + boolean success = manager.enableGPIO(device, gpioIndex, enable, dir); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "enableGpio error", e); + result.error("ENABLE_GPIO_FAILED", e.getMessage(), null); + } + } + + private void setGpioVal(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int gpioIndex = call.argument("gpioIndex"); + int valueIndex = call.argument("value"); + + cn.wch.uartlib.base.gpio.GPIO_VALUE value = Ch34TypeConverter.toGpioValue(valueIndex); + boolean success = manager.setGPIOVal(device, gpioIndex, value); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "setGpioVal error", e); + result.error("SET_GPIO_VAL_FAILED", e.getMessage(), null); + } + } + + private void getGpioVal(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int gpioIndex = call.argument("gpioIndex"); + + cn.wch.uartlib.base.gpio.GPIO_VALUE value = manager.getGPIOVal(device, gpioIndex); + result.success(value == cn.wch.uartlib.base.gpio.GPIO_VALUE.HIGH ? 1 : 0); + } catch (Exception e) { + Log.e(TAG, "getGpioVal error", e); + result.error("GET_GPIO_VAL_FAILED", e.getMessage(), null); + } + } + + // ==================== 信号控制 ==================== + + private void setDtr(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + boolean valid = call.argument("valid"); + + boolean success = manager.setDTR(device, serialNumber, valid); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "setDtr error", e); + result.error("SET_DTR_FAILED", e.getMessage(), null); + } + } + + private void setRts(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + boolean valid = call.argument("valid"); + + boolean success = manager.setRTS(device, serialNumber, valid); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "setRts error", e); + result.error("SET_RTS_FAILED", e.getMessage(), null); + } + } + + private void setBreak(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + boolean valid = call.argument("valid"); + + boolean success = manager.setBreak(device, serialNumber, valid); + result.success(success); + } catch (Exception e) { + Log.e(TAG, "setBreak error", e); + result.error("SET_BREAK_FAILED", e.getMessage(), null); + } + } + + // ==================== Modem 状态回调 ==================== + + private void registerModemStatusCallback(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + + modemStreamHandler.startListening(); + manager.registerModemStatusCallback(device, new IModemStatus() { + @Override + public void onStatusChanged(int serialNumber, boolean isDCDRaised, + boolean isDSRRaised, boolean isCTSRaised, boolean isRINGRaised) { + Log.d(TAG, "Modem onStatusChanged: serial=" + serialNumber + + " dcd=" + isDCDRaised + " dsr=" + isDSRRaised + + " cts=" + isCTSRaised + " ri=" + isRINGRaised); + Map map = new HashMap<>(); + map.put("dcd", isDCDRaised); + map.put("dsr", isDSRRaised); + map.put("cts", isCTSRaised); + map.put("ri", isRINGRaised); + Log.d(TAG, "Sending modem status to Flutter: " + map); + modemStreamHandler.sendStatus(map); + } + + @Override + public void onOverrunError(int serialNumber) { + } + + @Override + public void onParityError(int serialNumber) { + } + + @Override + public void onFrameError(int serialNumber) { + } + }); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "registerModemStatusCallback error", e); + result.error("REGISTER_MODEM_CALLBACK_FAILED", e.getMessage(), null); + } + } + + private void removeModemStatusCallback(@NonNull MethodCall call, @NonNull Result result) { + try { + modemStreamHandler.stopListening(); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "removeModemStatusCallback error", e); + result.error("REMOVE_MODEM_CALLBACK_FAILED", e.getMessage(), null); + } + } + + // ==================== 错误查询 ==================== + + private void querySerialErrorCount(@NonNull MethodCall call, @NonNull Result result) { + ensureManagerInitialized(); + try { + String deviceName = call.argument("deviceName"); + UsbDevice device = getDeviceOrThrow(deviceName); + int serialNumber = call.argument("serialNumber"); + String errorType = call.argument("errorType"); + + cn.wch.uartlib.base.error.SerialErrorType nativeType = + Ch34TypeConverter.toSerialErrorType(errorType); + int count = manager.querySerialErrorCount(device, serialNumber, nativeType); + result.success(count); + } catch (Exception e) { + Log.e(TAG, "querySerialErrorCount error", e); + result.error("QUERY_ERROR_COUNT_FAILED", e.getMessage(), null); + } + } + + // ==================== 全局配置 ==================== + + private void setReadTimeout(@NonNull MethodCall call, @NonNull Result result) { + try { + int timeout = call.argument("timeout"); + WCHUARTManager.setReadTimeout(timeout); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "setReadTimeout error", e); + result.error("SET_TIMEOUT_FAILED", e.getMessage(), null); + } + } + + private void addNewHardware(@NonNull MethodCall call, @NonNull Result result) { + try { + int vid = call.argument("vid"); + int pid = call.argument("pid"); + String chipType = call.argument("chipType"); + cn.wch.uartlib.chip.type.ChipType2 type = + Ch34TypeConverter.toChipType(chipType); + WCHUARTManager.addNewHardwareAndChipType(vid, pid, type); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "addNewHardware error", e); + result.error("ADD_HARDWARE_FAILED", e.getMessage(), null); + } + } + + private void setDebug(@NonNull MethodCall call, @NonNull Result result) { + try { + boolean enabled = call.argument("enabled"); + WCHUARTManager.setDebug(enabled); + result.success(null); + } catch (Exception e) { + Log.e(TAG, "setDebug error", e); + result.error("SET_DEBUG_FAILED", e.getMessage(), null); + } + } + + private void isDebugMode(@NonNull Result result) { + result.success(WCHUARTManager.isDebugMode()); + } + + // ==================== 辅助方法 ==================== + + private void ensureManagerInitialized() { + if (manager == null) { + Application app; + if (context instanceof Application) { + app = (Application) context; + } else { + app = (Application) context.getApplicationContext(); + } + manager = WCHUARTManager.getInstance(); + manager.init(app); + usbStateStreamHandler.setManager(manager); + usbStateStreamHandler.setContext(context); + } + } + + private UsbDevice getDeviceOrThrow(String deviceName) { + UsbDevice device = openedDevices.get(deviceName); + if (device == null) { + try { + ArrayList devices = manager.enumDevice(); + for (UsbDevice d : devices) { + if (d.getDeviceName().equals(deviceName)) { + return d; + } + } + } catch (Exception ignored) { + } + throw new IllegalStateException("Device not found: " + deviceName); + } + return device; + } +} diff --git a/android/src/main/java/com/example/ch34/Ch34TypeConverter.java b/android/src/main/java/com/example/ch34/Ch34TypeConverter.java new file mode 100644 index 0000000..7d3c3d7 --- /dev/null +++ b/android/src/main/java/com/example/ch34/Ch34TypeConverter.java @@ -0,0 +1,134 @@ +package com.example.ch34; + +import java.util.HashMap; +import java.util.Map; + +/** + * 类型转换工具类。 + * + * 在 Dart 枚举值和 Java/WCH 类型之间进行转换。 + */ +public class Ch34TypeConverter { + + private Ch34TypeConverter() { + } + + /** + * 将 GPIO 状态对象转换为 Map。 + * + * @param status WCH GPIO_Status 对象。 + * @param gpioIndex GPIO 编号。 + * @return 包含 index/direction/value/enabled 的 Map。 + */ + public static Map gpioStatusToMap( + cn.wch.uartlib.base.gpio.GPIO_Status status, int gpioIndex) { + Map map = new HashMap<>(); + if (status != null) { + map.put("index", gpioIndex); + map.put("enabled", status.isEnabled()); + map.put("direction", status.getDir() == cn.wch.uartlib.base.gpio.GPIO_DIR.OUT ? 1 : 0); + map.put("value", status.getValue() == cn.wch.uartlib.base.gpio.GPIO_VALUE.HIGH ? 1 : 0); + } else { + map.put("index", gpioIndex); + map.put("enabled", false); + map.put("direction", 0); + map.put("value", 0); + } + return map; + } + + /** + * 将 Dart 端的方向索引转换为 WCH GPIO_DIR 枚举。 + * + * @param index 0 = IN, 1 = OUT。 + * @return WCH GPIO_DIR 枚举。 + */ + public static cn.wch.uartlib.base.gpio.GPIO_DIR toGpioDirection(int index) { + if (index == 1) { + return cn.wch.uartlib.base.gpio.GPIO_DIR.OUT; + } + return cn.wch.uartlib.base.gpio.GPIO_DIR.IN; + } + + /** + * 将 Dart 端的值索引转换为 WCH GPIO_VALUE 枚举。 + * + * @param index 0 = LOW, 1 = HIGH。 + * @return WCH GPIO_VALUE 枚举。 + */ + public static cn.wch.uartlib.base.gpio.GPIO_VALUE toGpioValue(int index) { + if (index == 1) { + return cn.wch.uartlib.base.gpio.GPIO_VALUE.HIGH; + } + return cn.wch.uartlib.base.gpio.GPIO_VALUE.LOW; + } + + /** + * 将芯片类型字符串转换为 WCH ChipType2 枚举。 + * + * @param chipType 芯片类型描述字符串(如 "CH340"、"CH9102")。 + * @return WCH ChipType2 枚举。 + * @throws IllegalArgumentException 当 chipType 为 null 或未知类型时抛出。 + */ + public static cn.wch.uartlib.chip.type.ChipType2 toChipType(String chipType) { + if (chipType == null) { + throw new IllegalArgumentException("chipType is null"); + } + switch (chipType) { + case "CH340": + case "CH341": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH341; + case "CH342": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH342F; + case "CH343": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH343GP; + case "CH344": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH344L; + case "CH347": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH347TF; + case "CH9101": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9101U; + case "CH9102": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9102F; + case "CH9103": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9103M; + case "CH9104": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9104L; + case "CH9143": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9143; + case "CH9111": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9111L_MODE0; + case "CH9114": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH9114L; + case "CH339": + return cn.wch.uartlib.chip.type.ChipType2.CHIP_CH339W; + default: + throw new IllegalArgumentException("Unknown chipType: " + chipType); + } + } + + /** + * 将 Dart 端的错误类型字符串转换为 WCH SerialErrorType 枚举。 + * + * WCH 原生库仅支持 FRAME/PARITY/OVERRUN 三种错误类型。 + * + * @param errorType 错误类型字符串。 + * @return WCH SerialErrorType 枚举。 + * @throws IllegalArgumentException 传入的错误类型字符串无法识别时抛出。 + */ + public static cn.wch.uartlib.base.error.SerialErrorType toSerialErrorType(String errorType) { + if (errorType == null) { + throw new IllegalArgumentException("errorType is null"); + } + switch (errorType) { + case "SerialErrorType.FramingError": + return cn.wch.uartlib.base.error.SerialErrorType.FRAME; + case "SerialErrorType.ParityError": + return cn.wch.uartlib.base.error.SerialErrorType.PARITY; + case "SerialErrorType.OverrunError": + return cn.wch.uartlib.base.error.SerialErrorType.OVERRUN; + default: + throw new IllegalArgumentException("Unknown SerialErrorType: " + errorType); + } + } +} diff --git a/android/src/main/java/com/example/ch34/Ch34UsbStateStreamHandler.java b/android/src/main/java/com/example/ch34/Ch34UsbStateStreamHandler.java new file mode 100644 index 0000000..d16a91a --- /dev/null +++ b/android/src/main/java/com/example/ch34/Ch34UsbStateStreamHandler.java @@ -0,0 +1,114 @@ +package com.example.ch34; + +import io.flutter.plugin.common.EventChannel; +import android.content.Context; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import cn.wch.uartlib.WCHUARTManager; +import cn.wch.uartlib.callback.IUsbStateChange; + +import java.util.HashMap; +import java.util.Map; + +/** + * USB 插拔状态 EventChannel StreamHandler。 + * + * 基于 WCH 库的 {@link IUsbStateChange} 回调接收设备插入/拔出/权限变更事件, + * 并通过 EventChannel 通知 Flutter 端。 + */ +public class Ch34UsbStateStreamHandler implements EventChannel.StreamHandler { + + private static final String TAG = "Ch34UsbStateStream"; + + private EventChannel.EventSink eventSink; + private Context context; + private WCHUARTManager manager; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + /** WCH 库 USB 状态回调,监听设备插拔与权限变化 */ + private final IUsbStateChange wchUsbStateListener = new IUsbStateChange() { + @Override + public void usbDeviceAttach(UsbDevice device) { + if (device != null) { + notifyStateChanged(device.getDeviceName(), true); + } + } + + @Override + public void usbDeviceDetach(UsbDevice device) { + if (device != null) { + notifyStateChanged(device.getDeviceName(), false); + } + } + + @Override + public void usbDevicePermission(UsbDevice device, boolean granted) { + Log.d(TAG, "USB permission changed: " + + (device != null ? device.getDeviceName() : "null") + + ", granted=" + granted); + } + }; + + /** + * 设置 WCH 管理器。 + * + * 在管理器可用后自动注册 USB 状态监听器。 + * + * @param manager WCHUARTManager 实例。 + */ + public void setManager(WCHUARTManager manager) { + this.manager = manager; + if (manager != null) { + try { + manager.setUsbStateListener(wchUsbStateListener); + Log.d(TAG, "WCH USB state listener registered"); + } catch (Exception e) { + Log.e(TAG, "Failed to register WCH USB state listener", e); + } + } + } + + /** + * 设置上下文。 + * + * @param context Android Context。 + */ + public void setContext(Context context) { + this.context = context; + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + this.eventSink = events; + Log.d(TAG, "USB state stream listening"); + } + + @Override + public void onCancel(Object arguments) { + this.eventSink = null; + Log.d(TAG, "USB state stream cancelled"); + } + + /** + * 通知设备状态变化。 + * + * 会在主线程上执行 eventSink.success(),确保线程安全。 + * + * @param deviceName 设备名称。 + * @param connected `true` 已连接,`false` 已断开。 + */ + public void notifyStateChanged(String deviceName, boolean connected) { + mainHandler.post(() -> { + EventChannel.EventSink sink = eventSink; + if (sink != null) { + Map map = new HashMap<>(); + map.put("deviceName", deviceName); + map.put("connected", connected); + sink.success(map); + } + }); + } +} diff --git a/android/src/test/java/com/example/ch34/Ch34PluginTest.java b/android/src/test/java/com/example/ch34/Ch34PluginTest.java new file mode 100644 index 0000000..29b7403 --- /dev/null +++ b/android/src/test/java/com/example/ch34/Ch34PluginTest.java @@ -0,0 +1,29 @@ +package com.example.ch34; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +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. + * + * 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. + */ + +public class Ch34PluginTest { + @Test + public void onMethodCall_getPlatformVersion_returnsExpectedValue() { + Ch34Plugin plugin = new Ch34Plugin(); + + 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); + } +} diff --git a/docs/CH34X-api_docs.md b/docs/CH34X-api_docs.md new file mode 100644 index 0000000..7d3fcec --- /dev/null +++ b/docs/CH34X-api_docs.md @@ -0,0 +1,703 @@ +# CH34X 系列芯片串口 Android 程序开发说明 + +**版本:1.8** + +https://wch.cn + +--- + +## 一、简介 + +本文档适用于 CH339 / CH340 / CH341 / CH342 / CH343 / CH344 / CH347 / CH9101 / CH9102 / CH9103 / CH9104 / CH9143 / CH9111 / CH9114 的 USB 转串口芯片开发说明。 + +本文档其余部分均以 CH340 系列 USB 转异步串口功能(以下简称 CH34XUART)以及 GPIO 功能,以及 Android 4.4 和更新版本的系统进行说明。在功能基于 USB Host 实现连接,用户需调用相应的 API 实现与 Android 设备进行数据交互。 + +Android Host 和 USB Device 的通讯关系参考图: + +``` +USB HOST USB Device +Android (USB HOST/OTG) ─── USB 通讯 ─── 转接芯片 ─── UART ─── PC/MCU/串口设备 +``` + +CH34X 串口转接芯片 Android 接口需要 Android 4.4 及以上版本系统,使用 CH34X 需要 Android 满足条件: + +1. 基于 Android 4.4 及以上版本系统 +2. Android 设备具备 USB Host(或 OTG)接口 + +本文档将着重介绍调用 Android USB Host 与 Device 通讯相关 API 以及相应的操作说明。关于 Android USB Host 协议说明,可以参考 Google 官方文档。 + +--- + +## 二、Android Host + +本文档所指的程序是基于 Android 4.4 及以上版本系统下编写的。Android 应用程序的动画参考文章 Android Filter.xml 文件的 product id 和 vendor id,基于 CH34X UART 开发的 Android 应用程序主要架构和步骤,如下图: + +``` +┌──────────────────────────────┐ +│ Android Host │ +│ ┌──────────────────────┐ │ +│ │ User Layout │ │ +│ └──────────────────────┘ │ +│ │ +│ ┌──────────────────────┐ │ +│ │ CH34XUART │ │ +│ │ Applications │ │ +│ └──────────────────────┘ │ +│ ↕ │ +│ ┌──────────────────────┐ │ +│ │ CH34XDriver.jar (lib)│ │ +│ └──────────────────────┘ │ +└──────────────────────────────┘ +``` + +--- + +## 三、软件操作说明 + +用户需要在支持 USB Host 功能的 Android 设备上安装测试程序(CH34XUARTDemo.apk),点击打开测试程序,测试程序显示当前 USB 设备的状态。点击"打开"后,如果未连接相应的 USB 芯片的设备,则会显示设备未连接状态;点击"断开",则会显示设备未连接。在连接条件后,按钮显示"已连接"。用户通过串口调试功能,发送数据可以接收返回的数据。 + +--- + +## 四、函数接口说明 + +### 4.1 getInstance + +```java +public static WCHUARTManager getInstance() +``` + +用于获取全局唯一实例。 + +| 项目 | 说明 | +|------|------| +| 返回 | 返回全局唯一实例 | + +--- + +### 4.2 init + +```java +public void init(android.app.Application application) +``` + +初始化上下文,注册广播接收设备状态变化。 + +| 项目 | 说明 | +|------|------| +| 参数 | application - 全局上下文 | + +--- + +### 4.3 enumDevice + +```java +public java.util.ArrayList enumDevice() + throws java.lang.Exception +``` + +枚举当前所有符合要求的 USB 设备。 + +| 项目 | 说明 | +|------|------| +| 返回 | android.hardware.usb.UsbDevice 设备列表 | +| 抛出 | java.lang.Exception | + +--- + +### 4.4 getChipType + +```java +public cn.wch.uartlib.chipImpl.type.ChipType2 getChipType(@NonNull + android.hardware.usb.UsbDevice usbDevice) +``` + +获取 usbDevice 的芯片类型。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | +| 返回 | 芯片类型(usbDevice 是 WCH 芯片);否则表示无法识别 USB 设备的芯片类型 | + +--- + +### 4.5 openDevice + +```java +public boolean openDevice(@NonNull + android.hardware.usb.UsbDevice usbDevice) + throws cn.wch.uartlib.exception.UartLibException, + cn.wch.uartlib.exception.NoPermissionException, + cn.wch.uartlib.exception.ChipException +``` + +打开 USB 设备。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | +| 返回 | true 成功;false 失败 | +| 抛出 | cn.wch.uartlib.exception.UartLibException
cn.wch.uartlib.exception.NoPermissionException
cn.wch.uartlib.exception.ChipException | + +--- + +### 4.6 requestPermission + +```java +public void requestPermission(@NonNull + android.content.Context context, + @NonNull android.hardware.usb.UsbDevice usbDevice) + throws cn.wch.uartlib.exception.UartLibException +``` + +请求 USB 设备权限。 + +| 项目 | 说明 | +|------|------| +| 参数 | context - 上下文
usbDevice - USB 设备 | +| 抛出 | cn.wch.uartlib.exception.UartLibException | + +--- + +### 4.7 setUsbStateListener + +```java +public void setUsbStateListener(@NonNull + cn.wch.uartlib.callback.IUsbStateChange usbStateListener) +``` + +监听设备的状态变化。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbStateListener - 设备状态监听回调 | + +--- + +### 4.8 getSerialCount + +```java +public int getSerialCount(@NonNull + android.hardware.usb.UsbDevice usbDevice) +``` + +获取设备串口数量。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | +| 返回 | 返回串口数量。如果为负数,说明获取芯片类型失败 | + +--- + +### 4.9 enableSerial + +```java +public boolean enableSerial(@NonNull UsbDevice usbDevice, + int serialNumber, boolean enable) throws Exception +``` + +打开/关闭串口(实际仅对 CH9114 系列有效,其他类型设备无影响)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号
enable - false 关闭;true 打开 | +| 返回 | true 设置成功;false 设置失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.10 setSerialParameter + +```java +public boolean setSerialParameter(@NonNull + android.hardware.usb.UsbDevice usbDevice, + int serialNumber, + int baud, + int dataBit, + int stopBit, + int parityBit, + boolean flow) + throws java.lang.Exception +``` + +设置串口参数。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号
baud - 波特率
dataBit - 数据位 5,6,7,8
stopBit - 停止位 1,2
parityBit - 校验位 0 NONE; 1 ODD; 2 EVEN; 3 MARK; 4 SPACE
flow - true 打开流控;false 关闭 | +| 返回 | true 设置成功;false 设置失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.11 getSerialBaud + +```java +public int getSerialBaud(@NonNull UsbDevice usbDevice, int serialNumber) + throws Exception +``` + +获取串口波特率(实际仅对 CH9114 系列有效,其他类型设备无影响)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号 | +| 返回 | 大于 0:串口波特率;小于 0:出错 | +| 抛出 | java.lang.Exception | + +--- + +### 4.12 getChipMasterFrequency + +```java +public ChipMasterFrequency getChipMasterFrequency(@NonNull UsbDevice + usbDevice) throws Exception +``` + +获取芯片主频(实际仅对 CH9114 系列有效,其他类型设备无影响)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | +| 返回 | ChipMasterFrequency 对象:
- int frequency 频率
- Boolean switchEnable 是否允许切换
- int CoStatus 当前状态 | +| 抛出 | java.lang.Exception | + +--- + +### 4.13 syncWriteData + +```java +public int syncWriteData(@NonNull UsbDevice usbDevice, int serialNumber, + byte[] data, int length, int timeout) throws Exception +``` + +发送串口数据(同步发送)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号
data - 将要发送的数据
length - 将要发送数据的长度
timeout - 超时时间 | +| 返回 | 发送成功返回数据的长度 | +| 抛出 | java.lang.Exception | + +--- + +### 4.14 asyncWriteData + +```java +public void asyncWriteData(@NonNull UsbDevice usbDevice, int serialNumber, + byte[] data) throws Exception +``` + +发送串口数据(异步发送,将数据加入缓存队列发送,不能返回状态和结果)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号
data - 将要发送的数据 | +| 抛出 | java.lang.Exception | + +--- + +### 4.15 readData + +```java +public byte[] readData(@NonNull + android.hardware.usb.UsbDevice usbDevice, + int serialNumber) + throws java.lang.Exception +``` + +读取串口数据。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号 | +| 返回 | 读取到的数据 | +| 抛出 | cn.wch.uartlib.exception.ChipException | + +--- + +### 4.16 readData(重载方法) + +```java +public byte[] readData(@NonNull + android.hardware.usb.UsbDevice usbDevice, + int serialNumber, + int vTime, + int vMin) + throws java.lang.Exception +``` + +读取串口数据。 + +**说明:** + +1. 当 vTime>0,vMin>0 时,read 函数将阻塞直到读到最后一个字符时开始计时,超过 vTime 时间后,如果接收到的数据不足 vMin 字节,则返回。否则继续等待直到收到 vMin 个字节或超时。 +2. 当 vTime>0,vMin=0 时,read 函数立即返回,超时为每个字符等待 vTime 时间。 +3. 当 vTime=0,vMin>0 时,read 函数一直阻塞,直到读到 vMin 个字节后返回。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
serialNumber - 串口号
vTime - 等待时间
vMin - 最小读取字节数 | +| 返回 | 读取到的数据 | +| 抛出 | cn.wch.uartlib.exception.ChipException | + +--- + +### 4.17 registerDataCallback + +```java +public void registerDataCallback (@NonNull + android.hardware.usb.UsbDevice usbDevice, + cn.wch.uartlib.callback.IDataCallback dataCallback) + throws java.lang.Exception +``` + +注册串口数据回调(此方法可替代 readData 函数,用户通过回调方式接收数据)。注意:如果调用了 registerDataCallback(device,null),等同于 removeDataCallback(device)。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备
dataCallback - 数据回调 | +| 抛出 | java.lang.Exception | + +--- + +### 4.18 removeDataCallback + +```java +public void removeDataCallback (@NonNull + android.hardware.usb.UsbDevice usbDevice) +``` + +移除串口数据回调。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | + +--- + +### 4.19 isConnected + +```java +public boolean isConnected(@NonNull + android.hardware.usb.UsbDevice usbDevice) +``` + +判断 USB 设备是否已经连接。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | +| 返回 | true 已经连接;false 没有打开 | + +--- + +### 4.20 getConnectedDevices + +```java +public java.util.ArrayList getConnectedDevices() +``` + +获取当前已连接的设备。 + +| 项目 | 说明 | +|------|------| +| 返回 | 已经打开的设备列表 | + +--- + +### 4.21 disconnect + +```java +public void disconnect(@NonNull + android.hardware.usb.UsbDevice usbDevice) +``` + +断开 USB 设备连接。 + +| 项目 | 说明 | +|------|------| +| 参数 | usbDevice - USB 设备 | + +--- + +### 4.22 close + +```java +public void close(@NonNull Context context) +``` + +释放资源、断开所有连接设备。 + +| 项目 | 说明 | +|------|------| +| 参数 | context - 上下文 | + +--- + +### 4.23 isSupportGPIOFeature + +```java +public boolean isSupportGPIOFeature(UsbDevice device) + throws java.lang.Exception +``` + +检查本接口当前是否支持该设备的 GPIO 特性配置。应当在操作 GPIO 前调用。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备 | +| 返回 | true 支持;false 不支持 | +| 抛出 | java.lang.Exception | + +--- + +### 4.24 queryGPIOCount + +```java +public int queryGPIOCount(UsbDevice device) + throws java.lang.Exception +``` + +查询该 USB 设备的 GPIO 数量。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备 | +| 返回 | GPIO 数量 | +| 抛出 | java.lang.Exception | + +--- + +### 4.25 queryGPIOStatus + +```java +public GPIO_Status queryGPIOStatus(UsbDevice device, int gpioIndex) + throws java.lang.Exception +``` + +查询该 USB 设备的某个 GPIO 状态。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
gpioIndex - GPIO 序号,从 0 开始 | +| 返回 | GPIO 状态 | +| 抛出 | java.lang.Exception | + +--- + +### 4.26 queryAllGPIOStatus + +```java +public List queryAllGPIOStatus(UsbDevice device) + throws java.lang.Exception +``` + +查询该 USB 设备的全部 GPIO 状态。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备 | +| 返回 | 全部 GPIO 状态 | +| 抛出 | java.lang.Exception | + +--- + +### 4.27 enableGPIO + +```java +public boolean enableGPIO(UsbDevice device, int gpioIndex, boolean enable, + GPIO_DIR dir) + throws java.lang.Exception +``` + +使能该硬件设备的某个 GPIO。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
gpioIndex - GPIO 序号
enable - true 打开;false 关闭
dir - GPIO 方向 | +| 抛出 | java.lang.Exception | + +--- + +### 4.28 setGPIOVal + +```java +public boolean setGPIOVal(UsbDevice device, int gpioIndex, GPIO_VALUE value) + throws java.lang.Exception +``` + +设置该硬件设备的某个 GPIO 的电平值。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
gpioIndex - GPIO 序号
value - GPIO 电平值 | +| 返回 | true 操作成功;false 操作失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.29 getGPIOVal + +```java +public GPIO_VALUE getGPIOVal(UsbDevice device, int gpioIndex) + throws java.lang.Exception +``` + +获取该硬件设备的某个 GPIO 的电平值。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
gpioIndex - GPIO 序号 | +| 返回 | value - GPIO 电平值 | +| 抛出 | java.lang.Exception | + +--- + +### 4.30 setDTR + +```java +public boolean setDTR(@NonNull UsbDevice usbDevice, int serialNumber, boolean valid) + throws Exception +``` + +设置 DTR 信号。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
serialNumber - 串口号
valid - 是否有效(低电平有效) | +| 返回 | true 操作成功;false 操作失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.31 setRTS + +```java +public boolean setRTS(@NonNull UsbDevice usbDevice, int serialNumber, boolean valid) + throws Exception +``` + +设置 RTS 信号。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
serialNumber - 串口号
valid - 是否有效(低电平有效) | +| 返回 | true 操作成功;false 操作失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.32 setBreak + +```java +public boolean setBreak(@NonNull UsbDevice usbDevice, int serialNumber, boolean valid) + throws Exception +``` + +设置 Break 信号。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
serialNumber - 串口号
valid - 是否有效(低电平有效) | +| 返回 | true 操作成功;false 操作失败 | +| 抛出 | java.lang.Exception | + +--- + +### 4.33 registerModemStatusCallback + +```java +public void registerModemStatusCallback(@NonNull UsbDevice usbDevice, IModemStatus + modemStatus) throws Exception +``` + +注册 Modem 输入信号状态的回调。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
modemStatus - 状态回调 | +| 抛出 | java.lang.Exception | + +--- + +### 4.34 querySerialErrorCount + +```java +public int querySerialErrorCount(@NonNull UsbDevice usbDevice, int + serialNumber, @NonNull SerialErrorType errorType) throws Exception +``` + +查询串口错误状态。 + +| 项目 | 说明 | +|------|------| +| 参数 | device - USB 设备
serialNumber - 串口号
errorType - 错误类型 | +| 返回 | 该种错误的次数 | +| 抛出 | java.lang.Exception | + +--- + +### 4.35 setReadTimeout + +```java +public static void setReadTimeout(int timeout) +``` + +设置读超时时间。默认为 0,即采用异步方式读取;设置为非 0 时采用同步方式,超时时间为 BulkTransfer 同步传输的超时时间。全局生效,应在 APP 初始化时设置。 + +| 项目 | 说明 | +|------|------| +| 参数 | timeout - 超时时间,单位为 ms | + +--- + +### 4.36 addNewHardwareAndChipType + +```java +public static void addNewHardwareAndChipType(int vid, int pid, ChipType2 chipType) +``` + +添加新的硬件 VID/PID 以及芯片类型。 + +| 项目 | 说明 | +|------|------| +| 参数 | vid - 硬件 vid
pid - 硬件 pid
chipType - 芯片类型 | + +--- + +### 4.37 setDebug + +```java +public static void setDebug(boolean open) +``` + +设置调试模式打开或者关闭。打开调试模式会打印日志,默认关闭。应在 APP 初始化时设置。 + +| 项目 | 说明 | +|------|------| +| 参数 | open - true 打开;false 关闭 | + +--- + +### 4.38 isDebugMode + +```java +public static boolean isDebugMode() +``` + +返回当前是否在调试模式,是否打印日志。 + +| 项目 | 说明 | +|------|------| +| 返回 | true 处于调试模式;false 不处于调试模式 | + +--- + +## 附录 + +- **文档版本:** V1.8 +- **文档名称:** CH34X 系列芯片串口 Android 程序开发说明 +- **适用芯片型号:** CH339 / CH340 / CH341 / CH342 / CH343 / CH344 / CH347 / CH9101 / CH9102 / CH9103 / CH9104 / CH9143 / CH9111 / CH9114 +- **系统要求:** Android 4.4 及以上版本 +- **技术支持网站:** https://wch.cn diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md new file mode 100644 index 0000000..980dd2d --- /dev/null +++ b/docs/USAGE_GUIDE.md @@ -0,0 +1,1265 @@ +# CH34 Flutter 插件使用手册 + +> 版本: 1.0.0 +> 支持的芯片: CH340/CH341/CH342/CH343/CH344/CH347/CH9101/CH9102/CH9103/CH9104/CH9143 + +--- + +## 目录 + +1. [简介](#一简介) +2. [安装与配置](#二安装与配置) +3. [快速开始](#三快速开始) +4. [API 参考](#四api-参考) +5. [类型定义](#五类型定义) +6. [完整示例](#六完整示例) +7. [常见问题](#七常见问题) + +--- + +## 一、简介 + +`ch34` 是一个 Flutter 插件,为 WCH CH34X 系列 USB 转串口芯片提供 Flutter 接口支持。通过该插件,Flutter 应用可以与 CH34X 芯片进行串口通信。 + +### 支持的芯片型号 + +| 芯片型号 | 说明 | +|---------|------| +| CH340 | 单串口 USB 转串口芯片 | +| CH341 | 并口/串口 USB 转换芯片 | +| CH342 | 双串口 USB 转串口芯片 | +| CH343 | 增强型单串口芯片 | +| CH344 | 四串口 USB 转串口芯片 | +| CH347 | 高速 USB 转串口芯片 | +| CH9101 | 单串口 USB 转串口芯片 | +| CH9102 | 双串口 USB 转串口芯片 | +| CH9103 | 四串口 USB 转串口芯片 | +| CH9104 | 八串口 USB 转串口芯片 | +| CH9143 | 多串口 USB 转串口芯片 | + +### 系统要求 + +- Android 4.4 及以上版本 +- 支持 USB Host 或 OTG 功能的 Android 设备 + +--- + +## 二、安装与配置 + +### 2.1 添加依赖 + +在 `pubspec.yaml` 中添加: + +```yaml +dependencies: + ch34: ^1.0.0 +``` + +### 2.2 导入插件 + +```dart +import 'package:ch34/ch34.dart'; +``` + +### 2.3 初始化配置 + +在应用启动时进行初始化配置: + +```dart +// 开启调试模式(可选,默认关闭) +await Ch34Manager.setDebug(true); + +// 设置全局读取超时时间(可选,默认 0 表示异步传输) +await Ch34Manager.setReadTimeout(1000); // 1000ms +``` + +--- + +## 三、快速开始 + +### 3.1 基本使用流程 + +```dart +// 1. 枚举可用设备 +final devices = await Ch34Manager.enumDevice(); +if (devices.isEmpty) { + print('未找到 CH34X 设备'); + return; +} + +// 2. 选择设备 +final device = devices.first; +print('找到设备: ${device.deviceName}, 芯片: ${device.chipType}'); + +// 3. 打开设备 +final opened = await Ch34Manager.openDevice(device.deviceName); +if (!opened) { + print('打开设备失败'); + return; +} + +// 4. 设置串口参数 +await Ch34Manager.setSerialParameter( + device.deviceName, + 0, // 串口号(从 0 开始) + const SerialParameter( + baud: 9600, + dataBits: DataBits.bits8, + stopBits: StopBits.one, + parity: Parity.none, + hardwareFlowControl: false, + ), +); + +// 5. 注册数据回调 +Ch34Manager.registerDataCallback( + device.deviceName, + 0, + (data) { + print('收到数据: $data'); + }, +); + +// 6. 发送数据 +await Ch34Manager.writeData( + device.deviceName, + 0, + Uint8List.fromList([0x01, 0x02, 0x03]), +); + +// 7. 使用完毕后断开 +await Ch34Manager.disconnect(device.deviceName); +``` + +### 3.2 权限处理 + +```dart +// 请求 USB 设备权限 +final granted = await Ch34Manager.requestPermission(device.deviceName); +if (!granted) { + print('USB 权限被拒绝'); + return; +} +``` + +--- + +## 四、API 参考 + +### 4.1 基础方法 + +#### getPlatformVersion + +获取平台版本。 + +```dart +static Future getPlatformVersion() +``` + +**返回值**: 平台版本字符串 + +--- + +### 4.2 设备枚举与识别 + +#### enumDevice + +枚举当前所有可用的 USB 设备。 + +```dart +static Future> enumDevice() +``` + +**返回值**: 可用 USB 设备列表 +**异常**: `Ch34Exception` - 枚举失败时抛出 + +#### getChipType + +获取指定设备的芯片型号。 + +```dart +static Future getChipType(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: 芯片型号字符串,`null` 表示无法识别 + +--- + +### 4.3 设备打开与权限 + +#### openDevice + +打开 USB 设备。 + +```dart +static Future openDevice(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: `true` 成功,`false` 失败 + +#### requestPermission + +申请 USB 设备的权限。 + +```dart +static Future requestPermission(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: `true` 已授权,`false` 被拒绝 + +--- + +### 4.4 USB 状态监听 + +#### setUsbStateListener + +注册 USB 设备插拔状态监听。 + +```dart +static void setUsbStateListener( + void Function(String deviceName, bool connected) onStateChanged, +) +``` + +**参数**: +- `onStateChanged` - 状态变化回调函数 + +#### removeUsbStateListener + +移除 USB 状态监听。 + +```dart +static void removeUsbStateListener() +``` + +--- + +### 4.5 串口信息 + +#### getSerialCount + +获取设备的串口数目。 + +```dart +static Future getSerialCount(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: 串口数目,`-1` 表示读取芯片型号失败 + +#### getSerialBaud + +获取串口波特率(仅 CH9114 系列有效)。 + +```dart +static Future getSerialBaud(String deviceName, int serialNumber) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 + +**返回值**: 大于 0 表示波特率,小于 0 表示出错 + +#### getChipMasterFrequency + +获取芯片主频(仅 CH9114 系列有效)。 + +```dart +static Future getChipMasterFrequency(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: 芯片主频信息对象 + +#### enableSerial + +打开或关闭串口(仅 CH9114 系列有效)。 + +```dart +static Future enableSerial( + String deviceName, + int serialNumber, + bool enable, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `enable` - `true` 打开,`false` 关闭 + +**返回值**: `true` 设置成功,`false` 设置失败 + +--- + +### 4.6 串口参数设置 + +#### setSerialParameter + +设置串口参数。 + +```dart +static Future setSerialParameter( + String deviceName, + int serialNumber, + SerialParameter parameter, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号(从 0 开始) +- `parameter` - 串口参数配置 + +**返回值**: `true` 设置成功,`false` 设置失败 + +--- + +### 4.7 数据读写 + +#### writeData + +发送串口数据(同步发送)。 + +```dart +static Future writeData( + String deviceName, + int serialNumber, + Uint8List data, { + int timeout = 0, +}) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `data` - 要发送的数据 +- `timeout` - 超时时间(毫秒),0 表示不超时 + +**返回值**: 实际发送的字节数 + +#### asyncWriteData + +发送串口数据(异步发送)。 + +```dart +static Future asyncWriteData( + String deviceName, + int serialNumber, + Uint8List data, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `data` - 要发送的数据 + +**说明**: 将数据加入缓存持续发送,不返回状态和结果 + +#### readData + +阻塞读取串口数据。 + +```dart +static Future readData( + String deviceName, + int serialNumber, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 + +**返回值**: 读取到的数据 + +#### readDataWithTimeout + +主动读取串口数据(带超时参数)。 + +```dart +static Future readDataWithTimeout( + String deviceName, + int serialNumber, + int vTime, + int vMin, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `vTime` - 等待时间(毫秒) +- `vMin` - 读取的最小字节数 + +**返回值**: 读取到的数据 + +**读取行为说明**: +- `vTime>0, vMin>0`: 阻塞直到读取到第一个字符后开始计时,时间到或已读够 vMin 个字符则返回 +- `vTime>0, vMin=0`: 读到数据立即返回,否则最多等待 vTime +- `vTime=0, vMin>0`: 一直阻塞直到读到 vMin 个字符后返回 + +#### registerDataCallback + +注册串口数据回调。 + +```dart +static Future registerDataCallback( + String deviceName, + int serialNumber, + void Function(Uint8List data) onData, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `onData` - 数据接收回调函数 + +**说明**: 注册后数据自动推送,不需要主动调用 `readData`。**推荐使用此方式接收数据**。 + +#### removeDataCallback + +取消注册串口数据回调。 + +```dart +static void removeDataCallback(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +--- + +### 4.8 连接状态 + +#### isConnected + +判断 USB 设备是否已经连接。 + +```dart +static Future isConnected(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: `true` 已连接,`false` 未连接 + +#### getConnectedDevices + +获取当前已经打开的设备列表。 + +```dart +static Future> getConnectedDevices() +``` + +**返回值**: 已打开的设备名称列表 + +--- + +### 4.9 断开与关闭 + +#### disconnect + +断开 USB 设备的连接。 + +```dart +static Future disconnect(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +#### close + +释放资源,关闭所有串口设备。 + +```dart +static Future close() +``` + +--- + +### 4.10 GPIO 功能 + +#### isSupportGpio + +查询设备是否支持 GPIO 功能。 + +```dart +static Future isSupportGpio(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: `true` 支持,`false` 不支持 + +#### queryGpioCount + +查询该 USB 设备的 GPIO 数目。 + +```dart +static Future queryGpioCount(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: GPIO 数目 + +#### queryGpioStatus + +查询指定 GPIO 的状态。 + +```dart +static Future queryGpioStatus( + String deviceName, + int gpioIndex, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `gpioIndex` - GPIO 编号(从 0 开始) + +**返回值**: GPIO 状态 + +#### queryAllGpioStatus + +查询所有 GPIO 状态。 + +```dart +static Future> queryAllGpioStatus(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +**返回值**: 全部 GPIO 状态列表 + +#### enableGpio + +使能指定 GPIO。 + +```dart +static Future enableGpio( + String deviceName, + int gpioIndex, + bool enable, + GpioDirection direction, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `gpioIndex` - GPIO 编号 +- `enable` - `true` 使能,`false` 关闭 +- `direction` - GPIO 方向 + +**返回值**: `true` 使能成功,`false` 使能失败 + +#### setGpioVal + +设置指定 GPIO 的电平值。 + +```dart +static Future setGpioVal( + String deviceName, + int gpioIndex, + GpioValue value, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `gpioIndex` - GPIO 编号 +- `value` - GPIO 电平值 + +**返回值**: `true` 设置成功,`false` 设置失败 + +#### getGpioVal + +获取指定 GPIO 的电平值。 + +```dart +static Future getGpioVal( + String deviceName, + int gpioIndex, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `gpioIndex` - GPIO 编号 + +**返回值**: GPIO 电平值 + +--- + +### 4.11 信号控制 + +#### setDtr + +设置 DTR 信号。 + +```dart +static Future setDtr( + String deviceName, + int serialNumber, + bool valid, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `valid` - 是否有效(低电平有效) + +**返回值**: `true` 设置成功,`false` 设置失败 + +#### setRts + +设置 RTS 信号。 + +```dart +static Future setRts( + String deviceName, + int serialNumber, + bool valid, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `valid` - 是否有效(低电平有效) + +**返回值**: `true` 设置成功,`false` 设置失败 + +#### setBreakSignal + +设置 Break 信号。 + +```dart +static Future setBreakSignal( + String deviceName, + int serialNumber, + bool valid, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `valid` - 是否有效(低电平有效) + +**返回值**: `true` 设置成功,`false` 设置失败 + +--- + +### 4.12 Modem 状态回调 + +#### registerModemStatusCallback + +注册 Modem 控制信号状态回调。 + +```dart +static Future registerModemStatusCallback( + String deviceName, + void Function(ModemStatus status) onModemStatus, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `onModemStatus` - Modem 状态变化回调 + +#### removeModemStatusCallback + +移除 Modem 状态回调。 + +```dart +static void removeModemStatusCallback(String deviceName) +``` + +**参数**: +- `deviceName` - 设备名称 + +--- + +### 4.13 错误查询 + +#### querySerialErrorCount + +查询串口错误状态。 + +```dart +static Future querySerialErrorCount( + String deviceName, + int serialNumber, + SerialErrorType errorType, +) +``` + +**参数**: +- `deviceName` - 设备名称 +- `serialNumber` - 串口号 +- `errorType` - 错误类型 + +**返回值**: 该种错误出现的次数 + +--- + +### 4.14 全局配置 + +#### setReadTimeout + +设置读取超时时间。 + +```dart +static Future setReadTimeout(int timeout) +``` + +**参数**: +- `timeout` - 超时时间(毫秒) + +**说明**: 全局有效,应在 APP 初始化时调用 + +#### addNewHardware + +添加自定义硬件 VID/PID。 + +```dart +static Future addNewHardware( + int vid, + int pid, { + String? chipType, +}) +``` + +**参数**: +- `vid` - 硬件 VID +- `pid` - 硬件 PID +- `chipType` - 芯片类型(可选) + +#### setDebug + +设置调试模式。 + +```dart +static Future setDebug(bool enabled) +``` + +**参数**: +- `enabled` - `true` 开启,`false` 关闭 + +#### isDebugMode + +返回当前是否处于调试模式。 + +```dart +static Future isDebugMode() +``` + +**返回值**: `true` 处于调试模式,`false` 不处于 + +--- + +## 五、类型定义 + +### 5.1 枚举类型 + +#### DataBits - 数据位 + +```dart +enum DataBits { + bits5(5), // 5 位数据位 + bits6(6), // 6 位数据位 + bits7(7), // 7 位数据位 + bits8(8); // 8 位数据位(默认) +} +``` + +#### StopBits - 停止位 + +```dart +enum StopBits { + one(1), // 1 位停止位(默认) + two(2), // 2 位停止位 +} +``` + +#### Parity - 校验位 + +```dart +enum Parity { + none(0), // 无校验(默认) + odd(1), // 奇校验 + even(2), // 偶校验 + mark(3), // 标记校验 + space(4), // 空格校验 +} +``` + +#### GpioDirection - GPIO 方向 + +```dart +enum GpioDirection { + inDir, // 输入方向 + outDir, // 输出方向 +} +``` + +#### GpioValue - GPIO 电平值 + +```dart +enum GpioValue { + low, // 低电平 + high, // 高电平 +} +``` + +#### SerialErrorType - 串口错误类型 + +```dart +enum SerialErrorType { + framingError, // 帧错误 + parityError, // 校验错误 + overrunError, // 溢出错误 + breakInterrupt, // 中断错误 +} +``` + +### 5.2 数据类 + +#### SerialParameter - 串口参数 + +```dart +class SerialParameter { + const SerialParameter({ + this.baud = 115200, // 波特率 + this.dataBits = DataBits.bits8, // 数据位 + this.stopBits = StopBits.one, // 停止位 + this.parity = Parity.none, // 校验位 + this.hardwareFlowControl = false, // 硬件流控 + }); +} +``` + +**常用波特率**: 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200 + +#### UsbDeviceInfo - USB 设备信息 + +```dart +class UsbDeviceInfo { + final String deviceName; // 设备名称 + final int productId; // 产品 ID (PID) + final int vendorId; // 厂商 ID (VID) + final int serialCount; // 串口数量 + final String? chipType; // 芯片型号(如果识别) +} +``` + +#### GpioStatus - GPIO 状态 + +```dart +class GpioStatus { + final int index; // GPIO 编号 + final GpioDirection direction; // 方向 + final GpioValue value; // 电平值 + final bool enabled; // 是否已使能 +} +``` + +#### ChipMasterFrequency - 芯片主频 + +```dart +class ChipMasterFrequency { + final int frequency; // 芯片主频(Hz) + final bool switchEnable; // 是否允许切换主频 + final int coStatus; // 晶振状态 +} +``` + +#### ModemStatus - Modem 状态 + +```dart +class ModemStatus { + final bool cts; // Clear To Send + final bool dsr; // Data Set Ready + final bool ri; // Ring Indicator + final bool dcd; // Data Carrier Detect +} +``` + +--- + +## 六、完整示例 + +### 6.1 设备扫描与管理 + +```dart +import 'package:ch34/ch34.dart'; +import 'dart:typed_data'; + +class SerialDeviceManager { + static List _devices = []; + + /// 扫描并连接设备 + static Future scanAndConnect() async { + try { + // 获取已连接设备 + final connected = await Ch34Manager.getConnectedDevices(); + print('已连接设备: $connected'); + + // 枚举所有可用设备 + _devices = await Ch34Manager.enumDevice(); + print('发现 ${_devices.length} 个设备'); + + for (final device in _devices) { + print('设备: ${device.deviceName}'); + print(' VID: 0x${device.vendorId.toRadixString(16).toUpperCase().padLeft(4, '0')}'); + print(' PID: 0x${device.productId.toRadixString(16).toUpperCase().padLeft(4, '0')}'); + print(' 芯片: ${device.chipType ?? "未知"}'); + print(' 串口数: ${device.serialCount}'); + } + } on Ch34Exception catch (e) { + print('设备扫描失败: $e'); + } + } +} +``` + +### 6.2 串口通信封装 + +```dart +class SerialPortService { + String? _deviceName; + int _serialNumber = 0; + bool _isConnected = false; + + /// 打开串口 + Future open(String deviceName, {int baudRate = 9600}) async { + try { + // 请求权限 + final granted = await Ch34Manager.requestPermission(deviceName); + if (!granted) { + return false; + } + + // 打开设备 + final opened = await Ch34Manager.openDevice(deviceName); + if (!opened) { + return false; + } + + // 设置参数 + final configured = await Ch34Manager.setSerialParameter( + deviceName, + _serialNumber, + SerialParameter( + baud: baudRate, + dataBits: DataBits.bits8, + stopBits: StopBits.one, + parity: Parity.none, + ), + ); + + if (!configured) { + await Ch34Manager.disconnect(deviceName); + return false; + } + + _deviceName = deviceName; + _isConnected = true; + return true; + } catch (e) { + print('打开串口失败: $e'); + return false; + } + } + + /// 关闭串口 + Future close() async { + if (_deviceName != null) { + Ch34Manager.removeDataCallback(_deviceName!); + await Ch34Manager.disconnect(_deviceName!); + _deviceName = null; + _isConnected = false; + } + } + + /// 发送数据 + Future send(List data) async { + if (!_isConnected || _deviceName == null) { + throw StateError('串口未连接'); + } + + return await Ch34Manager.writeData( + _deviceName!, + _serialNumber, + Uint8List.fromList(data), + ); + } + + /// 异步发送数据 + Future sendAsync(List data) async { + if (!_isConnected || _deviceName == null) { + throw StateError('串口未连接'); + } + + await Ch34Manager.asyncWriteData( + _deviceName!, + _serialNumber, + Uint8List.fromList(data), + ); + } + + /// 注册数据接收回调 + Future onData(void Function(Uint8List data) callback) async { + if (_deviceName == null) { + throw StateError('串口未连接'); + } + + await Ch34Manager.registerDataCallback( + _deviceName!, + _serialNumber, + callback, + ); + } + + /// 获取连接状态 + bool get isConnected => _isConnected; +} +``` + +### 6.3 GPIO 控制示例 + +```dart +class GpioController { + /// 配置并设置 GPIO + static Future configureGpio( + String deviceName, + int gpioIndex, + ) async { + // 检查是否支持 GPIO + final supported = await Ch34Manager.isSupportGpio(deviceName); + if (!supported) { + print('设备不支持 GPIO 功能'); + return; + } + + // 查询 GPIO 数量 + final count = await Ch34Manager.queryGpioCount(deviceName); + if (gpioIndex >= count) { + print('GPIO 编号超出范围'); + return; + } + + // 使能 GPIO(输出方向) + final enabled = await Ch34Manager.enableGpio( + deviceName, + gpioIndex, + true, + GpioDirection.outDir, + ); + + if (!enabled) { + print('使能 GPIO 失败'); + return; + } + + // 设置高电平 + await Ch34Manager.setGpioVal( + deviceName, + gpioIndex, + GpioValue.high, + ); + + // 读取电平值验证 + final value = await Ch34Manager.getGpioVal(deviceName, gpioIndex); + print('GPIO $gpioIndex 电平: $value'); + } + + /// 查询所有 GPIO 状态 + static Future printAllGpioStatus(String deviceName) async { + final statuses = await Ch34Manager.queryAllGpioStatus(deviceName); + for (final status in statuses) { + print('GPIO ${status.index}: ' + '方向=${status.direction}, ' + '电平=${status.value}, ' + '使能=${status.enabled}'); + } + } +} +``` + +### 6.4 Modem 状态监听 + +```dart +class ModemMonitor { + static Future startMonitoring(String deviceName) async { + await Ch34Manager.registerModemStatusCallback( + deviceName, + (ModemStatus status) { + print('Modem 状态变化:'); + print(' CTS: ${status.cts}'); + print(' DSR: ${status.dsr}'); + print(' RI: ${status.ri}'); + print(' DCD: ${status.dcd}'); + }, + ); + } + + static void stopMonitoring(String deviceName) { + Ch34Manager.removeModemStatusCallback(deviceName); + } +} +``` + +### 6.5 错误检测 + +```dart +class ErrorDetector { + static Future checkErrors(String deviceName, int serialNumber) async { + final framingErrors = await Ch34Manager.querySerialErrorCount( + deviceName, + serialNumber, + SerialErrorType.framingError, + ); + + final parityErrors = await Ch34Manager.querySerialErrorCount( + deviceName, + serialNumber, + SerialErrorType.parityError, + ); + + print('帧错误: $framingErrors, 校验错误: $parityErrors'); + } +} +``` + +--- + +## 七、常见问题 + +### 7.1 找不到设备 + +**问题**: `enumDevice()` 返回空列表 + +**解决方法**: +1. 确认设备支持 USB Host/OTG 功能 +2. 检查 USB 线缆连接是否正常 +3. 确认 Android 设备已开启 OTG 功能(部分手机需要手动开启) +4. 检查芯片是否在支持列表中 + +### 7.2 权限被拒绝 + +**问题**: `requestPermission()` 返回 `false` + +**解决方法**: +1. 确保在 `AndroidManifest.xml` 中添加了 USB 权限声明 +2. 用户需要在系统弹窗中点击"允许" +3. 可以尝试重新请求权限 + +### 7.3 数据收发异常 + +**问题**: 发送数据成功但收不到回复 + +**解决方法**: +1. 确认串口参数(波特率、数据位、停止位、校验位)与对端设备一致 +2. 使用 `registerDataCallback` 代替 `readData`,推荐回调方式接收数据 +3. 检查线缆连接和电平匹配(3.3V vs 5V) +4. 使用 `querySerialErrorCount` 检查是否有串口错误 + +### 7.4 多串口设备使用 + +**问题**: CH344/CH9104 等多串口设备如何使用 + +**解决方法**: +```dart +// 多串口设备每个串口都需要单独配置 +for (int i = 0; i < device.serialCount; i++) { + await Ch34Manager.setSerialParameter( + device.deviceName, + i, // 不同的串口号 + const SerialParameter(baud: 9600), + ); + + Ch34Manager.registerDataCallback( + device.deviceName, + i, + (data) { + print('串口 $i 收到数据: $data'); + }, + ); +} +``` + +### 7.5 设备热插拔处理 + +**问题**: 设备拔出后如何检测 + +**解决方法**: +```dart +// 注册 USB 状态监听 +Ch34Manager.setUsbStateListener((deviceName, connected) { + if (!connected) { + print('设备 $deviceName 已拔出'); + // 清理资源 + Ch34Manager.removeDataCallback(deviceName); + } else { + print('设备 $deviceName 已插入'); + // 重新打开设备 + } +}); +``` + +### 7.6 自定义 VID/PID + +**问题**: 如何支持非标准 VID/PID 的设备 + +**解决方法**: +```dart +// 添加自定义硬件 +await Ch34Manager.addNewHardware( + 0x1234, // 自定义 VID + 0x5678, // 自定义 PID + chipType: 'CH340', // 可选:指定芯片类型 +); +``` + +--- + +## 附录:通信通道说明 + +插件内部使用以下通道与 Android 原生端通信: + +| 通道类型 | 名称 | 用途 | +|---------|------|------| +| MethodChannel | `ch34` | 双向方法调用 | +| EventChannel | `ch34/data` | 串口数据推送 | +| EventChannel | `ch34/modem` | Modem 状态变化 | +| EventChannel | `ch34/usb_state` | USB 插拔状态 | + +--- + +*文档版本: 1.0.0* +*插件版本: 1.0.0* diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..29a3a50 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..a820e25 --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# ch34_example + +Demonstrates how to use the ch34 plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +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. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..7056dd4 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,58 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") +if (flutterVersionCode == null) { + flutterVersionCode = "1" +} + +def flutterVersionName = localProperties.getProperty("flutter.versionName") +if (flutterVersionName == null) { + flutterVersionName = "1.0" +} + +android { + namespace = "com.example.ch34_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.ch34_example" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutterVersionCode.toInteger() + versionName = flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fb3d65a --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/java/com/example/ch34_example/MainActivity.java b/example/android/app/src/main/java/com/example/ch34_example/MainActivity.java new file mode 100644 index 0000000..64ed4e1 --- /dev/null +++ b/example/android/app/src/main/java/com/example/ch34_example/MainActivity.java @@ -0,0 +1,6 @@ +package com.example.ch34_example; + +import io.flutter.embedding.android.FlutterActivity; + +public class MainActivity extends FlutterActivity { +} diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..3b5b324 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..348c409 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..dbf9ff3 --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.7.0" apply false + id "org.jetbrains.kotlin.android" version "2.0.20" apply false +} + +include ":app" diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart new file mode 100644 index 0000000..c5ad9e8 --- /dev/null +++ b/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:ch34/ch34.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + final String? version = await Ch34Manager.getPlatformVersion(); + expect(version?.isNotEmpty, true); + }); +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..5231180 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:ch34/ch34.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + String _platformVersion = 'Unknown'; + List _devices = []; + String _status = '未连接'; + String _receivedData = ''; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + Future initPlatformState() async { + String platformVersion; + try { + platformVersion = + await Ch34Manager.getPlatformVersion() ?? 'Unknown platform version'; + } on PlatformException { + platformVersion = 'Failed to get platform version.'; + } + + if (!mounted) return; + + setState(() { + _platformVersion = platformVersion; + }); + } + + Future _scanDevices() async { + try { + final devices = await Ch34Manager.enumDevice(); + setState(() { + _devices = devices; + _status = '发现 ${devices.length} 个设备'; + }); + } catch (e) { + setState(() { + _status = '扫描失败: $e'; + }); + } + } + + Future _openDevice(String deviceName) async { + try { + final success = await Ch34Manager.openDevice(deviceName); + if (success) { + await Ch34Manager.setSerialParameter( + deviceName, + 0, + const SerialParameter(baud: 115200), + ); + + await Ch34Manager.registerDataCallback( + deviceName, + 0, + (Uint8List data) { + final hex = data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + setState(() { + _receivedData = '收到: $hex'; + }); + }, + ); + + setState(() { + _status = '已连接: $deviceName'; + }); + } else { + setState(() { + _status = '打开失败'; + }); + } + } catch (e) { + setState(() { + _status = '连接失败: $e'; + }); + } + } + + Future _sendData(String deviceName) async { + try { + final data = Uint8List.fromList([0x01, 0x02, 0x03]); + final sent = await Ch34Manager.writeData(deviceName, 0, data); + setState(() { + _status = '已发送 $sent 字节'; + }); + } catch (e) { + setState(() { + _status = '发送失败: $e'; + }); + } + } + + Future _disconnect(String deviceName) async { + try { + Ch34Manager.removeDataCallback(deviceName); + await Ch34Manager.disconnect(deviceName); + setState(() { + _status = '已断开'; + }); + } catch (e) { + setState(() { + _status = '断开失败: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('CH34X 示例'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('平台版本: $_platformVersion\n'), + Text('状态: $_status\n'), + if (_receivedData.isNotEmpty) + Text('接收数据: $_receivedData\n'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _scanDevices, + child: const Text('扫描设备'), + ), + const SizedBox(height: 8), + if (_devices.isNotEmpty) + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _devices.length, + itemBuilder: (context, index) { + final device = _devices[index]; + return ListTile( + title: Text('${device.deviceName}'), + subtitle: Text( + 'VID: 0x${device.vendorId.toRadixString(16).toUpperCase()} ' + 'PID: 0x${device.productId.toRadixString(16).toUpperCase()} ' + '端口: ${device.serialCount}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.power), + onPressed: () => _openDevice(device.deviceName), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _status.contains('已连接') + ? () => _sendData(device.deviceName) + : null, + ), + IconButton( + icon: const Icon(Icons.power_off), + onPressed: _status.contains('已连接') + ? () => _disconnect(device.deviceName) + : null, + ), + ], + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..c7043eb --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,283 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + ch34: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "1.1.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.5" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.1.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..47ec0bc --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,85 @@ +name: ch34_example +description: "Demonstrates how to use the ch34 plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: '>=3.4.3 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + ch34: + # When depending on this package from a real application you should use: + # ch34: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..7e1f80b --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:ch34_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => widget is Text && + widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/lib/ch34.dart b/lib/ch34.dart new file mode 100644 index 0000000..74fe51c --- /dev/null +++ b/lib/ch34.dart @@ -0,0 +1,68 @@ +/// CH34 Flutter 插件。 +/// +/// 用于与 WCH CH34X 系列 USB 转串口芯片进行通信。 +/// +/// 支持的芯片:CH340/CH341/CH342/CH343/CH344/CH347/CH9101/CH9102/CH9103/CH9104/CH9143 +/// +/// 使用方法: +/// ```dart +/// import 'package:ch34/ch34.dart'; +/// +/// // 初始化插件(必须在首次调用 Ch34Manager 前调用) +/// Ch34.ensureInitialized(); +/// +/// // 枚举设备 +/// final devices = await Ch34Manager.enumDevice(); +/// +/// // 打开设备 +/// await Ch34Manager.openDevice(devices.first.deviceName); +/// +/// // 设置参数 +/// await Ch34Manager.setSerialParameter( +/// devices.first.deviceName, +/// 0, +/// const SerialParameter(baud: 9600), +/// ); +/// +/// // 发送数据 +/// await Ch34Manager.writeData( +/// devices.first.deviceName, +/// 0, +/// Uint8List.fromList([0x01, 0x02]), +/// ); +/// +/// // 注册数据回调 +/// Ch34Manager.registerDataCallback( +/// devices.first.deviceName, +/// 0, +/// (data) { print('Received: $data'); }, +/// ); +/// ``` +library ch34; + +export 'src/ch34_method_channel.dart' show Ch34Exception; +export 'src/ch34_platform_interface.dart' show Ch34Platform; +export 'src/types/ch34_types.dart'; +export 'src/ch34_manager.dart'; + +import 'src/ch34_method_channel.dart'; + +/// CH34 插件入口类。 +/// +/// 提供插件初始化方法,确保平台实例已注册。 +class Ch34 { + Ch34._(); + + static bool _initialized = false; + + /// 初始化 CH34 插件。 + /// + /// 必须在首次调用 [Ch34Manager] 方法前调用此方法。 + /// 重复调用不会产生副作用。 + static void ensureInitialized() { + if (!_initialized) { + MethodChannelCh34.registerDefault(); + _initialized = true; + } + } +} diff --git a/lib/ch34_method_channel.dart b/lib/ch34_method_channel.dart new file mode 100644 index 0000000..f984df5 --- /dev/null +++ b/lib/ch34_method_channel.dart @@ -0,0 +1,5 @@ +/// 顶层导出,保持向后兼容。 +/// 所有实现已迁移到 `src/` 目录下。 +library ch34_method_channel; + +export 'src/ch34_method_channel.dart'; diff --git a/lib/ch34_platform_interface.dart b/lib/ch34_platform_interface.dart new file mode 100644 index 0000000..7cc6f3b --- /dev/null +++ b/lib/ch34_platform_interface.dart @@ -0,0 +1,5 @@ +/// 顶层导出,保持向后兼容。 +/// 所有实现已迁移到 `src/` 目录下。 +library ch34_platform_interface; + +export 'src/ch34_platform_interface.dart'; diff --git a/lib/src/ch34_manager.dart b/lib/src/ch34_manager.dart new file mode 100644 index 0000000..b19d052 --- /dev/null +++ b/lib/src/ch34_manager.dart @@ -0,0 +1,491 @@ +import 'dart:typed_data'; + +import 'ch34_platform_interface.dart'; +import 'types/ch34_types.dart'; + +/// CH34X USB 转串口插件的管理器。 +/// +/// 提供静态方法访问所有 WCH WCHUARTManager API。 +/// 所有方法委托给 [Ch34Platform.instance] 实现。 +class Ch34Manager { + Ch34Manager._(); + + /// ==================== 基础方法 ==================== + + /// 获取平台版本。 + /// + /// @return 平台版本字符串。 + static Future getPlatformVersion() { + return Ch34Platform.instance.getPlatformVersion(); + } + + /// ==================== 设备枚举与识别 ==================== + + /// 枚举当前所有可用的 USB 设备。 + /// + /// @return 可用 USB 设备列表。 + /// @throws Ch34Exception 如果枚举失败。 + static Future> enumDevice() { + return Ch34Platform.instance.enumDevice(); + } + + /// 获取该 UsbDevice 的芯片型号。 + /// + /// @param deviceName 设备名称。 + /// @return 芯片型号字符串,null 表示无法识别。 + static Future getChipType(String deviceName) { + return Ch34Platform.instance.getChipType(deviceName); + } + + /// ==================== 设备打开与权限 ==================== + + /// 打开 USB 设备。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 成功,`false` 失败。 + static Future openDevice(String deviceName) { + return Ch34Platform.instance.openDevice(deviceName); + } + + /// 申请 USB 设备的权限。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 已授权,`false` 被拒绝。 + static Future requestPermission(String deviceName) { + return Ch34Platform.instance.requestPermission(deviceName); + } + + /// ==================== USB 状态监听 ==================== + + /// 注册 USB 设备插拔状态监听。 + /// + /// @param onStateChanged 状态变化回调。 + static void setUsbStateListener( + void Function(String deviceName, bool connected) onStateChanged) { + Ch34Platform.instance.setUsbStateListener(onStateChanged); + } + + /// 移除 USB 状态监听。 + static void removeUsbStateListener() { + Ch34Platform.instance.removeUsbStateListener(); + } + + /// ==================== 串口信息 ==================== + + /// 获取设备的串口数目。 + /// + /// @param deviceName 设备名称。 + /// @return 串口数目,-1 表示读取芯片型号失败。 + static Future getSerialCount(String deviceName) { + return Ch34Platform.instance.getSerialCount(deviceName); + } + + /// 获取串口波特率。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @return 大于 0 表示串口波特率,小于 0 表示出错。 + static Future getSerialBaud(String deviceName, int serialNumber) { + return Ch34Platform.instance.getSerialBaud(deviceName, serialNumber); + } + + /// 获取芯片主频。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @return 芯片主频信息对象。 + static Future getChipMasterFrequency(String deviceName) { + return Ch34Platform.instance.getChipMasterFrequency(deviceName); + } + + /// 打开或关闭串口。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param enable `true` 打开,`false` 关闭。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future enableSerial( + String deviceName, + int serialNumber, + bool enable, + ) { + return Ch34Platform.instance.enableSerial(deviceName, serialNumber, enable); + } + + /// ==================== 串口参数设置 ==================== + + /// 设置串口参数。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号(从 0 开始)。 + /// @param parameter 串口参数配置。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future setSerialParameter( + String deviceName, + int serialNumber, + SerialParameter parameter, + ) { + return Ch34Platform.instance.setSerialParameter( + deviceName, + serialNumber, + parameter, + ); + } + + /// ==================== 数据读写 ==================== + + /// 发送串口数据(同步发送)。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param data 要发送的数据。 + /// @param timeout 超时时间(毫秒),0 表示不超时。 + /// @return 实际发送的字节数。 + static Future writeData( + String deviceName, + int serialNumber, + Uint8List data, { + int timeout = 0, + }) { + return Ch34Platform.instance.writeData( + deviceName, + serialNumber, + data, + timeout: timeout, + ); + } + + /// 发送串口数据(异步发送)。 + /// + /// 将数据加入缓存持续发送,不返回状态和结果。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param data 要发送的数据。 + static Future asyncWriteData( + String deviceName, + int serialNumber, + Uint8List data, + ) { + return Ch34Platform.instance.asyncWriteData( + deviceName, + serialNumber, + data, + ); + } + + /// 阻塞读取串口数据。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @return 读取到的数据。 + static Future readData( + String deviceName, + int serialNumber, + ) { + return Ch34Platform.instance.readData(deviceName, serialNumber); + } + + /// 主动读取串口数据(带超时参数)。 + /// + /// 读取行为说明: + /// - 当 vTime>0,vMin>0 时:阻塞直到读取到第一个字符后开始计时, + /// 时间到或已读够 vMin 个字符则返回。 + /// - 当 vTime>0,vMin=0 时:读到数据立即返回,否则最多等待 vTime。 + /// - 当 vTime=0,vMin>0 时:一直阻塞直到读到 vMin 个字符后返回。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param vTime 等待时间(毫秒)。 + /// @param vMin 读取的最小字节数。 + /// @return 读取到的数据。 + static Future readDataWithTimeout( + String deviceName, + int serialNumber, + int vTime, + int vMin, + ) { + return Ch34Platform.instance.readDataWithTimeout( + deviceName, + serialNumber, + vTime, + vMin, + ); + } + + /// 注册串口数据回调。 + /// + /// 注册后数据自动推送,不需要主动调用 [readData]。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param onData 数据接收回调。 + static Future registerDataCallback( + String deviceName, + int serialNumber, + void Function(Uint8List data) onData, + ) { + return Ch34Platform.instance.registerDataCallback( + deviceName, + serialNumber, + onData, + ); + } + + /// 取消注册串口数据回调。 + /// + /// @param deviceName 设备名称。 + static void removeDataCallback(String deviceName) { + Ch34Platform.instance.removeDataCallback(deviceName); + } + + /// ==================== 连接状态 ==================== + + /// 判断 USB 设备是否已经连接。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 已连接,`false` 未连接。 + static Future isConnected(String deviceName) { + return Ch34Platform.instance.isConnected(deviceName); + } + + /// 获取当前已经打开的设备列表。 + /// + /// @return 已打开的设备名称列表。 + static Future> getConnectedDevices() { + return Ch34Platform.instance.getConnectedDevices(); + } + + /// ==================== 断开与关闭 ==================== + + /// 断开 USB 设备的连接。 + /// + /// @param deviceName 设备名称。 + static Future disconnect(String deviceName) { + return Ch34Platform.instance.disconnect(deviceName); + } + + /// 释放资源,关闭所有串口设备。 + static Future close() { + return Ch34Platform.instance.close(); + } + + /// ==================== GPIO 功能 ==================== + + /// 查询设备是否支持 GPIO 功能。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 支持,`false` 不支持。 + static Future isSupportGpio(String deviceName) { + return Ch34Platform.instance.isSupportGpio(deviceName); + } + + /// 查询该 USB 设备的 GPIO 数目。 + /// + /// @param deviceName 设备名称。 + /// @return GPIO 数目。 + static Future queryGpioCount(String deviceName) { + return Ch34Platform.instance.queryGpioCount(deviceName); + } + + /// 查询该 USB 设备指定 GPIO 的状态。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号(从 0 开始)。 + /// @return GPIO 状态。 + static Future queryGpioStatus( + String deviceName, + int gpioIndex, + ) { + return Ch34Platform.instance.queryGpioStatus(deviceName, gpioIndex); + } + + /// 查询该 USB 设备的所有 GPIO 状态。 + /// + /// @param deviceName 设备名称。 + /// @return 全部 GPIO 状态列表。 + static Future> queryAllGpioStatus(String deviceName) { + return Ch34Platform.instance.queryAllGpioStatus(deviceName); + } + + /// 使能指定 GPIO。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @param enable `true` 使能,`false` 关闭。 + /// @param direction GPIO 方向。 + /// @return `true` 使能成功,`false` 使能失败。 + static Future enableGpio( + String deviceName, + int gpioIndex, + bool enable, + GpioDirection direction, + ) { + return Ch34Platform.instance.enableGpio( + deviceName, + gpioIndex, + enable, + direction, + ); + } + + /// 设置指定 GPIO 的电平值。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @param value GPIO 电平值。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future setGpioVal( + String deviceName, + int gpioIndex, + GpioValue value, + ) { + return Ch34Platform.instance.setGpioVal( + deviceName, + gpioIndex, + value, + ); + } + + /// 获取指定 GPIO 的电平值。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @return GPIO 电平值。 + static Future getGpioVal( + String deviceName, + int gpioIndex, + ) { + return Ch34Platform.instance.getGpioVal(deviceName, gpioIndex); + } + + /// ==================== 信号控制 ==================== + + /// 设置 DTR 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future setDtr( + String deviceName, + int serialNumber, + bool valid, + ) { + return Ch34Platform.instance.setDtr(deviceName, serialNumber, valid); + } + + /// 设置 RTS 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future setRts( + String deviceName, + int serialNumber, + bool valid, + ) { + return Ch34Platform.instance.setRts(deviceName, serialNumber, valid); + } + + /// 设置 Break 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + static Future setBreakSignal( + String deviceName, + int serialNumber, + bool valid, + ) { + return Ch34Platform.instance.setBreakSignal( + deviceName, + serialNumber, + valid, + ); + } + + /// ==================== Modem 状态回调 ==================== + + /// 注册 Modem 控制信号状态回调。 + /// + /// @param deviceName 设备名称。 + /// @param onModemStatus Modem 状态变化回调。 + static Future registerModemStatusCallback( + String deviceName, + void Function(ModemStatus status) onModemStatus, + ) { + return Ch34Platform.instance.registerModemStatusCallback( + deviceName, + onModemStatus, + ); + } + + /// 移除 Modem 状态回调。 + /// + /// @param deviceName 设备名称。 + static void removeModemStatusCallback(String deviceName) { + Ch34Platform.instance.removeModemStatusCallback(deviceName); + } + + /// ==================== 错误查询 ==================== + + /// 查询串口错误状态。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param errorType 错误类型。 + /// @return 该种错误出现的次数。 + static Future querySerialErrorCount( + String deviceName, + int serialNumber, + SerialErrorType errorType, + ) { + return Ch34Platform.instance.querySerialErrorCount( + deviceName, + serialNumber, + errorType, + ); + } + + /// ==================== 全局配置 ==================== + + /// 设置读取超时时间。 + /// + /// 全局有效,应在 APP 初始化时调用。 + /// + /// @param timeout 超时时间(毫秒)。 + static Future setReadTimeout(int timeout) { + return Ch34Platform.instance.setReadTimeout(timeout); + } + + /// 添加自定义硬件 VID/PID。 + /// + /// @param vid 硬件 VID。 + /// @param pid 硬件 PID。 + /// @param chipType 芯片类型(必填,如 "CH340"、"CH9102")。 + static Future addNewHardware(int vid, int pid, String chipType) { + return Ch34Platform.instance.addNewHardware(vid, pid, chipType); + } + + /// 设置调试模式。 + /// + /// @param enabled `true` 开启,`false` 关闭。 + static Future setDebug(bool enabled) { + return Ch34Platform.instance.setDebug(enabled); + } + + /// 返回当前是否处于调试模式。 + /// + /// @return `true` 处于调试模式,`false` 不处于。 + static Future isDebugMode() { + return Ch34Platform.instance.isDebugMode(); + } +} diff --git a/lib/src/ch34_method_channel.dart b/lib/src/ch34_method_channel.dart new file mode 100644 index 0000000..20c9e8c --- /dev/null +++ b/lib/src/ch34_method_channel.dart @@ -0,0 +1,589 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'ch34_platform_interface.dart'; +import 'types/ch34_types.dart'; + +/// [Ch34Platform] 的 MethodChannel 实现。 +/// +/// 将平台接口方法映射到原生端 MethodChannel 调用, +/// 使用 EventChannel 处理数据回调、Modem 状态和 USB 插拔事件。 +class MethodChannelCh34 extends Ch34Platform { + /// 注册默认实例。 + static void registerDefault() { + Ch34Platform.instance = MethodChannelCh34._(); + } + + MethodChannelCh34._(); + + /// MethodChannel 名称。 + @visibleForTesting + final methodChannel = const MethodChannel('ch34'); + + /// 数据 EventChannel 名称。 + @visibleForTesting + final dataEventChannel = const EventChannel('ch34/data'); + + /// Modem 状态 EventChannel 名称。 + @visibleForTesting + final modemEventChannel = const EventChannel('ch34/modem'); + + /// USB 状态 EventChannel 名称。 + @visibleForTesting + final usbStateEventChannel = const EventChannel('ch34/usb_state'); + + // ==================== 事件流订阅 ==================== + + StreamSubscription? _dataSubscription; + StreamSubscription? _modemSubscription; + StreamSubscription? _usbStateSubscription; + + void Function(String deviceName, bool connected)? _usbStateCallback; + void Function(Uint8List data)? _dataCallback; + void Function(ModemStatus status)? _modemCallback; + + // ==================== 基础方法 ==================== + + @override + Future getPlatformVersion() async { + final version = + await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } + + // ==================== 设备枚举与识别 ==================== + + @override + Future> enumDevice() async { + final result = await methodChannel.invokeMethod('enumDevice'); + if (result == null) return []; + return result + .map((e) => UsbDeviceInfo.fromMap(e as Map)) + .toList(); + } + + @override + Future getChipType(String deviceName) async { + return await methodChannel.invokeMethod( + 'getChipType', + {'deviceName': deviceName}, + ); + } + + // ==================== 设备打开与权限 ==================== + + @override + Future openDevice(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'openDevice', + {'deviceName': deviceName}, + ); + return result ?? false; + } + + @override + Future requestPermission(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'requestPermission', + {'deviceName': deviceName}, + ); + return result ?? false; + } + + // ==================== USB 状态监听 ==================== + + @override + void setUsbStateListener( + void Function(String deviceName, bool connected) onStateChanged) { + _usbStateCallback = onStateChanged; + _usbStateSubscription?.cancel(); + _usbStateSubscription = usbStateEventChannel + .receiveBroadcastStream() + .listen( + (event) { + final map = event as Map; + final deviceName = map['deviceName'] as String; + final connected = map['connected'] as bool; + _usbStateCallback?.call(deviceName, connected); + }, + onError: (error) { + debugPrint('CH34 USB state error: $error'); + }, + ); + } + + @override + void removeUsbStateListener() { + _usbStateSubscription?.cancel(); + _usbStateSubscription = null; + _usbStateCallback = null; + } + + // ==================== 串口信息 ==================== + + @override + Future getSerialCount(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'getSerialCount', + {'deviceName': deviceName}, + ); + return result ?? -1; + } + + @override + Future getSerialBaud(String deviceName, int serialNumber) async { + final result = await methodChannel.invokeMethod( + 'getSerialBaud', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + }, + ); + return result ?? -1; + } + + @override + Future getChipMasterFrequency(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'getChipMasterFrequency', + {'deviceName': deviceName}, + ); + if (result == null) { + throw const Ch34Exception('Failed to get chip master frequency'); + } + return ChipMasterFrequency.fromMap(result); + } + + // ==================== 串口参数设置 ==================== + + @override + Future enableSerial( + String deviceName, + int serialNumber, + bool enable, + ) async { + final result = await methodChannel.invokeMethod( + 'enableSerial', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'enable': enable, + }, + ); + return result ?? false; + } + + @override + Future setSerialParameter( + String deviceName, + int serialNumber, + SerialParameter parameter, + ) async { + final result = await methodChannel.invokeMethod( + 'setSerialParameter', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + ...parameter.toMap(), + }, + ); + return result ?? false; + } + + // ==================== 数据读写 ==================== + + @override + Future writeData( + String deviceName, + int serialNumber, + Uint8List data, { + int timeout = 0, + }) async { + final result = await methodChannel.invokeMethod( + 'writeData', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'data': data, + 'timeout': timeout, + }, + ); + return result ?? 0; + } + + @override + Future asyncWriteData( + String deviceName, + int serialNumber, + Uint8List data, + ) async { + await methodChannel.invokeMethod( + 'asyncWriteData', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'data': data, + }, + ); + } + + @override + Future readData(String deviceName, int serialNumber) async { + final result = await methodChannel.invokeMethod( + 'readData', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + }, + ); + return result ?? Uint8List(0); + } + + @override + Future readDataWithTimeout( + String deviceName, + int serialNumber, + int vTime, + int vMin, + ) async { + final result = await methodChannel.invokeMethod( + 'readDataWithTimeout', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'vTime': vTime, + 'vMin': vMin, + }, + ); + return result ?? Uint8List(0); + } + + @override + Future registerDataCallback( + String deviceName, + int serialNumber, + void Function(Uint8List data) onData, + ) async { + _dataCallback = onData; + await _cancelDataSubscription(); + _dataSubscription = dataEventChannel + .receiveBroadcastStream({'deviceName': deviceName}) + .listen( + (event) { + if (event is Uint8List) { + _dataCallback?.call(event); + } + }, + onError: (error) { + debugPrint('CH34 data callback error: $error'); + }, + ); + await methodChannel.invokeMethod('registerDataCallback', { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + }); + } + + @override + void removeDataCallback(String deviceName) { + _cancelDataSubscription(); + methodChannel.invokeMethod('removeDataCallback', { + 'deviceName': deviceName, + }); + } + + Future _cancelDataSubscription() async { + await _dataSubscription?.cancel(); + _dataSubscription = null; + _dataCallback = null; + } + + // ==================== 连接状态 ==================== + + @override + Future isConnected(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'isConnected', + {'deviceName': deviceName}, + ); + return result ?? false; + } + + @override + Future> getConnectedDevices() async { + final result = await methodChannel.invokeMethod( + 'getConnectedDevices', + ); + if (result == null) return []; + return result.map((e) => e.toString()).toList(); + } + + // ==================== 断开与关闭 ==================== + + @override + Future disconnect(String deviceName) async { + await methodChannel.invokeMethod( + 'disconnect', + {'deviceName': deviceName}, + ); + } + + @override + Future close() async { + // 清理所有订阅 + await _cancelDataSubscription(); + await _modemSubscription?.cancel(); + _modemSubscription = null; + _modemCallback = null; + _usbStateSubscription?.cancel(); + _usbStateSubscription = null; + _usbStateCallback = null; + + await methodChannel.invokeMethod('close'); + } + + // ==================== GPIO 功能 ==================== + + @override + Future isSupportGpio(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'isSupportGpio', + {'deviceName': deviceName}, + ); + return result ?? false; + } + + @override + Future queryGpioCount(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'queryGpioCount', + {'deviceName': deviceName}, + ); + return result ?? 0; + } + + @override + Future queryGpioStatus(String deviceName, int gpioIndex) async { + final result = await methodChannel.invokeMethod( + 'queryGpioStatus', + { + 'deviceName': deviceName, + 'gpioIndex': gpioIndex, + }, + ); + if (result == null) { + throw const Ch34Exception('Failed to query GPIO status'); + } + return GpioStatus.fromMap(result); + } + + @override + Future> queryAllGpioStatus(String deviceName) async { + final result = await methodChannel.invokeMethod( + 'queryAllGpioStatus', + {'deviceName': deviceName}, + ); + if (result == null) return []; + return result + .map((e) => GpioStatus.fromMap(e as Map)) + .toList(); + } + + @override + Future enableGpio( + String deviceName, + int gpioIndex, + bool enable, + GpioDirection direction, + ) async { + final result = await methodChannel.invokeMethod( + 'enableGpio', + { + 'deviceName': deviceName, + 'gpioIndex': gpioIndex, + 'enable': enable, + 'direction': direction.index, + }, + ); + return result ?? false; + } + + @override + Future setGpioVal( + String deviceName, + int gpioIndex, + GpioValue value, + ) async { + final result = await methodChannel.invokeMethod( + 'setGpioVal', + { + 'deviceName': deviceName, + 'gpioIndex': gpioIndex, + 'value': value.index, + }, + ); + return result ?? false; + } + + @override + Future getGpioVal(String deviceName, int gpioIndex) async { + final result = await methodChannel.invokeMethod( + 'getGpioVal', + { + 'deviceName': deviceName, + 'gpioIndex': gpioIndex, + }, + ); + if (result == null) { + throw const Ch34Exception('Failed to get GPIO value'); + } + return GpioValue.values[result]; + } + + // ==================== 信号控制 ==================== + + @override + Future setDtr(String deviceName, int serialNumber, bool valid) async { + final result = await methodChannel.invokeMethod( + 'setDtr', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'valid': valid, + }, + ); + return result ?? false; + } + + @override + Future setRts(String deviceName, int serialNumber, bool valid) async { + final result = await methodChannel.invokeMethod( + 'setRts', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'valid': valid, + }, + ); + return result ?? false; + } + + @override + Future setBreakSignal( + String deviceName, int serialNumber, bool valid) async { + final result = await methodChannel.invokeMethod( + 'setBreak', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'valid': valid, + }, + ); + return result ?? false; + } + + // ==================== Modem 状态回调 ==================== + + @override + Future registerModemStatusCallback( + String deviceName, + void Function(ModemStatus status) onModemStatus, + ) async { + _modemCallback = onModemStatus; + await _modemSubscription?.cancel(); + _modemSubscription = modemEventChannel + .receiveBroadcastStream({'deviceName': deviceName}) + .listen( + (event) { + final map = event as Map; + debugPrint('CH34 modem event received: $map'); + _modemCallback?.call(ModemStatus.fromMap(map)); + }, + onError: (error) { + debugPrint('CH34 modem callback error: $error'); + }, + ); + await methodChannel.invokeMethod('registerModemStatusCallback', { + 'deviceName': deviceName, + }); + } + + @override + void removeModemStatusCallback(String deviceName) { + _modemSubscription?.cancel(); + _modemSubscription = null; + _modemCallback = null; + methodChannel.invokeMethod('removeModemStatusCallback', { + 'deviceName': deviceName, + }); + } + + // ==================== 错误查询 ==================== + + @override + Future querySerialErrorCount( + String deviceName, + int serialNumber, + SerialErrorType errorType, + ) async { + final result = await methodChannel.invokeMethod( + 'querySerialErrorCount', + { + 'deviceName': deviceName, + 'serialNumber': serialNumber, + 'errorType': errorType.toNativeString(), + }, + ); + return result ?? 0; + } + + // ==================== 全局配置 ==================== + + @override + Future setReadTimeout(int timeout) async { + await methodChannel.invokeMethod( + 'setReadTimeout', + {'timeout': timeout}, + ); + } + + @override + Future addNewHardware(int vid, int pid, String chipType) async { + await methodChannel.invokeMethod( + 'addNewHardware', + { + 'vid': vid, + 'pid': pid, + 'chipType': chipType, + }, + ); + } + + @override + Future setDebug(bool enabled) async { + await methodChannel.invokeMethod( + 'setDebug', + {'enabled': enabled}, + ); + } + + @override + Future isDebugMode() async { + final result = + await methodChannel.invokeMethod('isDebugMode'); + return result ?? false; + } +} + +/// CH34 插件自定义异常。 +class Ch34Exception implements Exception { + const Ch34Exception(this.message); + + /// 异常描述。 + final String message; + + @override + String toString() => 'Ch34Exception: $message'; +} diff --git a/lib/src/ch34_platform_interface.dart b/lib/src/ch34_platform_interface.dart new file mode 100644 index 0000000..5a852ab --- /dev/null +++ b/lib/src/ch34_platform_interface.dart @@ -0,0 +1,418 @@ +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'types/ch34_types.dart'; + +/// CH34X USB 转串口插件的抽象平台接口。 +/// +/// 定义所有 WCH WCHUARTManager API 的方法签名。 +/// 具体实现由 `MethodChannelCh34` 提供。 +abstract class Ch34Platform extends PlatformInterface { + /// Constructs a Ch34Platform. + Ch34Platform() : super(token: _token); + + static final Object _token = Object(); + + static Ch34Platform? _instance; + + /// The default instance of [Ch34Platform] to use. + /// + /// Defaults to [MethodChannelCh34]. + /// 注意: 需要在应用初始化时调用 [Ch34Platform.registerDefaultInstance] 注册默认实例。 + static Ch34Platform get instance { + if (_instance == null) { + throw StateError( + 'Ch34Platform.instance has not been initialized. ' + 'Ensure the plugin is properly registered.', + ); + } + return _instance!; + } + + /// 注册默认平台实例。 + /// + /// 通常在插件初始化时由 [MethodChannelCh34] 调用。 + static void registerDefaultInstance(Ch34Platform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [Ch34Platform] when they register + /// themselves. + static set instance(Ch34Platform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + /// ==================== 基础方法 ==================== + + /// 获取平台版本。 + /// + /// @return 平台版本字符串。 + Future getPlatformVersion(); + + /// ==================== 设备枚举与识别 ==================== + + /// 枚举当前所有可用的 USB 设备。 + /// + /// 返回设备信息列表,包含 VID/PID、串口数量等。 + /// + /// @return 可用 USB 设备列表。 + /// @throws Ch34Exception 如果枚举失败。 + Future> enumDevice(); + + /// 获取该 UsbDevice 的芯片型号。 + /// + /// @param deviceName 设备名称(由 [enumDevice] 返回的 deviceName)。 + /// @return 芯片型号字符串。如果为 null,表示无法识别。 + Future getChipType(String deviceName); + + /// ==================== 设备打开与权限 ==================== + + /// 打开 USB 设备。 + /// + /// 打开设备后可以进行串口通信。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 成功,`false` 失败。 + Future openDevice(String deviceName); + + /// 申请 USB 设备的权限。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 已授权,`false` 被拒绝。 + Future requestPermission(String deviceName); + + /// ==================== USB 状态监听 ==================== + + /// 注册 USB 设备插拔状态监听。 + /// + /// 通过 EventChannel 监听设备的连接和断开事件。 + /// + /// @param onStateChanged 状态变化回调,参数为设备名称和是否已连接。 + void setUsbStateListener(void Function(String deviceName, bool connected) onStateChanged); + + /// 移除 USB 状态监听。 + void removeUsbStateListener(); + + /// ==================== 串口信息 ==================== + + /// 获取设备的串口数目。 + /// + /// @param deviceName 设备名称。 + /// @return 串口数目;如果为 -1,说明读取芯片型号失败。 + Future getSerialCount(String deviceName); + + /// 获取串口波特率。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @return 大于 0 表示串口波特率,小于 0 表示出错。 + Future getSerialBaud(String deviceName, int serialNumber); + + /// 获取芯片主频。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @return 芯片主频信息对象。 + Future getChipMasterFrequency(String deviceName); + + /// 打开或关闭串口。 + /// + /// 实际仅针对 CH9114 系列有效,其他类型设备无需调用。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param enable `true` 打开,`false` 关闭。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果操作失败。 + Future enableSerial(String deviceName, int serialNumber, bool enable); + + /// ==================== 串口参数设置 ==================== + + /// 设置串口参数。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号(从 0 开始)。 + /// @param parameter 串口参数配置。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果参数无效或设备未打开。 + Future setSerialParameter( + String deviceName, + int serialNumber, + SerialParameter parameter, + ); + + /// ==================== 数据读写 ==================== + + /// 发送串口数据(同步发送)。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param data 要发送的数据。 + /// @param timeout 超时时间(毫秒),0 表示不超时。 + /// @return 实际发送的字节数。 + /// @throws Ch34Exception 如果发送失败。 + Future writeData( + String deviceName, + int serialNumber, + Uint8List data, { + int timeout = 0, + }); + + /// 发送串口数据(异步发送)。 + /// + /// 将数据加入缓存持续发送,不返回状态和结果。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param data 要发送的数据。 + /// @throws Ch34Exception 如果发送失败。 + Future asyncWriteData( + String deviceName, + int serialNumber, + Uint8List data, + ); + + /// 阻塞读取串口数据。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @return 读取到的数据。 + /// @throws Ch34Exception 如果读取失败。 + Future readData(String deviceName, int serialNumber); + + /// 主动读取串口数据(带超时参数)。 + /// + /// 读取行为说明: + /// - 当 vTime>0,vMin>0 时:读取将保持阻塞直到读取到第一个字符, + /// 读到了第一个字符之后开始计时,此后若时间到了 vTime 或者时间未到 + /// 但已读够了 vMin 个字符则会返回。 + /// - 当 vTime>0,vMin=0 时:读取读到数据则立即返回,否则将为每个字符 + /// 最多等待 vTime 时间。 + /// - 当 vTime=0,vMin>0 时:读取一直阻塞,直到读到 vMin 个字符后立即返回。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param vTime 等待时间(毫秒)。 + /// @param vMin 读取的最小字节数。 + /// @return 读取到的数据。 + /// @throws Ch34Exception 如果读取失败。 + Future readDataWithTimeout( + String deviceName, + int serialNumber, + int vTime, + int vMin, + ); + + /// 注册串口数据回调。 + /// + /// 注册后数据会通过 EventChannel 自动推送,不需要主动调用 [readData]。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param onData 数据接收回调。 + /// @throws Ch34Exception 如果注册失败。 + Future registerDataCallback( + String deviceName, + int serialNumber, + void Function(Uint8List data) onData, + ); + + /// 取消注册串口数据回调。 + /// + /// @param deviceName 设备名称。 + void removeDataCallback(String deviceName); + + /// ==================== 连接状态 ==================== + + /// 判断 USB 设备是否已经连接。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 已连接,`false` 未连接。 + Future isConnected(String deviceName); + + /// 获取当前已经打开的设备列表。 + /// + /// @return 已打开的设备名称列表。 + Future> getConnectedDevices(); + + /// ==================== 断开与关闭 ==================== + + /// 断开 USB 设备的连接。 + /// + /// @param deviceName 设备名称。 + Future disconnect(String deviceName); + + /// 释放资源,关闭所有的串口设备。 + Future close(); + + /// ==================== GPIO 功能 ==================== + + /// 查询设备是否支持 GPIO 功能。 + /// + /// 应该在操作 GPIO 前调用。 + /// + /// @param deviceName 设备名称。 + /// @return `true` 支持,`false` 不支持。 + /// @throws Ch34Exception 如果查询失败。 + Future isSupportGpio(String deviceName); + + /// 查询该 USB 设备的 GPIO 数目。 + /// + /// @param deviceName 设备名称。 + /// @return GPIO 数目。 + /// @throws Ch34Exception 如果查询失败。 + Future queryGpioCount(String deviceName); + + /// 查询该 USB 设备指定 GPIO 的状态。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号(从 0 开始)。 + /// @return GPIO 状态。 + /// @throws Ch34Exception 如果查询失败。 + Future queryGpioStatus(String deviceName, int gpioIndex); + + /// 查询该 USB 设备的所有 GPIO 状态。 + /// + /// @param deviceName 设备名称。 + /// @return 全部 GPIO 状态列表。 + /// @throws Ch34Exception 如果查询失败。 + Future> queryAllGpioStatus(String deviceName); + + /// 使能指定 GPIO。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @param enable `true` 使能,`false` 关闭。 + /// @param direction GPIO 方向。 + /// @return `true` 使能成功,`false` 使能失败。 + /// @throws Ch34Exception 如果操作失败。 + Future enableGpio( + String deviceName, + int gpioIndex, + bool enable, + GpioDirection direction, + ); + + /// 设置指定 GPIO 的电平值。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @param value GPIO 电平值。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果操作失败。 + Future setGpioVal( + String deviceName, + int gpioIndex, + GpioValue value, + ); + + /// 获取指定 GPIO 的电平值。 + /// + /// @param deviceName 设备名称。 + /// @param gpioIndex GPIO 编号。 + /// @return GPIO 电平值。 + /// @throws Ch34Exception 如果操作失败。 + Future getGpioVal(String deviceName, int gpioIndex); + + /// ==================== 信号控制 ==================== + + /// 设置 DTR 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果操作失败。 + Future setDtr(String deviceName, int serialNumber, bool valid); + + /// 设置 RTS 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果操作失败。 + Future setRts(String deviceName, int serialNumber, bool valid); + + /// 设置 Break 信号。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param valid 是否有效(低电平有效)。 + /// @return `true` 设置成功,`false` 设置失败。 + /// @throws Ch34Exception 如果操作失败。 + Future setBreakSignal(String deviceName, int serialNumber, bool valid); + + /// ==================== Modem 状态回调 ==================== + + /// 注册 Modem 控制信号状态回调。 + /// + /// @param deviceName 设备名称。 + /// @param onModemStatus Modem 状态变化回调。 + /// @throws Ch34Exception 如果注册失败。 + Future registerModemStatusCallback( + String deviceName, + void Function(ModemStatus status) onModemStatus, + ); + + /// 移除 Modem 状态回调。 + /// + /// @param deviceName 设备名称。 + void removeModemStatusCallback(String deviceName); + + /// ==================== 错误查询 ==================== + + /// 查询串口错误状态。 + /// + /// @param deviceName 设备名称。 + /// @param serialNumber 串口号。 + /// @param errorType 错误类型。 + /// @return 该种错误出现的次数。 + /// @throws Ch34Exception 如果查询失败。 + Future querySerialErrorCount( + String deviceName, + int serialNumber, + SerialErrorType errorType, + ); + + /// ==================== 全局配置 ==================== + + /// 设置读取超时时间。 + /// + /// 默认为 0,使用 USBRequest 异步读取; + /// 如果不为 0,使用同步传输,超时单位为毫秒。 + /// 全局有效,应在 APP 初始化时调用。 + /// + /// @param timeout 超时时间(毫秒)。 + Future setReadTimeout(int timeout); + + /// 添加自定义硬件 VID/PID 及芯片类型。 + /// + /// 当用户修改了硬件设备的 VID 和 PID 后,需要将修改后的值添加到库中。 + /// + /// @param vid 硬件 VID。 + /// @param pid 硬件 PID。 + /// @param chipType 芯片类型(必填,如 "CH340"、"CH9102")。 + Future addNewHardware(int vid, int pid, String chipType); + + /// 设置调试模式。 + /// + /// 开启调试模式会打印日志,默认关闭。 + /// 应在 APP 初始化时调用。 + /// + /// @param enabled `true` 开启,`false` 关闭。 + Future setDebug(bool enabled); + + /// 返回当前是否处于调试模式。 + /// + /// @return `true` 处于调试模式,`false` 不处于。 + Future isDebugMode(); +} diff --git a/lib/src/types/ch34_types.dart b/lib/src/types/ch34_types.dart new file mode 100644 index 0000000..4ff7086 --- /dev/null +++ b/lib/src/types/ch34_types.dart @@ -0,0 +1,359 @@ +/// CH34X 插件的所有类型和枚举定义。 +/// +/// 对应 WCH WCHUARTManager API 文档中的参数类型。 +library ch34_types; + +/// 数据位 +enum DataBits { + bits5(5), + bits6(6), + bits7(7), + bits8(8); + + const DataBits(this.value); + final int value; + + static DataBits fromValue(int value) { + return DataBits.values.firstWhere( + (e) => e.value == value, + orElse: () => DataBits.bits8, + ); + } +} + +/// 停止位 +enum StopBits { + one(1), + two(2); + + const StopBits(this.value); + final int value; + + static StopBits fromValue(int value) { + return StopBits.values.firstWhere( + (e) => e.value == value, + orElse: () => StopBits.one, + ); + } +} + +/// 校验位 +enum Parity { + none(0), + odd(1), + even(2), + mark(3), + space(4); + + const Parity(this.value); + final int value; + + static Parity fromValue(int value) { + return Parity.values.firstWhere( + (e) => e.value == value, + orElse: () => Parity.none, + ); + } +} + +/// GPIO 方向 +enum GpioDirection { + inDir, + outDir; +} + +/// GPIO 电平值 +enum GpioValue { + low, + high; +} + +/// GPIO 状态 +class GpioStatus { + const GpioStatus({ + required this.index, + required this.direction, + required this.value, + required this.enabled, + }); + + factory GpioStatus.fromMap(Map map) { + return GpioStatus( + index: map['index'] as int, + direction: + GpioDirection.values[map['direction'] as int? ?? 0], + value: GpioValue.values[map['value'] as int? ?? 0], + enabled: map['enabled'] as bool? ?? false, + ); + } + + /// GPIO 编号(从 0 开始) + final int index; + + /// 方向 + final GpioDirection direction; + + /// 电平值 + final GpioValue value; + + /// 是否已使能 + final bool enabled; + + Map toMap() { + return { + 'index': index, + 'direction': direction.index, + 'value': value.index, + 'enabled': enabled, + }; + } + + @override + String toString() { + return 'GpioStatus(index: $index, direction: $direction, ' + 'value: $value, enabled: $enabled)'; + } +} + +/// 串口参数 +class SerialParameter { + const SerialParameter({ + this.baud = 115200, + this.dataBits = DataBits.bits8, + this.stopBits = StopBits.one, + this.parity = Parity.none, + this.hardwareFlowControl = false, + }); + + factory SerialParameter.fromMap(Map map) { + return SerialParameter( + baud: map['baud'] as int? ?? 115200, + dataBits: DataBits.fromValue(map['dataBits'] as int? ?? 8), + stopBits: StopBits.fromValue(map['stopBits'] as int? ?? 1), + parity: Parity.fromValue(map['parity'] as int? ?? 0), + hardwareFlowControl: map['hardwareFlowControl'] as bool? ?? false, + ); + } + + /// 波特率 + final int baud; + + /// 数据位 + final DataBits dataBits; + + /// 停止位 + final StopBits stopBits; + + /// 校验位 + final Parity parity; + + /// 硬件流控 + final bool hardwareFlowControl; + + Map toMap() { + return { + 'baud': baud, + 'dataBits': dataBits.value, + 'stopBits': stopBits.value, + 'parity': parity.value, + 'hardwareFlowControl': hardwareFlowControl, + }; + } + + @override + String toString() { + return 'SerialParameter(baud: $baud, dataBits: $dataBits, ' + 'stopBits: $stopBits, parity: $parity, ' + 'hardwareFlowControl: $hardwareFlowControl)'; + } + + SerialParameter copyWith({ + int? baud, + DataBits? dataBits, + StopBits? stopBits, + Parity? parity, + bool? hardwareFlowControl, + }) { + return SerialParameter( + baud: baud ?? this.baud, + dataBits: dataBits ?? this.dataBits, + stopBits: stopBits ?? this.stopBits, + parity: parity ?? this.parity, + hardwareFlowControl: + hardwareFlowControl ?? this.hardwareFlowControl, + ); + } +} + +/// 芯片主频信息 +/// +/// 对应 WCH API 4.12 节 `getChipMasterFrequency` 返回值。 +class ChipMasterFrequency { + const ChipMasterFrequency({ + required this.frequency, + required this.switchEnable, + required this.coStatus, + }); + + factory ChipMasterFrequency.fromMap(Map map) { + return ChipMasterFrequency( + frequency: map['frequency'] as int? ?? 0, + switchEnable: map['switchEnable'] as bool? ?? false, + coStatus: map['coStatus'] as int? ?? 0, + ); + } + + /// 芯片主频(Hz) + final int frequency; + + /// 是否允许切换主频 + final bool switchEnable; + + /// 晶振状态 + final int coStatus; + + Map toMap() { + return { + 'frequency': frequency, + 'switchEnable': switchEnable, + 'coStatus': coStatus, + }; + } + + @override + String toString() { + return 'ChipMasterFrequency(frequency: $frequency Hz, ' + 'switchEnable: $switchEnable, coStatus: $coStatus)'; + } +} + +/// Modem 状态 +class ModemStatus { + const ModemStatus({ + this.cts = false, + this.dsr = false, + this.ri = false, + this.dcd = false, + }); + + factory ModemStatus.fromMap(Map map) { + return ModemStatus( + cts: map['cts'] as bool? ?? false, + dsr: map['dsr'] as bool? ?? false, + ri: map['ri'] as bool? ?? false, + dcd: map['dcd'] as bool? ?? false, + ); + } + + /// Clear To Send + final bool cts; + + /// Data Set Ready + final bool dsr; + + /// Ring Indicator + final bool ri; + + /// Data Carrier Detect + final bool dcd; + + Map toMap() { + return { + 'cts': cts, + 'dsr': dsr, + 'ri': ri, + 'dcd': dcd, + }; + } + + @override + String toString() { + return 'ModemStatus(CTS: $cts, DSR: $dsr, RI: $ri, DCD: $dcd)'; + } +} + +/// 串口错误类型 +/// +/// 对应 WCH 原生库的 `SerialErrorType` 枚举(仅包含 FRAME/PARITY/OVERRUN 三种)。 +enum SerialErrorType { + framingError, + parityError, + overrunError; + + /// 转换为原生端字符串标识。 + String toNativeString() { + switch (this) { + case SerialErrorType.framingError: + return 'SerialErrorType.FramingError'; + case SerialErrorType.parityError: + return 'SerialErrorType.ParityError'; + case SerialErrorType.overrunError: + return 'SerialErrorType.OverrunError'; + } + } +} + +/// USB 设备信息 +class UsbDeviceInfo { + const UsbDeviceInfo({ + required this.deviceName, + required this.productId, + required this.vendorId, + required this.serialCount, + this.chipType, + }); + + factory UsbDeviceInfo.fromMap(Map map) { + return UsbDeviceInfo( + deviceName: map['deviceName'] as String, + productId: map['productId'] as int, + vendorId: map['vendorId'] as int, + serialCount: map['serialCount'] as int? ?? 1, + chipType: map['chipType'] as String?, + ); + } + + /// 设备名称 + final String deviceName; + + /// 产品 ID + final int productId; + + /// 厂商 ID + final int vendorId; + + /// 串口数量 + final int serialCount; + + /// 芯片型号(如果识别) + final String? chipType; + + Map toMap() { + return { + 'deviceName': deviceName, + 'productId': productId, + 'vendorId': vendorId, + 'serialCount': serialCount, + 'chipType': chipType, + }; + } + + @override + String toString() { + return 'UsbDeviceInfo(name: $deviceName, VID: 0x${vendorId.toRadixString(16).toUpperCase().padLeft(4, '0')}, ' + 'PID: 0x${productId.toRadixString(16).toUpperCase().padLeft(4, '0')}, ' + 'ports: $serialCount, chip: $chipType)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UsbDeviceInfo && + other.deviceName == deviceName && + other.productId == productId && + other.vendorId == vendorId; + } + + @override + int get hashCode => Object.hash(deviceName, productId, vendorId); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..fc9e9ac --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,25 @@ +name: ch34 +description: "CH34X" +version: 1.1.0 +homepage: + +environment: + sdk: '>=3.4.3 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + plugin: + platforms: + android: + package: com.example.ch34 + pluginClass: Ch34Plugin diff --git a/test/ch34_method_channel_test.dart b/test/ch34_method_channel_test.dart new file mode 100644 index 0000000..da474cc --- /dev/null +++ b/test/ch34_method_channel_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ch34/src/ch34_method_channel.dart'; +import 'package:ch34/src/ch34_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelCh34.registerDefault(); + MethodChannelCh34 platform = Ch34Platform.instance as MethodChannelCh34; + const MethodChannel channel = MethodChannel('ch34'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/test/ch34_test.dart b/test/ch34_test.dart new file mode 100644 index 0000000..a0f44b2 --- /dev/null +++ b/test/ch34_test.dart @@ -0,0 +1,205 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ch34/ch34.dart'; +import 'package:ch34/src/ch34_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockCh34Platform + with MockPlatformInterfaceMixin + implements Ch34Platform { + + @override + Future getPlatformVersion() => Future.value('42'); + + @override + Future> enumDevice() => Future.value([]); + + @override + Future getChipType(String deviceName) => Future.value('CH340'); + + @override + Future openDevice(String deviceName) => Future.value(true); + + @override + Future requestPermission(String deviceName) => Future.value(true); + + @override + void setUsbStateListener(void Function(String, bool) onStateChanged) {} + + @override + void removeUsbStateListener() {} + + @override + Future getSerialCount(String deviceName) => Future.value(1); + + @override + Future getSerialBaud(String deviceName, int serialNumber) => + Future.value(115200); + + @override + Future getChipMasterFrequency(String deviceName) => + Future.value(const ChipMasterFrequency( + frequency: 12000000, + switchEnable: false, + coStatus: 0, + )); + + @override + Future enableSerial(String deviceName, int serialNumber, bool enable) => + Future.value(true); + + @override + Future setSerialParameter( + String deviceName, + int serialNumber, + SerialParameter parameter, + ) => Future.value(true); + + @override + Future writeData( + String deviceName, + int serialNumber, + Uint8List data, { + int timeout = 0, + }) => Future.value(data.length); + + @override + Future asyncWriteData( + String deviceName, + int serialNumber, + Uint8List data, + ) => Future.value(); + + @override + Future readData(String deviceName, int serialNumber) => + Future.value(Uint8List(0)); + + @override + Future readDataWithTimeout( + String deviceName, + int serialNumber, + int vTime, + int vMin, + ) => Future.value(Uint8List(0)); + + @override + Future registerDataCallback( + String deviceName, + int serialNumber, + void Function(Uint8List) onData, + ) => Future.value(); + + @override + void removeDataCallback(String deviceName) {} + + @override + Future isConnected(String deviceName) => Future.value(true); + + @override + Future> getConnectedDevices() => Future.value([]); + + @override + Future disconnect(String deviceName) => Future.value(); + + @override + Future close() => Future.value(); + + @override + Future isSupportGpio(String deviceName) => Future.value(false); + + @override + Future queryGpioCount(String deviceName) => Future.value(0); + + @override + Future queryGpioStatus(String deviceName, int gpioIndex) => + throw const Ch34Exception('Not supported'); + + @override + Future> queryAllGpioStatus(String deviceName) => + Future.value([]); + + @override + Future enableGpio( + String deviceName, + int gpioIndex, + bool enable, + GpioDirection direction, + ) => Future.value(false); + + @override + Future setGpioVal( + String deviceName, + int gpioIndex, + GpioValue value, + ) => Future.value(false); + + @override + Future getGpioVal(String deviceName, int gpioIndex) => + Future.value(GpioValue.low); + + @override + Future setDtr(String deviceName, int serialNumber, bool valid) => + Future.value(true); + + @override + Future setRts(String deviceName, int serialNumber, bool valid) => + Future.value(true); + + @override + Future setBreakSignal( + String deviceName, int serialNumber, bool valid) => + Future.value(true); + + @override + Future registerModemStatusCallback( + String deviceName, + void Function(ModemStatus) onModemStatus, + ) => Future.value(); + + @override + void removeModemStatusCallback(String deviceName) {} + + @override + Future querySerialErrorCount( + String deviceName, + int serialNumber, + SerialErrorType errorType, + ) => Future.value(0); + + @override + Future setReadTimeout(int timeout) => Future.value(); + + @override + Future addNewHardware(int vid, int pid, String chipType) => Future.value(); + + @override + Future setDebug(bool enabled) => Future.value(); + + @override + Future isDebugMode() => Future.value(false); +} + +void main() { + MethodChannelCh34.registerDefault(); + final Ch34Platform initialPlatform = Ch34Platform.instance; + + test('$MethodChannelCh34 is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + MockCh34Platform fakePlatform = MockCh34Platform(); + Ch34Platform.instance = fakePlatform; + + expect(await Ch34Manager.getPlatformVersion(), '42'); + }); + + test('enumDevice returns empty list', () async { + MockCh34Platform fakePlatform = MockCh34Platform(); + Ch34Platform.instance = fakePlatform; + + final devices = await Ch34Manager.enumDevice(); + expect(devices, isEmpty); + }); +}