feat(android): 添加 CH934X USB 转串口芯片支持
- 在 AndroidManifest.xml 中添加 USB Host 权限声明 - 集成 CH934X Android SDK 并通过反射调用原生功能 - 实现设备查找、串口读写、GPIO 和 Modem 控制功能 - 添加异常回调机制处理设备拔出等情况 - 提供 Stream 数据流支持实时串口数据监听 - 完善单元测试覆盖所有核心功能模块
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 文档 2:USB Host 能力声明,使用 CH934X 转接设备时必须开启。 -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="ch934x_serial_example"
|
||||
android:name="${applicationName}"
|
||||
@@ -12,10 +17,6 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
@@ -25,17 +26,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
// This is a basic Flutter integration test.
|
||||
// CH934X 插件的集成测试入口。
|
||||
//
|
||||
// Since integration tests run in a full Flutter application, they can interact
|
||||
// with the host side of a plugin implementation, unlike Dart unit tests.
|
||||
//
|
||||
// For more information about Flutter integration tests, please see
|
||||
// https://flutter.dev/to/integration-testing
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
// 集成测试运行在完整的 Flutter 应用中,可以与原生层通信;
|
||||
// 当前插件主要覆盖 Android 平台,因此以下用例仅在连接真实
|
||||
// USB 设备时才有意义。CI 中通常会跳过该测试。
|
||||
|
||||
import 'package:ch934x_serial/ch934x_serial.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('getPlatformVersion test', (WidgetTester tester) async {
|
||||
testWidgets('plugin 暴露 deviceList API', (tester) async {
|
||||
final Ch934xSerial plugin = Ch934xSerial();
|
||||
final String? version = await plugin.getPlatformVersion();
|
||||
// The version string depends on the host platform running the test, so
|
||||
// just assert that some non-empty string is returned.
|
||||
expect(version?.isNotEmpty, true);
|
||||
final devices = await plugin.getDeviceList();
|
||||
// 集成测试环境下可能没有真实设备,允许为空。
|
||||
expect(devices, isA<List<Ch934xDeviceInfo>>());
|
||||
});
|
||||
}
|
||||
|
||||
+297
-44
@@ -1,58 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ch934x_serial/ch934x_serial.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// CH934X 插件示例应用,演示:
|
||||
/// 1. 设备查找;
|
||||
/// 2. 串口打开/关闭;
|
||||
/// 3. 数据发送与接收(GPIO/Modem 控制以按钮形式呈现)。
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
runApp(const Ch934xSerialExampleApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
String _platformVersion = 'Unknown';
|
||||
final _ch934xSerialPlugin = Ch934xSerial();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initPlatformState();
|
||||
}
|
||||
|
||||
// Platform messages are asynchronous, so we initialize in an async method.
|
||||
Future<void> initPlatformState() async {
|
||||
String platformVersion;
|
||||
// Platform messages may fail, so we use a try/catch PlatformException.
|
||||
// We also handle the message potentially returning null.
|
||||
try {
|
||||
platformVersion =
|
||||
await _ch934xSerialPlugin.getPlatformVersion() ?? 'Unknown platform version';
|
||||
} on PlatformException {
|
||||
platformVersion = 'Failed to get platform version.';
|
||||
}
|
||||
|
||||
// If the widget was removed from the tree while the asynchronous platform
|
||||
// message was in flight, we want to discard the reply rather than calling
|
||||
// setState to update our non-existent appearance.
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_platformVersion = platformVersion;
|
||||
});
|
||||
}
|
||||
class Ch934xSerialExampleApp extends StatelessWidget {
|
||||
const Ch934xSerialExampleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(title: const Text('Plugin example app')),
|
||||
body: Center(child: Text('Running on: $_platformVersion\n')),
|
||||
title: 'CH934X Serial Example',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const Ch934xSerialExamplePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Ch934xSerialExamplePage extends StatefulWidget {
|
||||
const Ch934xSerialExamplePage({super.key});
|
||||
|
||||
@override
|
||||
State<Ch934xSerialExamplePage> createState() =>
|
||||
_Ch934xSerialExamplePageState();
|
||||
}
|
||||
|
||||
class _Ch934xSerialExamplePageState extends State<Ch934xSerialExamplePage> {
|
||||
final Ch934xSerial _plugin = Ch934xSerial();
|
||||
final TextEditingController _commandController =
|
||||
TextEditingController(text: '*IDN?\n');
|
||||
|
||||
List<Ch934xDeviceInfo> _devices = const <Ch934xDeviceInfo>[];
|
||||
Ch934xDeviceInfo? _selectedDevice;
|
||||
Ch934xSerialPortInfo? _selectedPort;
|
||||
StreamSubscription<Ch934xException>? _exceptionSubscription;
|
||||
StreamSubscription<Uint8List>? _dataSubscription;
|
||||
|
||||
String _status = '未连接';
|
||||
String _receivedBuffer = '';
|
||||
bool _portOpened = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindExceptionCallback();
|
||||
_refreshDeviceList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_exceptionSubscription?.cancel();
|
||||
_dataSubscription?.cancel();
|
||||
if (_portOpened) {
|
||||
// 关闭串口,保证释放 USB 接口。
|
||||
_plugin.closePort();
|
||||
}
|
||||
_commandController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 注册异常回调(对应 7.3.1)。
|
||||
Future<void> _bindExceptionCallback() async {
|
||||
_exceptionSubscription = await _plugin.setExceptionCallback((event) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = '设备异常: $event';
|
||||
_portOpened = false;
|
||||
_dataSubscription?.cancel();
|
||||
_dataSubscription = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 触发设备列表刷新(对应 4.1.4)。
|
||||
Future<void> _refreshDeviceList() async {
|
||||
try {
|
||||
final devices = await _plugin.getDeviceList();
|
||||
setState(() {
|
||||
_devices = devices;
|
||||
_status = '已扫描到 ${devices.length} 台 CH934X 设备';
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = '设备扫描失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPort() async {
|
||||
final device = _selectedDevice;
|
||||
final port = _selectedPort;
|
||||
if (device == null || port == null) {
|
||||
setState(() => _status = '请先选择设备与串口');
|
||||
return;
|
||||
}
|
||||
final target = Ch934xPortTarget(
|
||||
deviceId: device.deviceId,
|
||||
interfaceNumber: device.interfaceCount > 0 ? 0 : 0,
|
||||
serialPortIndex: port.portIndex,
|
||||
);
|
||||
final ok = await _plugin.openPort(target);
|
||||
if (!ok) {
|
||||
setState(() => _status = '打开串口失败,请确认已授予 USB 权限');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_portOpened = true;
|
||||
_status = '串口 ${port.portIndex} 已打开';
|
||||
});
|
||||
_startReceiving();
|
||||
}
|
||||
|
||||
Future<void> _closePort() async {
|
||||
await _dataSubscription?.cancel();
|
||||
_dataSubscription = null;
|
||||
final ok = await _plugin.closePort();
|
||||
setState(() {
|
||||
_portOpened = false;
|
||||
_status = ok ? '串口已关闭' : '关闭串口失败';
|
||||
});
|
||||
}
|
||||
|
||||
/// 启动数据流订阅(基于 [Ch934xSerial.dataStream])。
|
||||
void _startReceiving() {
|
||||
_dataSubscription?.cancel();
|
||||
_dataSubscription = _plugin.dataStream().listen((chunk) {
|
||||
final text = String.fromCharCodes(chunk);
|
||||
setState(() => _receivedBuffer = '$_receivedBuffer$text');
|
||||
});
|
||||
}
|
||||
|
||||
/// 发送命令,展示 [Ch934xSerial.write] 的用法。
|
||||
Future<void> _sendCommand() async {
|
||||
if (!_portOpened) {
|
||||
setState(() => _status = '请先打开串口');
|
||||
return;
|
||||
}
|
||||
final data = Uint8List.fromList(_commandController.text.codeUnits);
|
||||
final written = await _plugin.write(data);
|
||||
setState(() => _status = '已写入 $written 字节');
|
||||
}
|
||||
|
||||
/// 查询 Modem 状态,展示 7.2.2 接口。
|
||||
Future<void> _queryModemStatus() async {
|
||||
if (!_portOpened) return;
|
||||
final status = await _plugin.getModemStatus();
|
||||
setState(() {
|
||||
_status = 'Modem 状态位: 0x${status.toRadixString(16).padLeft(2, '0')} '
|
||||
'(CTS=${ModemStatus.isSet(status, ModemStatus.cts)}, '
|
||||
'DSR=${ModemStatus.isSet(status, ModemStatus.dsr)})';
|
||||
});
|
||||
}
|
||||
|
||||
/// 控制 DTR/RTS,展示 7.2.1 接口。
|
||||
Future<void> _toggleModem() async {
|
||||
if (!_portOpened) return;
|
||||
final ok = await _plugin.setModemControl(dtr: 1, rts: 1);
|
||||
setState(() => _status = ok ? 'DTR/RTS 置高' : 'Modem 控制失败');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CH934X Serial Example'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '刷新设备列表',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refreshDeviceList,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ListView(
|
||||
children: [
|
||||
Text('状态: $_status'),
|
||||
const SizedBox(height: 8),
|
||||
if (_devices.isEmpty)
|
||||
const Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.usb_off),
|
||||
title: Text('未发现 CH934X 设备'),
|
||||
subtitle: Text('请确认 OTG 已连接,并授予 USB 权限'),
|
||||
),
|
||||
)
|
||||
else
|
||||
DropdownButton<Ch934xDeviceInfo>(
|
||||
isExpanded: true,
|
||||
value: _selectedDevice,
|
||||
hint: const Text('选择 CH934X 设备'),
|
||||
items: _devices
|
||||
.map(
|
||||
(d) => DropdownMenuItem<Ch934xDeviceInfo>(
|
||||
value: d,
|
||||
child: Text(
|
||||
'VID=0x${d.vendorId.toRadixString(16)} '
|
||||
'PID=0x${d.productId.toRadixString(16)} '
|
||||
'SN=${d.serialNumber ?? "-"}',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (device) {
|
||||
setState(() {
|
||||
_selectedDevice = device;
|
||||
_selectedPort = device?.serialPorts.isNotEmpty == true
|
||||
? device!.serialPorts.first
|
||||
: null;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_selectedDevice?.serialPorts.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
DropdownButton<Ch934xSerialPortInfo>(
|
||||
isExpanded: true,
|
||||
value: _selectedPort,
|
||||
hint: const Text('选择串口'),
|
||||
items: _selectedDevice!.serialPorts
|
||||
.map(
|
||||
(p) => DropdownMenuItem<Ch934xSerialPortInfo>(
|
||||
value: p,
|
||||
child: Text('port #${p.portIndex}'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (p) => setState(() => _selectedPort = p),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _portOpened ? null : _openPort,
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: const Text('打开'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _portOpened ? _closePort : null,
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('关闭'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _commandController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '发送数据',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _sendCommand,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('写入'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _queryModemStatus,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
label: const Text('查询 Modem'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _toggleModem,
|
||||
icon: const Icon(Icons.cable),
|
||||
label: const Text('DTR/RTS=1'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
if (!_portOpened) return;
|
||||
final v = await _plugin.getGpioInput(0);
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'GPIO0 = $v');
|
||||
},
|
||||
icon: const Icon(Icons.input),
|
||||
label: const Text('读取 GPIO0'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'接收缓冲区:\n$_receivedBuffer',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ packages:
|
||||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
version: "1.0.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
// 示例应用 Widget 测试,验证应用能正常构建出主入口。
|
||||
|
||||
import 'package:ch934x_serial_example/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ch934x_serial_example/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Verify Platform version', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('App boots and shows the example title', (tester) async {
|
||||
await tester.pumpWidget(const Ch934xSerialExampleApp());
|
||||
await tester.pump();
|
||||
|
||||
// Verify that platform version is retrieved.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(Widget widget) =>
|
||||
widget is Text && widget.data!.startsWith('Running on:'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('CH934X Serial Example'), findsWidgets);
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user