17 changed files with 19 additions and 2418 deletions
@ -1,10 +0,0 @@ |
|||
{ |
|||
"mcpServers": { |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
}, |
|||
"amap-amap-sse": { |
|||
"url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" |
|||
} |
|||
} |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
|
|||
class JieliTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<JieliTestController>( |
|||
() => JieliTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -1,323 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'package:flutter/material.dart' hide ConnectionState; |
|||
import 'package:get/get.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
/// 杰理测试控制器 |
|||
class JieliTestController extends GetxController { |
|||
// Jieli插件实例 |
|||
late Jieli jieli; |
|||
|
|||
// 状态变量 |
|||
var isScanning = false.obs; |
|||
var deviceInfo = '未获取'.obs; |
|||
var connectStatus = '未连接'.obs; |
|||
var currentDevice = Rx<BleDevice?>(null); |
|||
var scannedDevices = RxList<BleDevice>([]); |
|||
var wakeupReceived = false.obs; |
|||
var audioDataSize = 0.obs; |
|||
var receivedFrames = 0.obs; |
|||
var audioDataReceiving = false.obs; |
|||
var volumeCommandResult = ''.obs; |
|||
|
|||
// 事件订阅 |
|||
StreamSubscription? _scanSubscription; |
|||
StreamSubscription? _connectionSubscription; |
|||
StreamSubscription? _wakeupSubscription; |
|||
StreamSubscription? _audioSubscription; |
|||
StreamSubscription? _commandResultSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeJieli(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_cancelAllSubscriptions(); |
|||
_disposeJieli(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 取消所有订阅 |
|||
void _cancelAllSubscriptions() { |
|||
_scanSubscription?.cancel(); |
|||
_connectionSubscription?.cancel(); |
|||
_wakeupSubscription?.cancel(); |
|||
_audioSubscription?.cancel(); |
|||
_commandResultSubscription?.cancel(); |
|||
} |
|||
|
|||
/// 初始化杰理SDK |
|||
void _initializeJieli() { |
|||
try { |
|||
jieli = Jieli(); |
|||
_setupEventListeners(); |
|||
} catch (e) { |
|||
// 初始化错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListeners() { |
|||
// 监听扫描结果 |
|||
_scanSubscription = jieli.scanResults.listen((devices) { |
|||
scannedDevices.assignAll(devices); |
|||
}); |
|||
|
|||
// 监听连接状态 |
|||
_connectionSubscription = jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
connectStatus.value = '已连接'; |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
connectStatus.value = '已断开'; |
|||
currentDevice.value = null; |
|||
break; |
|||
case ConnectionState.connecting: |
|||
connectStatus.value = '连接中...'; |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
connectStatus.value = '断开中...'; |
|||
break; |
|||
case ConnectionState.error: |
|||
connectStatus.value = '连接错误'; |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 监听唤醒信号 |
|||
_wakeupSubscription = jieli.wakeupStream.listen((wakeup) { |
|||
wakeupReceived.value = true; |
|||
Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); |
|||
}); |
|||
|
|||
// 监听音频数据 |
|||
_audioSubscription = jieli.audioDataStream.listen((data) { |
|||
// 更新音频数据统计 |
|||
audioDataSize.value += data.length; |
|||
receivedFrames.value++; |
|||
audioDataReceiving.value = true; |
|||
}); |
|||
|
|||
// 监听命令结果 |
|||
_commandResultSubscription = jieli.commandResultStream.listen((result) { |
|||
// 处理音量调整命令结果 |
|||
if (result.commandType == Jieli.commandTypeVolume) { |
|||
volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; |
|||
Get.snackbar( |
|||
result.success ? '音量调整成功' : '音量调整失败', |
|||
result.success ? '设备已更新音量' : '设备未能更新音量', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), |
|||
colorText: Colors.white, |
|||
duration: Duration(seconds: 2), |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 检查蓝牙状态 |
|||
Future<bool> checkBluetoothStatus() async { |
|||
try { |
|||
final isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
return await jieli.requestBluetoothEnable(); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
if (isScanning.value) return; |
|||
|
|||
final bluetoothReady = await checkBluetoothStatus(); |
|||
if (!bluetoothReady) { |
|||
Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isScanning.value = true; |
|||
|
|||
// 清空之前的设备列表 |
|||
scannedDevices.clear(); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
final success = await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
if (!success) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); |
|||
} |
|||
|
|||
// 10秒后自动更新UI状态 |
|||
Future.delayed(Duration(milliseconds: 10000), () { |
|||
isScanning.value = false; |
|||
}); |
|||
|
|||
} catch (e) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
if (!isScanning.value) return; |
|||
|
|||
try { |
|||
await jieli.stopScan(); |
|||
isScanning.value = false; |
|||
} catch (e) { |
|||
// 停止扫描错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<void> connectToDevice(BleDevice device) async { |
|||
try { |
|||
// 先断开当前连接 |
|||
if (currentDevice.value != null) { |
|||
await disconnectDevice(); |
|||
} |
|||
|
|||
// 更新UI状态 |
|||
connectStatus.value = '连接中...'; |
|||
|
|||
// 重置音频和唤醒相关的状态 |
|||
wakeupReceived.value = false; |
|||
audioDataSize.value = 0; |
|||
receivedFrames.value = 0; |
|||
audioDataReceiving.value = false; |
|||
|
|||
// 连接设备 |
|||
final success = await jieli.connect(device.id); |
|||
|
|||
if (success) { |
|||
// 连接成功,更新当前设备 |
|||
currentDevice.value = device; |
|||
deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; |
|||
} else { |
|||
deviceInfo.value = '连接失败'; |
|||
} |
|||
} catch (e) { |
|||
deviceInfo.value = '连接错误: ${e.toString()}'; |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnectDevice() async { |
|||
if (currentDevice.value == null) return; |
|||
|
|||
try { |
|||
connectStatus.value = '断开中...'; |
|||
|
|||
final success = await jieli.disconnect(); |
|||
|
|||
if (success) { |
|||
deviceInfo.value = '未连接设备'; |
|||
} |
|||
|
|||
} catch (e) { |
|||
// 断开连接错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<void> sendStartRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStartRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送开始录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送开始录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<void> sendStopRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStopRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送停止录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送停止录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<void> increaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.increaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送增加音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<void> decreaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.decreaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送减小音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
void _disposeJieli() { |
|||
try { |
|||
jieli.dispose(); |
|||
} catch (e) { |
|||
// 释放资源错误处理 |
|||
} |
|||
} |
|||
} |
|||
@ -1,568 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
class JieliTestView extends GetView<JieliTestController> { |
|||
const JieliTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'杰理蓝牙测试', |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
), |
|||
body: Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
_buildStatusCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildActionButtons(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDeviceInfo(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDevicesList(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'连接状态: ', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
controller.connectStatus.value, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: _getStatusColor(controller.connectStatus.value), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Obx(() => _buildStatusChip( |
|||
label: '唤醒信号', |
|||
isActive: controller.wakeupReceived.value, |
|||
activeColor: Colors.orange, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
Obx(() => _buildStatusChip( |
|||
label: '音频数据', |
|||
isActive: controller.audioDataReceiving.value, |
|||
activeColor: Colors.green, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.audioDataReceiving.value |
|||
? Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'音频数据统计:', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
'已接收 ${(controller.audioDataSize.value / 1024).toStringAsFixed(2)} KB', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'共 ${controller.receivedFrames.value} 帧', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusChip({ |
|||
required String label, |
|||
required bool isActive, |
|||
required Color activeColor, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
decoration: BoxDecoration( |
|||
color: isActive |
|||
? activeColor |
|||
: (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: isActive ? Colors.white : Colors.grey, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
SizedBox(width: 6.w), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isActive |
|||
? Colors.white |
|||
: (isDarkMode ? Colors.white70 : Colors.black54), |
|||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildActionButtons(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Obx(() => ElevatedButton.icon( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
icon: Icon( |
|||
controller.isScanning.value ? Icons.stop : Icons.bluetooth_searching, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
controller.isScanning.value ? '停止扫描' : '扫描设备', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: controller.isScanning.value |
|||
? Colors.red[600] |
|||
: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStartRecordingCommand, |
|||
icon: Icon( |
|||
Icons.mic, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'开始录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStopRecordingCommand, |
|||
icon: Icon( |
|||
Icons.stop_circle, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'停止录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.increaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_up, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'增加音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.decreaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_down, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'减小音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.orange[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.volumeCommandResult.value.isNotEmpty |
|||
? Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
|||
decoration: BoxDecoration( |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[100] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[100] |
|||
: Colors.red[100]), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Text( |
|||
'音量控制: ${controller.volumeCommandResult.value}', |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[900] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[900] |
|||
: Colors.red[900]), |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeviceInfo(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备信息:', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Container( |
|||
padding: EdgeInsets.all(8.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.black : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Obx(() => Text( |
|||
controller.deviceInfo.value, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
height: 1.4, |
|||
), |
|||
)), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => controller.currentDevice.value != null |
|||
? ElevatedButton.icon( |
|||
onPressed: controller.disconnectDevice, |
|||
icon: Icon( |
|||
Icons.bluetooth_disabled, |
|||
size: 16.sp, |
|||
), |
|||
label: Text( |
|||
'断开连接', |
|||
style: TextStyle(fontSize: 13.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDevicesList(bool isDarkMode) { |
|||
return Expanded( |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'扫描到的设备', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
'${controller.scannedDevices.length}个设备', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
Divider(height: 1, thickness: 1, color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
Expanded( |
|||
child: Obx(() => controller.scannedDevices.isEmpty |
|||
? Center( |
|||
child: controller.isScanning.value |
|||
? Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
CircularProgressIndicator(), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
'正在扫描...', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: Text( |
|||
'未扫描到设备', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
) |
|||
: ListView.separated( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h), |
|||
itemCount: controller.scannedDevices.length, |
|||
separatorBuilder: (context, index) => Divider( |
|||
height: 1, |
|||
thickness: 1, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|||
), |
|||
itemBuilder: (context, index) { |
|||
final device = controller.scannedDevices[index]; |
|||
return ListTile( |
|||
dense: true, |
|||
title: Text( |
|||
device.name.isEmpty ? '未知设备' : device.name, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备ID: ${device.id}', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'RSSI: ${device.rssi} dBm', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
trailing: Obx(() => controller.currentDevice.value?.id == device.id |
|||
? Chip( |
|||
label: Text( |
|||
'已连接', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
backgroundColor: Colors.green, |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|||
) |
|||
: ElevatedButton( |
|||
onPressed: () => controller.connectToDevice(device), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle(fontSize: 12.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(6.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
isThreeLine: true, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Color _getStatusColor(String status) { |
|||
switch (status) { |
|||
case '连接中...': |
|||
return Colors.orange; |
|||
case '已连接': |
|||
return Colors.green; |
|||
case '连接失败': |
|||
return Colors.red; |
|||
case '已断开': |
|||
return Colors.grey; |
|||
default: |
|||
return Colors.grey; |
|||
} |
|||
} |
|||
} |
|||
@ -1,144 +0,0 @@ |
|||
# Jieli 插件 |
|||
|
|||
一个用于连接杰理蓝牙耳机的Flutter插件,支持Android 12+的BLE通信。 |
|||
|
|||
## 功能 |
|||
|
|||
- 扫描支持特定BLE服务的耳机设备 |
|||
- 连接设备并发现服务 |
|||
- 接收耳机的唤醒通知 |
|||
- 向耳机发送控制命令启动录音 |
|||
- 接收音频数据流 |
|||
|
|||
## 技术规范 |
|||
|
|||
此插件实现了以下BLE特性: |
|||
|
|||
- **服务UUID**: 0000A100-0000-1000-8000-00805F9B34FB |
|||
- **音频数据特征UUID**: 0000A101-0000-1000-8000-00805F9B34FB |
|||
- **控制特征UUID**: 0000A102-0000-1000-8000-00805F9B34FB |
|||
|
|||
## 使用方法 |
|||
|
|||
### 初始化插件 |
|||
|
|||
```dart |
|||
final jieli = Jieli(); |
|||
``` |
|||
|
|||
### 检查蓝牙状态 |
|||
|
|||
```dart |
|||
bool isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
await jieli.requestBluetoothEnable(); |
|||
} |
|||
``` |
|||
|
|||
### 扫描设备 |
|||
|
|||
```dart |
|||
// 监听扫描结果 |
|||
jieli.scanResults.listen((devices) { |
|||
// 处理扫描到的设备列表 |
|||
for (var device in devices) { |
|||
print('发现设备: ${device.name} (${device.id}), RSSI: ${device.rssi}'); |
|||
} |
|||
}); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
// 手动停止扫描 |
|||
await jieli.stopScan(); |
|||
``` |
|||
|
|||
### 连接设备 |
|||
|
|||
```dart |
|||
// 监听连接状态 |
|||
jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
print('设备已连接'); |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
print('设备已断开'); |
|||
break; |
|||
case ConnectionState.connecting: |
|||
print('正在连接...'); |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
print('正在断开...'); |
|||
break; |
|||
case ConnectionState.error: |
|||
print('连接错误'); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 连接到设备 |
|||
await jieli.connect(deviceId); |
|||
|
|||
// 断开连接 |
|||
await jieli.disconnect(); |
|||
``` |
|||
|
|||
### 接收唤醒信号 |
|||
|
|||
```dart |
|||
// 监听唤醒信号 |
|||
jieli.wakeupStream.listen((wakeup) { |
|||
print('收到唤醒信号'); |
|||
// 通常在收到唤醒后自动发送开始录音命令 |
|||
}); |
|||
``` |
|||
|
|||
### 发送录音命令 |
|||
|
|||
```dart |
|||
// 发送开始录音命令 |
|||
await jieli.sendStartRecordingCommand(); |
|||
|
|||
// 发送停止录音命令 |
|||
await jieli.sendStopRecordingCommand(); |
|||
``` |
|||
|
|||
### 接收音频数据 |
|||
|
|||
```dart |
|||
// 监听音频数据流 |
|||
jieli.audioDataStream.listen((data) { |
|||
print('收到音频数据: ${data.length} 字节'); |
|||
// 处理音频数据 |
|||
}); |
|||
``` |
|||
|
|||
### 资源清理 |
|||
|
|||
```dart |
|||
// 在不再需要插件时释放资源 |
|||
await jieli.dispose(); |
|||
``` |
|||
|
|||
## 权限 |
|||
|
|||
使用此插件需要以下Android权限: |
|||
|
|||
- Android 12+ (API 31+): `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` |
|||
- Android 11及以下: `BLUETOOTH`, `BLUETOOTH_ADMIN` |
|||
|
|||
插件会在需要时自动请求权限,但建议在应用清单文件中声明这些权限: |
|||
|
|||
```xml |
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
``` |
|||
@ -1,57 +0,0 @@ |
|||
plugins { |
|||
// Android Library 插件 |
|||
id("com.android.library") |
|||
// Kotlin Android 插件 |
|||
id("org.jetbrains.kotlin.android") |
|||
id("com.github.johnrengelman.shadow") version "8.1.1" |
|||
} |
|||
|
|||
android { |
|||
// 命名空间,对应你插件的包名(需与代码内包名保持一致) |
|||
namespace = "com.yunqiinnovation.jieli" |
|||
|
|||
// 目标 SDK 版本 |
|||
compileSdk = 35 |
|||
|
|||
defaultConfig { |
|||
// 最低 SDK 版本 |
|||
minSdk = 21 |
|||
targetSdk = 33 |
|||
} |
|||
|
|||
// Java 语言级别兼容配置 |
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
// Kotlin 语言级别 |
|||
kotlinOptions { |
|||
jvmTarget = "11" |
|||
} |
|||
|
|||
// 添加packaging配置,排除冲突文件 |
|||
packaging { |
|||
resources { |
|||
excludes.add("META-INF/DEPENDENCIES") |
|||
excludes.add("META-INF/LICENSE") |
|||
excludes.add("META-INF/LICENSE.txt") |
|||
excludes.add("META-INF/license.txt") |
|||
excludes.add("META-INF/NOTICE") |
|||
excludes.add("META-INF/NOTICE.txt") |
|||
excludes.add("META-INF/notice.txt") |
|||
excludes.add("META-INF/*.kotlin_module") |
|||
} |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") |
|||
implementation("androidx.core:core-ktx:1.7.0") |
|||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0") |
|||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0") |
|||
implementation("androidx.lifecycle:lifecycle-service:2.4.0") |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") |
|||
implementation(project(":azure_speech")) |
|||
implementation(project(":jl_opus")) |
|||
} |
|||
@ -1 +0,0 @@ |
|||
rootProject.name = "jieli" |
|||
@ -1,22 +0,0 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.jieli"> |
|||
|
|||
<!-- 声明需要蓝牙功能 --> |
|||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> |
|||
|
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
|
|||
<!-- 前台服务权限 --> |
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|||
|
|||
</manifest> |
|||
@ -1,665 +0,0 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.bluetooth.* |
|||
import android.bluetooth.le.* |
|||
import android.content.Context |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.os.ParcelUuid |
|||
import android.util.Log |
|||
import androidx.lifecycle.LifecycleService |
|||
import androidx.lifecycle.MutableLiveData |
|||
import kotlinx.coroutines.* |
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
import com.yunqiinnovation.azure_speech.AzureAsrHelper |
|||
import com.yunqiinnovation.azure_speech.AzureTtsHelper |
|||
import com.yunqiinnovation.jl_opus.JlOpusPlugin |
|||
|
|||
/** |
|||
* BLE服务类,提供核心蓝牙功能 |
|||
*/ |
|||
class BleService(private val context: Context) { |
|||
companion object { |
|||
private const val TAG = "JieliBleService" |
|||
|
|||
// 服务和特征UUID - 根据协议规范重新定义 |
|||
// private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID |
|||
private const val AUDIO_SERVICE_UUID = "0000abc0-0000-1111-2222-123456789abc" // 主服务UUID |
|||
private const val NOTIFY_CHAR_UUID = |
|||
"0000abc2-0000-1111-2222-123456789abc" // 用于接收数据的特征(notify) |
|||
private const val WRITE_CHAR_UUID = |
|||
"0000abc1-0000-1111-2222-123456789abc" // 用于发送命令的特征(write without response) |
|||
private const val CLIENT_CHAR_CONFIG_UUID = |
|||
"00002902-0000-1000-8000-00805f9b34fb" // 标准的客户端特征配置描述符 |
|||
|
|||
// 连接状态常量 |
|||
const val STATE_DISCONNECTED = 0 |
|||
const val STATE_CONNECTING = 1 |
|||
const val STATE_CONNECTED = 2 |
|||
const val STATE_DISCONNECTING = 3 |
|||
const val STATE_ERROR = 4 |
|||
|
|||
// 扫描结果更新间隔(毫秒) |
|||
private const val SCAN_RESULT_UPDATE_INTERVAL = 500L |
|||
|
|||
// 命令类型常量 |
|||
const val COMMAND_VOLUME = 1 |
|||
} |
|||
|
|||
/** |
|||
* 回调接口 |
|||
*/ |
|||
interface Callback { |
|||
fun onScanResult(devices: List<Map<String, Any>>) |
|||
fun onConnectionStateChanged(state: Int) |
|||
fun onAudioDataReceived(data: ByteArray) |
|||
fun onWakeupSignalReceived() |
|||
fun onCommandResult(commandType: Int, success: Boolean) |
|||
} |
|||
|
|||
// region 文件相关 |
|||
private var outputFile: File? = null |
|||
private var fos: FileOutputStream? = null |
|||
|
|||
// Azure服务 |
|||
private var azureAsrHelper: AzureAsrHelper? = null |
|||
private var azureTtsHelper: AzureTtsHelper? = null |
|||
|
|||
// 获取插件实例 |
|||
private val opusPlugin = JlOpusPlugin() |
|||
|
|||
// 蓝牙管理器和适配器 |
|||
private val bluetoothManager = |
|||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter |
|||
private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner |
|||
|
|||
// GATT连接和特征 |
|||
private var bluetoothGatt: BluetoothGatt? = null |
|||
private var notifyChar: BluetoothGattCharacteristic? = null |
|||
private var writeChar: BluetoothGattCharacteristic? = null |
|||
|
|||
// 扫描相关 |
|||
private val scanHandler = Handler(Looper.getMainLooper()) |
|||
private val scanResults = ArrayList<ScanResult>() |
|||
private var isScanning = false |
|||
private var isPendingScanResultUpdate = false |
|||
private val scanResultUpdateRunnable = Runnable { |
|||
notifyScanResults() |
|||
isPendingScanResultUpdate = false |
|||
} |
|||
|
|||
// 连接状态LiveData |
|||
val connectionState = MutableLiveData<Int>(STATE_DISCONNECTED) |
|||
|
|||
// 统一回调接口 |
|||
var callback: Callback? = null |
|||
|
|||
/** |
|||
* 开始扫描BLE设备 |
|||
*/ |
|||
fun startScan(scanTimeout: Long = 5000): Boolean { |
|||
if (bluetoothLeScanner == null || isScanning) { |
|||
return false |
|||
} |
|||
|
|||
Log.i(TAG, "开始扫描BLE设备...") |
|||
scanResults.clear() |
|||
|
|||
val serviceUuid = UUID.fromString(AUDIO_SERVICE_UUID) |
|||
val scanFilter = ScanFilter.Builder() |
|||
// .setServiceUuid(ParcelUuid(serviceUuid)) |
|||
.build() |
|||
val scanFilters = listOf(scanFilter) |
|||
|
|||
val scanSettings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
|||
.build() |
|||
|
|||
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) |
|||
isScanning = true |
|||
|
|||
// 设置超时停止扫描 |
|||
scanHandler.postDelayed({ |
|||
Log.d( |
|||
TAG, |
|||
"定时器触发,准备停止扫描 [主线程:${Looper.myLooper() == Looper.getMainLooper()}]" |
|||
) |
|||
stopScan() |
|||
}, scanTimeout) |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 停止扫描 |
|||
*/ |
|||
fun stopScan(): Boolean { |
|||
Log.i(TAG, "停止扫描BLE设备") |
|||
|
|||
if (bluetoothLeScanner == null || !isScanning) { |
|||
return false |
|||
} |
|||
|
|||
bluetoothLeScanner.stopScan(scanCallback) |
|||
scanHandler.removeCallbacksAndMessages(null) |
|||
isScanning = false |
|||
|
|||
// 最后一次发送扫描结果 |
|||
notifyScanResults() |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 扫描回调 |
|||
*/ |
|||
private val scanCallback = object : ScanCallback() { |
|||
override fun onScanResult(callbackType: Int, result: ScanResult) { |
|||
val device = result.device |
|||
val deviceName = device.name ?: "未知设备" |
|||
val deviceAddress = device.address |
|||
val rssi = result.rssi |
|||
val scanRecord = result.scanRecord |
|||
if (!deviceName.contains("zxd")) { |
|||
return |
|||
} |
|||
Log.d(TAG, "============发现设备: $deviceName [$deviceAddress], RSSI: $rssi") |
|||
|
|||
// 打印所有发现的UUID以便调试 |
|||
val serviceUuids = scanRecord?.serviceUuids |
|||
if (serviceUuids != null) { |
|||
for (uuid in serviceUuids) { |
|||
Log.d(TAG, "设备UUID: ${uuid.uuid}") |
|||
} |
|||
} |
|||
|
|||
// 检查制造商数据 |
|||
for (i in 0 until 65535) { |
|||
val manufacturerData = scanRecord?.getManufacturerSpecificData(i) |
|||
if (manufacturerData != null) { |
|||
// Log.d(TAG, "发现厂商ID: $i, 数据长度: ${manufacturerData.size}") |
|||
// 这里可以根据已知的厂商ID和格式解析数据 |
|||
} |
|||
} |
|||
|
|||
// 避免重复添加 |
|||
|
|||
val existingIndex = scanResults.indexOfFirst { it.device.address == deviceAddress } |
|||
|
|||
if (existingIndex >= 0) { |
|||
|
|||
scanResults[existingIndex] = result |
|||
} else { |
|||
Log.d(TAG, "扫描回调A, scanResults[existingIndex]ADD") |
|||
scanResults.add(result) |
|||
} |
|||
|
|||
// 如果没有待处理的更新,安排一个 |
|||
if (!isPendingScanResultUpdate) { |
|||
isPendingScanResultUpdate = true |
|||
scanHandler.postDelayed(scanResultUpdateRunnable, SCAN_RESULT_UPDATE_INTERVAL) |
|||
} |
|||
} |
|||
|
|||
override fun onScanFailed(errorCode: Int) { |
|||
Log.e(TAG, "扫描失败,错误码: $errorCode") |
|||
isScanning = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将扫描结果通知回调 |
|||
*/ |
|||
private fun notifyScanResults() { |
|||
val devicesJson = scanResults.map { result -> |
|||
mapOf( |
|||
"id" to result.device.address, |
|||
"name" to (result.device.name ?: "未知设备"), |
|||
"rssi" to result.rssi |
|||
) |
|||
} |
|||
|
|||
callback?.onScanResult(devicesJson) |
|||
} |
|||
|
|||
/** |
|||
* 连接到指定设备 |
|||
*/ |
|||
fun connect(deviceAddress: String) { |
|||
if (bluetoothAdapter == null) { |
|||
Log.e(TAG, "蓝牙适配器不可用") |
|||
updateConnectionState(STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
// 先断开现有连接 |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
|
|||
try { |
|||
val device = bluetoothAdapter.getRemoteDevice(deviceAddress) |
|||
updateConnectionState(STATE_CONNECTING) |
|||
|
|||
// 连接GATT服务器 |
|||
Log.i(TAG, "正在连接设备: ${device.name ?: "未知设备"} [$deviceAddress]") |
|||
bluetoothGatt = |
|||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "连接设备时发生错误: ${e.message}") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 断开连接 |
|||
*/ |
|||
fun disconnect() { |
|||
bluetoothGatt?.disconnect() |
|||
updateConnectionState(STATE_DISCONNECTING) |
|||
} |
|||
|
|||
/** |
|||
* 更新连接状态并通知回调 |
|||
*/ |
|||
private fun updateConnectionState(state: Int) { |
|||
connectionState.postValue(state) |
|||
callback?.onConnectionStateChanged(state) |
|||
} |
|||
|
|||
/** |
|||
* GATT回调 |
|||
*/ |
|||
private val gattCallback = object : BluetoothGattCallback() { |
|||
|
|||
|
|||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { |
|||
if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "设备已连接,开始发现服务...") |
|||
updateConnectionState(STATE_CONNECTED) |
|||
|
|||
// 开始搜索服务 |
|||
gatt.discoverServices() |
|||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { |
|||
Log.i(TAG, "设备已断开连接") |
|||
updateConnectionState(STATE_DISCONNECTED) |
|||
|
|||
// 清理资源 |
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} else if (status != BluetoothGatt.GATT_SUCCESS) { |
|||
Log.e(TAG, "连接失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
|
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
} |
|||
} |
|||
|
|||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "服务发现成功") |
|||
|
|||
// 查找目标服务 |
|||
val audioService = gatt.getService(UUID.fromString(AUDIO_SERVICE_UUID)) |
|||
if (audioService != null) { |
|||
Log.i(TAG, "找到音频服务,开始遍历特征...") |
|||
audioService.characteristics.forEach { char -> |
|||
Log.i(TAG, "特征UUID: ${char.uuid}") |
|||
} |
|||
Log.i(TAG, "找到音频服务$") |
|||
|
|||
// 获取特征 |
|||
notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) |
|||
writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) |
|||
if (notifyChar != null && writeChar != null) { |
|||
Log.i(TAG, "找到所需特征") |
|||
|
|||
// 设置特征写入类型为无响应模式 |
|||
writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE |
|||
|
|||
// 注册特征通知 |
|||
setupNotifications(gatt) |
|||
|
|||
opusPlugin?.startOpusDecodeStream(false, 1, 16000, 40, object : |
|||
JlOpusPlugin.CallbackOpus { |
|||
override fun onAudioDataReceivedOpus(pcmData: ByteArray) { |
|||
// Log.i(TAG, "收到解码控制通知: ${pcmData.contentToString()}") |
|||
callback?.onAudioDataReceived(pcmData) |
|||
// 处理解码后的 PCM 数据(如播放或写入文件) |
|||
} |
|||
}) |
|||
} else { |
|||
Log.e(TAG, "未找到所需特征") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "未找到音频服务") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "服务发现失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
// 添加扩展函数(在文件顶部声明) |
|||
fun List<Byte>.toByteArray(): ByteArray { |
|||
return ByteArray(this.size) { this[it] } |
|||
} |
|||
|
|||
override fun onCharacteristicChanged( |
|||
gatt: BluetoothGatt, |
|||
characteristic: BluetoothGattCharacteristic |
|||
) { |
|||
val characteristicUuid = characteristic.uuid.toString() |
|||
// Log.d(TAG, "收到特征变化通知: $characteristicUuid") |
|||
|
|||
when (characteristicUuid) { |
|||
// NOTIFY_CHAR_UUID -> { |
|||
// // 收到音频数据 |
|||
// val audioData = characteristic.value |
|||
// Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") |
|||
|
|||
// callback?.onAudioDataReceived(audioData) |
|||
// } |
|||
NOTIFY_CHAR_UUID -> { |
|||
// 处理控制消息 |
|||
val value = characteristic.value |
|||
// Log.i(TAG, "收到控制通知: ${value.size}") |
|||
|
|||
// 判断是否收到音频数据 |
|||
if (value.isNotEmpty() && (value[0].toInt() and 0xFF == 0x4b || value[0].toInt() and 0xFF == 0x48)) { |
|||
// 处理设备返回的协议数据 |
|||
fos?.write(value) |
|||
|
|||
opusPlugin?.handleDecodeRequest(value) |
|||
|
|||
|
|||
} |
|||
// 判断是否是协议格式响应 |
|||
if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { |
|||
// 处理设备返回的协议数据 |
|||
processResponseData(value) |
|||
} |
|||
// 假设0x01表示唤醒信号 |
|||
else if (value.isNotEmpty() && value[0].toInt() == 0x01) { |
|||
Log.i(TAG, "收到唤醒信号") |
|||
|
|||
callback?.onWakeupSignalReceived() |
|||
|
|||
// 收到唤醒后自动发送开始录音命令 |
|||
sendStartRecordingCommand() |
|||
|
|||
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCharacteristicWrite( |
|||
gatt: BluetoothGatt, |
|||
characteristic: BluetoothGattCharacteristic, |
|||
status: Int |
|||
) { |
|||
if (characteristic.uuid.toString() == WRITE_CHAR_UUID) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") |
|||
} else { |
|||
Log.e(TAG, "命令发送失败,status=$status") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDescriptorWrite( |
|||
gatt: BluetoothGatt, |
|||
descriptor: BluetoothGattDescriptor, |
|||
status: Int |
|||
) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") |
|||
Log.i(TAG, "特征通知设置成功,设备就绪") |
|||
} else { |
|||
Log.e(TAG, "描述符写入失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置特征通知 |
|||
*/ |
|||
private fun setupNotifications(gatt: BluetoothGatt) { |
|||
// 启用 notify 特征通知 |
|||
if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { |
|||
gatt.setCharacteristicNotification(notifyChar, true) |
|||
|
|||
val descriptor = notifyChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) |
|||
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
|
|||
if (descriptor != null) { |
|||
gatt.writeDescriptor(descriptor) |
|||
Log.i(TAG, "设置数据通知特征通知") |
|||
} |
|||
} else { |
|||
Log.e(TAG, "数据通知特征不支持通知") |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 处理设备返回的数据 |
|||
* 协议格式: |
|||
* 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) |
|||
*/ |
|||
private fun processResponseData(data: ByteArray) { |
|||
if (data.size < 4) { |
|||
Log.e(TAG, "返回数据格式错误,长度不足") |
|||
return |
|||
} |
|||
|
|||
// 判断协议头 |
|||
if (data[0].toInt() and 0xFF != 0xA5) { |
|||
Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") |
|||
return |
|||
} |
|||
|
|||
val fieldType = data[1].toInt() and 0xFF |
|||
val dataLen = data[2].toInt() and 0xFF |
|||
|
|||
// 验证长度 |
|||
if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 |
|||
Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") |
|||
return |
|||
} |
|||
|
|||
// 计算校验和 |
|||
var checksum = 0 |
|||
for (i in 0 until data.size - 1) { |
|||
checksum += data[i].toInt() and 0xFF |
|||
} |
|||
checksum = checksum and 0xFF |
|||
|
|||
val receivedChecksum = data[data.size - 1].toInt() and 0xFF |
|||
|
|||
if (checksum != receivedChecksum) { |
|||
Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") |
|||
return |
|||
} |
|||
|
|||
// 解析字段类型 |
|||
when (fieldType) { |
|||
0x01 -> { |
|||
// 音量调整命令的响应 |
|||
val result = data[3].toInt() and 0xFF |
|||
val success = result == 0 |
|||
Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") |
|||
callback?.onCommandResult(COMMAND_VOLUME, success) |
|||
} |
|||
// 其他命令类型可在此处添加 |
|||
else -> { |
|||
Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送开始录音命令 |
|||
*/ |
|||
fun sendStartRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送开始录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x01) |
|||
writeChar?.value = commandData |
|||
// 获取外部存储目录路径 |
|||
// 生成带时间戳的文件名(格式:yyyyMMdd_HHmmss_audio.opus) |
|||
val timeStamp = |
|||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) |
|||
val fileName = "${timeStamp}_audio.opus" |
|||
val externalDir = context.getExternalFilesDir(null) |
|||
outputFile = File(externalDir, fileName) |
|||
fos = FileOutputStream(outputFile).also { |
|||
Log.i(TAG, "音频文件创建成功:${outputFile?.absolutePath}") |
|||
} |
|||
|
|||
// val subscriptionKey = call.argument<String>("subscriptionKey") ?: "" |
|||
// val region = call.argument<String>("region") ?: "" |
|||
// val supportedLanguages = call.argument<List<String>>("supportedLanguages") ?: listOf("zh-CN") |
|||
|
|||
// val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages.toTypedArray()) |
|||
// result.success(success) |
|||
|
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "开始录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "开始录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送停止录音命令 |
|||
*/ |
|||
fun sendStopRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送停止录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x02) |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
fos?.close() |
|||
if (success) { |
|||
Log.i(TAG, "停止录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "停止录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送音量调整命令 |
|||
* 协议格式: |
|||
* 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 |
|||
*/ |
|||
fun adjustVolume(volume: Int): Boolean { |
|||
Log.e( |
|||
TAG, |
|||
"adjustVolume,bluetoothGatt=${bluetoothGatt},writeChar=${writeChar},connectionState=${connectionState.value}" |
|||
) |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送音量调整命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
// 限制音量范围为0-16 |
|||
val safeVolume = volume.coerceIn(0, 16) |
|||
|
|||
try { |
|||
// 构建命令数据 |
|||
val header: Byte = 0xAA.toByte() |
|||
val fieldType: Byte = 0x01 |
|||
val dataLen: Byte = 0x01 |
|||
val volumeData: Byte = safeVolume.toByte() |
|||
|
|||
// 计算校验和 |
|||
val checksum = ((header.toInt() and 0xFF) + |
|||
(fieldType.toInt() and 0xFF) + |
|||
(dataLen.toInt() and 0xFF) + |
|||
(volumeData.toInt() and 0xFF)) and 0xFF |
|||
|
|||
// 创建完整命令 |
|||
val commandData = |
|||
byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) |
|||
|
|||
// 写入特征 |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "音量调整命令已发送: $safeVolume") |
|||
} else { |
|||
Log.e(TAG, "音量调整命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加音量 |
|||
*/ |
|||
fun increaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送增加命令 |
|||
return adjustVolume(16) // 发送最大音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 减小音量 |
|||
*/ |
|||
fun decreaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送减小命令 |
|||
return adjustVolume(0) // 发送最小音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
stopScan() |
|||
scanHandler.removeCallbacks(scanResultUpdateRunnable) |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} |
|||
} |
|||
@ -1,294 +0,0 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.app.Activity |
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothManager |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.pm.PackageManager |
|||
import android.util.Log |
|||
import androidx.annotation.NonNull |
|||
import androidx.core.app.ActivityCompat |
|||
import androidx.core.content.ContextCompat |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
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 kotlinx.coroutines.* |
|||
|
|||
/** JieliPlugin */ |
|||
class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler { |
|||
/// 方法通道 |
|||
private lateinit var methodChannel: MethodChannel |
|||
/// 统一事件通道 |
|||
private lateinit var eventChannel: EventChannel |
|||
|
|||
private lateinit var context: Context |
|||
private var activity: Activity? = null |
|||
|
|||
/// BLE服务实例 |
|||
private lateinit var bleService: BleService |
|||
|
|||
// 协程作用域 |
|||
private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
|||
|
|||
// 事件接收器 |
|||
private var eventSink: EventChannel.EventSink? = null |
|||
|
|||
// 事件类型常量 |
|||
companion object { |
|||
private const val EVENT_TYPE_AUDIO_DATA = "audio_data" |
|||
private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" |
|||
private const val EVENT_TYPE_SCAN_RESULT = "scan_result" |
|||
private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" |
|||
private const val EVENT_TYPE_COMMAND_RESULT = "command_result" |
|||
} |
|||
|
|||
// 蓝牙相关请求码 |
|||
private val REQUEST_ENABLE_BT = 1 |
|||
private val REQUEST_PERMISSION_BLE = 2 |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
val binaryMessenger = flutterPluginBinding.binaryMessenger |
|||
|
|||
// 设置方法通道 |
|||
methodChannel = MethodChannel(binaryMessenger, "com.yunqiinnovation.jieli") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
// 设置统一事件通道 |
|||
eventChannel = EventChannel(binaryMessenger, "com.yunqiinnovation.jieli/events") |
|||
eventChannel.setStreamHandler(this) |
|||
|
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 初始化BLE服务 |
|||
bleService = BleService(context) |
|||
|
|||
// 设置回调 |
|||
setupBleCallbacks() |
|||
} |
|||
|
|||
/** |
|||
* 设置BLE服务回调 |
|||
*/ |
|||
private fun setupBleCallbacks() { |
|||
// 设置统一回调接口 |
|||
bleService.callback = object : BleService.Callback { |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_SCAN_RESULT, devices) |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_CONNECTION_STATE, state) |
|||
} |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
// Log.d("","数据流onAudioDataReceived${data}") |
|||
sendEvent(EVENT_TYPE_AUDIO_DATA, data) |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) |
|||
} |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( |
|||
"commandType" to commandType, |
|||
"success" to success |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件到Flutter端 |
|||
*/ |
|||
private fun sendEvent(eventType: String, data: Any?) { |
|||
eventSink?.success(mapOf( |
|||
"type" to eventType, |
|||
"data" to data |
|||
)) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"startScan" -> { |
|||
val scanTimeout = call.argument<Int>("scanTimeout") ?: 10000 |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能扫描设备", null) |
|||
return |
|||
} |
|||
|
|||
val success = bleService.startScan(scanTimeout.toLong()) |
|||
result.success(success) |
|||
} |
|||
"stopScan" -> { |
|||
val success = bleService.stopScan() |
|||
result.success(success) |
|||
} |
|||
"connect" -> { |
|||
val deviceId = call.argument<String>("deviceId") |
|||
if (deviceId == null) { |
|||
result.error("INVALID_ARGUMENT", "设备ID不能为空", null) |
|||
return |
|||
} |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能连接设备", null) |
|||
return |
|||
} |
|||
|
|||
bleService.connect(deviceId) |
|||
result.success(true) |
|||
} |
|||
"disconnect" -> { |
|||
bleService.disconnect() |
|||
result.success(true) |
|||
} |
|||
"sendStartRecordingCommand" -> { |
|||
val success = bleService.sendStartRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"sendStopRecordingCommand" -> { |
|||
val success = bleService.sendStopRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"isBluetoothEnabled" -> { |
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
result.success(bluetoothAdapter?.isEnabled == true) |
|||
} |
|||
"requestBluetoothEnable" -> { |
|||
if (activity == null) { |
|||
result.error("ACTIVITY_NOT_AVAILABLE", "Activity不可用,无法请求打开蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
|
|||
if (bluetoothAdapter == null) { |
|||
result.error("BLUETOOTH_UNAVAILABLE", "设备不支持蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
if (bluetoothAdapter.isEnabled) { |
|||
result.success(true) |
|||
return |
|||
} |
|||
|
|||
// 请求打开蓝牙 |
|||
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) |
|||
activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) |
|||
result.success(true) |
|||
} |
|||
"adjustVolume" -> { |
|||
val volume = call.argument<Int>("volume") ?: 0 |
|||
val success = bleService.adjustVolume(volume) |
|||
result.success(success) |
|||
} |
|||
"increaseVolume" -> { |
|||
val success = bleService.increaseVolume() |
|||
result.success(success) |
|||
} |
|||
"decreaseVolume" -> { |
|||
val success = bleService.decreaseVolume() |
|||
result.success(success) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
|
|||
bleService.cleanup() |
|||
pluginScope.cancel() |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
activity = null |
|||
} |
|||
|
|||
/// EventChannel.StreamHandler接口实现 |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
eventSink = null |
|||
} |
|||
|
|||
/// 检查是否拥有所需权限 |
|||
private fun hasRequiredPermissions(): Boolean { |
|||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_CONNECT) |
|||
} else { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_ADMIN) |
|||
} |
|||
} |
|||
|
|||
/// 检查单个权限 |
|||
private fun hasPermission(permission: String): Boolean { |
|||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED |
|||
} |
|||
|
|||
/// 请求BLE权限 |
|||
private fun requestBlePermissions() { |
|||
activity?.let { |
|||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH_SCAN, |
|||
android.Manifest.permission.BLUETOOTH_CONNECT |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} else { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH, |
|||
android.Manifest.permission.BLUETOOTH_ADMIN |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,283 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 设备类,代表一个BLE设备 |
|||
class BleDevice { |
|||
final String id; |
|||
final String name; |
|||
final int rssi; |
|||
|
|||
BleDevice({required this.id, required this.name, required this.rssi}); |
|||
|
|||
factory BleDevice.fromJson(Map<String, dynamic> json) { |
|||
return BleDevice( |
|||
id: json['id'] as String, |
|||
name: json['name'] as String? ?? 'Unknown Device', |
|||
rssi: json['rssi'] as int? ?? 0, |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'id': id, |
|||
'name': name, |
|||
'rssi': rssi, |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi)'; |
|||
} |
|||
|
|||
/// 连接状态 |
|||
enum ConnectionState { |
|||
disconnected, |
|||
connecting, |
|||
connected, |
|||
disconnecting, |
|||
error |
|||
} |
|||
|
|||
/// 事件类型 |
|||
class EventType { |
|||
static const String audioData = 'audio_data'; |
|||
static const String connectionState = 'connection_state'; |
|||
static const String scanResult = 'scan_result'; |
|||
static const String wakeupSignal = 'wakeup_signal'; |
|||
static const String commandResult = 'command_result'; |
|||
} |
|||
|
|||
/// 杰理BLE插件主类 |
|||
class Jieli { |
|||
static const MethodChannel _methodChannel = |
|||
MethodChannel('com.yunqiinnovation.jieli'); |
|||
static const EventChannel _eventChannel = |
|||
EventChannel('com.yunqiinnovation.jieli/events'); |
|||
|
|||
/// 扫描结果流 |
|||
static final StreamController<List<BleDevice>> _scanController = |
|||
StreamController<List<BleDevice>>.broadcast(); |
|||
Stream<List<BleDevice>> get scanResults => _scanController.stream; |
|||
|
|||
/// 连接状态变化流 |
|||
static final StreamController<ConnectionState> _connectionController = |
|||
StreamController<ConnectionState>.broadcast(); |
|||
Stream<ConnectionState> get connectionState => _connectionController.stream; |
|||
|
|||
/// 音频数据流 |
|||
static final StreamController<Uint8List> _audioController = |
|||
StreamController<Uint8List>.broadcast(); |
|||
Stream<Uint8List> get audioDataStream => _audioController.stream; |
|||
|
|||
/// 唤醒信号流 |
|||
static final StreamController<bool> _wakeupController = |
|||
StreamController<bool>.broadcast(); |
|||
Stream<bool> get wakeupStream => _wakeupController.stream; |
|||
|
|||
/// 命令结果流 |
|||
static final StreamController<CommandResult> _commandResultController = |
|||
StreamController<CommandResult>.broadcast(); |
|||
Stream<CommandResult> get commandResultStream => |
|||
_commandResultController.stream; |
|||
|
|||
/// 原始事件流 |
|||
Stream<Map<String, dynamic>>? _eventStream; |
|||
|
|||
/// 事件流订阅 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 命令类型常量 |
|||
static const int commandTypeVolume = 1; |
|||
|
|||
/// 构造函数,初始化平台通道和事件监听 |
|||
Jieli() { |
|||
_setupEventListener(); |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListener() { |
|||
_eventSubscription = |
|||
_eventChannel.receiveBroadcastStream().listen(_handleEvent); |
|||
} |
|||
|
|||
/// 处理来自原生平台的事件 |
|||
void _handleEvent(dynamic event) { |
|||
if (event is! Map<dynamic, dynamic>) return; |
|||
|
|||
final eventData = Map<String, dynamic>.from(event); |
|||
final String eventType = eventData['type'] as String; |
|||
final dynamic data = eventData['data']; |
|||
|
|||
switch (eventType) { |
|||
case EventType.scanResult: |
|||
if (data is List<dynamic>) { |
|||
final deviceList = data |
|||
.map( |
|||
(item) => BleDevice.fromJson(Map<String, dynamic>.from(item))) |
|||
.toList(); |
|||
_scanController.add(deviceList); |
|||
} |
|||
break; |
|||
case EventType.connectionState: |
|||
if (data is int && data >= 0 && data < ConnectionState.values.length) { |
|||
_connectionController.add(ConnectionState.values[data]); |
|||
} |
|||
break; |
|||
case EventType.audioData: |
|||
if (data is Uint8List) { |
|||
//print('收到ble通道消息: $data'); |
|||
_audioController.add(data); |
|||
} |
|||
break; |
|||
case EventType.wakeupSignal: |
|||
if (data is bool) { |
|||
_wakeupController.add(data); |
|||
} |
|||
break; |
|||
case EventType.commandResult: |
|||
if (data is Map<dynamic, dynamic>) { |
|||
final commandType = data['commandType'] as int; |
|||
final success = data['success'] as bool; |
|||
_commandResultController.add(CommandResult( |
|||
commandType: commandType, |
|||
success: success, |
|||
)); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描BLE设备 |
|||
Future<bool> startScan({int scanTimeout = 10000}) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'startScan', |
|||
{'scanTimeout': scanTimeout}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 停止扫描BLE设备 |
|||
Future<bool> stopScan() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('stopScan'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<bool> connect(String deviceId) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'connect', |
|||
{'deviceId': deviceId}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 断开连接 |
|||
Future<bool> disconnect() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('disconnect'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<bool> sendStartRecordingCommand() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('sendStartRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<bool> sendStopRecordingCommand() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('sendStopRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 关闭插件,释放资源 |
|||
Future<void> dispose() async { |
|||
await stopScan(); |
|||
// await disconnect(); |
|||
// _eventSubscription?.cancel(); |
|||
// _scanController.close(); |
|||
// _connectionController.close(); |
|||
// _audioController.close(); |
|||
// _wakeupController.close(); |
|||
// _commandResultController.close(); |
|||
} |
|||
|
|||
/// 检查蓝牙是否已启用 |
|||
Future<bool> isBluetoothEnabled() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('isBluetoothEnabled'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 请求打开蓝牙 |
|||
Future<bool> requestBluetoothEnable() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('requestBluetoothEnable'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 调整音量 |
|||
/// [volume] 音量值,范围0-16 |
|||
Future<bool> adjustVolume(int volume) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'adjustVolume', |
|||
{'volume': volume}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('调整音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<bool> increaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('increaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('增加音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<bool> decreaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('decreaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('减小音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 命令结果回调 |
|||
Stream<CommandResult> get onCommandResult => commandResultStream |
|||
.where((event) => event.commandType == commandTypeVolume) |
|||
.map((event) => event); |
|||
} |
|||
|
|||
/// 蓝牙设备模型 |
|||
class BluetoothDevice { |
|||
// ... existing code ... |
|||
} |
|||
|
|||
/// 命令结果 |
|||
class CommandResult { |
|||
final int commandType; |
|||
final bool success; |
|||
|
|||
CommandResult({ |
|||
required this.commandType, |
|||
required this.success, |
|||
}); |
|||
|
|||
@override |
|||
String toString() => |
|||
'CommandResult{commandType: $commandType, success: $success}'; |
|||
} |
|||
@ -1,23 +0,0 @@ |
|||
name: jieli |
|||
description: A Flutter plugin for connecting with Jieli BLE earphones |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ">=2.12.0 <3.0.0" |
|||
flutter: ">=2.0.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
|
|||
flutter: |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.jieli |
|||
pluginClass: JieliPlugin |
|||
Loading…
Reference in new issue