- 新增 setActivePort 方法用于在同一USB设备内部切换活跃串口 - 新增 requestUsbPermission 方法用于主动申请指定设备的USB权限 - 实现Android侧USB权限申请广播接收器和同步等待机制 - 在示例应用中集成权限预申请和端口切换UI逻辑 - 优化设备打开后自动拉取真实串口列表的功能 - 更新端口选择下拉框在打开状态下支持实时切换串口
394 lines
13 KiB
Dart
394 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:ch934x_serial/ch934x_serial.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// CH934X 插件示例应用,演示:
|
|
/// 1. 设备查找;
|
|
/// 2. 串口打开/关闭;
|
|
/// 3. 数据发送与接收(GPIO/Modem 控制以按钮形式呈现)。
|
|
void main() {
|
|
runApp(const Ch934xSerialExampleApp());
|
|
}
|
|
|
|
class Ch934xSerialExampleApp extends StatelessWidget {
|
|
const Ch934xSerialExampleApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
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;
|
|
/// 当前选中设备的可用串口索引集合(由 SDK getSerialCount 决定)。
|
|
List<int> _availablePortIndices = const <int>[];
|
|
int? _selectedPortIndex;
|
|
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)。
|
|
///
|
|
/// 刷新时主动给每个设备申请一次 USB 权限,避免用户点
|
|
/// "打开"时再被弹框打断。已授权的设备 SDK 会立即返回 true。
|
|
Future<void> _refreshDeviceList() async {
|
|
try {
|
|
final devices = await _plugin.getDeviceList();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_devices = devices;
|
|
// 清理已不再列表中的旧选项,避免 DropdownButton value 不在 items 中报错。
|
|
final selected = _selectedDevice;
|
|
if (selected != null && !devices.contains(selected)) {
|
|
_selectedDevice = null;
|
|
_availablePortIndices = const <int>[];
|
|
_selectedPortIndex = null;
|
|
} else if (selected != null) {
|
|
_availablePortIndices = _resolvePortIndices(selected);
|
|
final current = _selectedPortIndex;
|
|
if (current == null || !_availablePortIndices.contains(current)) {
|
|
_selectedPortIndex = _availablePortIndices.isNotEmpty
|
|
? _availablePortIndices.first
|
|
: null;
|
|
}
|
|
}
|
|
_status = '已扫描到 ${devices.length} 台 CH934X 设备';
|
|
});
|
|
// 后台并发请求权限,失败也不阻塞 UI。
|
|
for (final device in devices) {
|
|
unawaited(_plugin.requestUsbPermission(device.deviceId));
|
|
}
|
|
} on Exception catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _status = '设备扫描失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 把设备的串口描述转换为可用索引列表。
|
|
///
|
|
/// 若 SDK 还未在原生侧拿到真实串口数(需要 openPort 之后才能问出来),
|
|
/// 退回到 [Ch934xDeviceInfo.interfaceCount] 或最小占位 [0]。
|
|
List<int> _resolvePortIndices(Ch934xDeviceInfo device) {
|
|
if (device.serialPorts.isNotEmpty) {
|
|
return device.serialPorts.map((p) => p.portIndex).toList()..sort();
|
|
}
|
|
if (device.interfaceCount > 0) {
|
|
return List<int>.generate(device.interfaceCount, (i) => i);
|
|
}
|
|
return const <int>[0];
|
|
}
|
|
|
|
/// 打开设备后,主动从原生侧拉取真实串口列表。
|
|
Future<void> _refreshPortListAfterOpen(Ch934xDeviceInfo device) async {
|
|
final ports = await _plugin.getSerialPortList(
|
|
device.deviceId,
|
|
interfaceNumber: 0,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
// 用 SDK 返回的端口索引覆盖,确保与真实设备保持一致。
|
|
_availablePortIndices =
|
|
ports.map((p) => p.portIndex).toList()..sort();
|
|
if (_availablePortIndices.isEmpty) {
|
|
_availablePortIndices = const <int>[0];
|
|
}
|
|
if (_selectedPortIndex == null ||
|
|
!_availablePortIndices.contains(_selectedPortIndex)) {
|
|
_selectedPortIndex = _availablePortIndices.first;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _openPort() async {
|
|
final device = _selectedDevice;
|
|
final portIndex = _selectedPortIndex;
|
|
if (device == null || portIndex == null) {
|
|
setState(() => _status = '请先选择设备与串口');
|
|
return;
|
|
}
|
|
final target = Ch934xPortTarget(
|
|
deviceId: device.deviceId,
|
|
interfaceNumber: 0,
|
|
serialPortIndex: portIndex,
|
|
);
|
|
final ok = await _plugin.openPort(target);
|
|
if (!ok) {
|
|
setState(() => _status = '打开串口失败,请确认已授予 USB 权限');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_portOpened = true;
|
|
_status = '串口 $portIndex 已打开,正在拉取真实串口列表';
|
|
});
|
|
_startReceiving();
|
|
// openDevice 之后 SDK 才能正确返回串口数,刷新下拉框。
|
|
await _refreshPortListAfterOpen(device);
|
|
if (mounted && _portOpened) {
|
|
setState(() => _status = '已打开串口 $portIndex,可用 ${_availablePortIndices.length} 路');
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (device == null) {
|
|
_availablePortIndices = const <int>[];
|
|
_selectedPortIndex = null;
|
|
} else {
|
|
_availablePortIndices = _resolvePortIndices(device);
|
|
_selectedPortIndex = _availablePortIndices.isNotEmpty
|
|
? _availablePortIndices.first
|
|
: null;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
if (_selectedDevice != null) ...[
|
|
const SizedBox(height: 8),
|
|
DropdownButton<int>(
|
|
isExpanded: true,
|
|
value: _selectedPortIndex,
|
|
hint: const Text('选择串口'),
|
|
items: _availablePortIndices
|
|
.map(
|
|
(idx) => DropdownMenuItem<int>(
|
|
value: idx,
|
|
child: Text('port #$idx'),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: _portOpened
|
|
? (idx) async {
|
|
if (idx == null) return;
|
|
setState(() => _selectedPortIndex = idx);
|
|
await _plugin.setActivePort(idx);
|
|
if (mounted) {
|
|
setState(() => _status = '已切换到串口 $idx');
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
],
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|