mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-28 03:22:39 +00:00
6314188835
- align Android/iOS native bridge implementations and audio handling paths - improve Bluetooth provisioning/verification flow and related error handling - refactor WebSocket, music, and device utility logic for more stable behavior - clean up noisy debug logs and normalize comments across Flutter and native code - update AR view, dance/agent/device pages, and platform integration details
502 lines
17 KiB
Dart
502 lines
17 KiB
Dart
/*
|
||
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||
SPDX-License-Identifier: MIT
|
||
*/
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||
import 'package:flutter_svg/svg.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:stack_chan/util/XiaoZhi_util.dart';
|
||
import 'package:stack_chan/util/rsa_util.dart';
|
||
import 'package:stack_chan/view/popup/login_page.dart';
|
||
|
||
import '../../app_state.dart';
|
||
import '../../model/blue_device_info.dart';
|
||
import '../../model/blue_model.dart';
|
||
import '../../util/blue_util.dart';
|
||
import '../../util/mac_address_validator.dart';
|
||
import '../../util/value_constant.dart';
|
||
import 'device_name_page.dart';
|
||
|
||
enum DeviceWifiConfigStep { selectDevice, wifiConfig }
|
||
|
||
///scanBluetoothbinddevicepopup
|
||
class SelectBlueDevice extends StatefulWidget {
|
||
const SelectBlueDevice({super.key});
|
||
|
||
@override
|
||
State<StatefulWidget> createState() => _SelectBlueDeviceState();
|
||
}
|
||
|
||
class _SelectBlueDeviceState extends State<SelectBlueDevice> {
|
||
//connecttimeouttimer(preventNoWait)
|
||
Timer? _connectTimer;
|
||
|
||
//verifytimeouttimer(preventdeviceNoresponsewhen1to)
|
||
Timer? _verifyTimer;
|
||
|
||
//timeouttime:30Second(s)
|
||
static const int _connectTimeout = 30;
|
||
|
||
//verifytimeouttime:20Second(s)
|
||
static const int _verifyTimeout = 20;
|
||
|
||
String? _getDeviceId(BlueDeviceInfo blueDeviceInfo) {
|
||
final Map<int, List<int>> manufacturerDataMap =
|
||
blueDeviceInfo.advertisementData[ValueConstant.manufacturerData];
|
||
if (manufacturerDataMap.isNotEmpty) {
|
||
final MapEntry<int, List<int>> firstEntry =
|
||
manufacturerDataMap.entries.first;
|
||
final List<int> customData = firstEntry.value;
|
||
final address = customData.map((byte) {
|
||
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||
}).join();
|
||
return address;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
RxString connectDeviceId = RxString("");
|
||
|
||
@override
|
||
void dispose() {
|
||
_connectTimer?.cancel();
|
||
_verifyTimer?.cancel();
|
||
BlueUtil.shared.characteristicCallback = null;
|
||
BlueUtil.shared.wifiSetCharacteristicCall = null;
|
||
BlueUtil.shared.onReconnectSuccess = null;
|
||
connectDeviceId.value = "";
|
||
isSuccess = false;
|
||
super.dispose();
|
||
}
|
||
|
||
bool isSuccess = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
//enterbindStreamProcess / Thread,setbindflag
|
||
_registerBlueCallbacks();
|
||
BlueUtil.shared.onReconnectSuccess = (device) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Reconnected to device successfully.");
|
||
}
|
||
_registerBlueCallbacks();
|
||
//reconnectafterretriggerdeviceverify(key:originallogiconlyhasfirstconnectwhentrigger)
|
||
if (BlueUtil.shared.writeWifiSetCharacteristic != null) {
|
||
startVerificationEquipment(BlueUtil.shared.writeWifiSetCharacteristic!);
|
||
}
|
||
};
|
||
}
|
||
|
||
//resetconnectstate(System1wrap)
|
||
void _resetConnectState() {
|
||
connectDeviceId.value = "";
|
||
_connectTimer?.cancel();
|
||
_verifyTimer?.cancel();
|
||
if (mounted) {
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
void _registerBlueCallbacks() {
|
||
BlueUtil.shared.characteristicCallback = (device, characteristic) async {
|
||
final properties = characteristic.properties;
|
||
final canWrite = properties.write || properties.writeWithoutResponse;
|
||
if (canWrite) {
|
||
final uuid = characteristic.uuid.toString();
|
||
if (uuid == BlueUtil.wifiSetCharacteristicUUID) {
|
||
BlueUtil.shared.writeWifiSetCharacteristic = characteristic;
|
||
if (Platform.isAndroid) {
|
||
await device
|
||
.requestMtu(512)
|
||
.then((mtu) {
|
||
})
|
||
.catchError((e) {
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Failed to set MTU. Device may not work properly.",
|
||
);
|
||
}
|
||
});
|
||
}
|
||
//enablefeatureValuenotify
|
||
try {
|
||
await characteristic.setNotifyValue(true);
|
||
startVerificationEquipment(characteristic);
|
||
} catch (e) {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Failed to enable device notification.",
|
||
);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
if (mounted) {
|
||
AppState.shared.showToast("Device configuration feature not found.");
|
||
}
|
||
};
|
||
BlueUtil.shared.wifiSetCharacteristicCall = (data) async {
|
||
try {
|
||
String json = utf8.decode(data);
|
||
final model = BlueNotifyStateModel.fromJson(json);
|
||
|
||
if (model == null) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Failed to parse device data.");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (model.cmd != null &&
|
||
model.cmd == "notifyState" &&
|
||
model.data?.type == 4) {
|
||
//toEncryptinfo
|
||
String? data = model.data?.state;
|
||
if (data == null) {
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Device did not return encryption data.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
//RSADecrypt
|
||
final result = RsaUtil.decryptStackChanBlue(data);
|
||
//newIncrease / Add:Decryptfail/lengthNot
|
||
if (result.isEmpty || result.length < 12) {
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Device verification decryption failed.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isSuccess) return;
|
||
isSuccess = true;
|
||
_connectTimer?.cancel(); //cancelconnecttimeouttimer
|
||
_verifyTimer?.cancel(); //cancelverifytimeouttimer
|
||
|
||
final macAddress = result.substring(0, 12);
|
||
|
||
AppState.shared.showToast(
|
||
"The initially activated MAC address: $macAddress",
|
||
);
|
||
|
||
AppState.shared.deviceMac = macAddress;
|
||
AppState.shared.connectWebSocket();
|
||
activateDevice(macAddress);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Failed to process device data.");
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
String formatMacAddress(String mac) {
|
||
if (mac.length != 12) return mac;
|
||
return mac
|
||
.toUpperCase()
|
||
.replaceAllMapped(RegExp(r'(.{2})'), (match) => '${match.group(1)}:')
|
||
.substring(0, 17); //removeafter
|
||
}
|
||
|
||
bool isValidMac(String mac) {
|
||
final RegExp macRegex = RegExp(r'^[0-9A-Fa-f]{12}$');
|
||
return macRegex.hasMatch(mac);
|
||
}
|
||
|
||
//activatedevice
|
||
Future<void> activateDevice(String macAddress) async {
|
||
try {
|
||
///startqueryagentconfiginfo
|
||
bool isConfiguration = await queryConfiguration(macAddress);
|
||
if (!isConfiguration) {
|
||
_resetConnectState();
|
||
//activatefail(Alreadyhashint,NoRepeat)
|
||
return;
|
||
}
|
||
|
||
if (mounted) {
|
||
AppState.shared.showToast("The AI Agent has been configured.");
|
||
}
|
||
|
||
//binddevice
|
||
bool result = await AppState.shared.bindDevice(macAddress);
|
||
if (result) {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast("Device bound successfully!");
|
||
//configjump
|
||
Navigator.of(context).push(
|
||
CupertinoPageRoute(builder: (context) => const DeviceNamePage()),
|
||
);
|
||
}
|
||
} else {
|
||
_resetConnectState();
|
||
//newIncrease / Add:binddevicefail
|
||
if (mounted) {
|
||
AppState.shared.showToast("Device binding failed. Please try again.");
|
||
}
|
||
}
|
||
} catch (e) {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast("Device activation exception.");
|
||
}
|
||
}
|
||
}
|
||
|
||
//querydeviceconfig(StreamProcess / Threaderrorhint)
|
||
Future<bool> queryConfiguration(String macAddress) async {
|
||
try {
|
||
//1. querydevicewhetheractivated (laterNotAgainquery directactivate)
|
||
// final devices = await XiaoZhiUtil.shared.getDevice(macAddress);
|
||
// if (devices.isNotEmpty) {
|
||
// if (devices.first.agent_id != null) {
|
||
//debugPrint("✅ deviceactivated");
|
||
// return true;
|
||
// }
|
||
// }
|
||
|
||
//2. generatelicense
|
||
final generateLicense = await XiaoZhiUtil.shared.generateLicense(
|
||
macAddress,
|
||
);
|
||
if (generateLicense == null || generateLicense.serialNumber == null) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Failed to generate device license.");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
//3. activatedevice
|
||
final serialNumber = generateLicense.serialNumber!;
|
||
final mac = MacAddressValidator.formatMac(AppState.shared.deviceMac);
|
||
if (mac == null) {
|
||
AppState.shared.showToast("Failed to format device MAC address.");
|
||
return false;
|
||
}
|
||
bool activateResult = await XiaoZhiUtil.shared.agentsDevicesActivate(
|
||
serialNumber,
|
||
mac,
|
||
);
|
||
if (!activateResult) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Device cloud activation failed.");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
///deviceMayexist Notactivate,Needverify
|
||
final checkDevice = await XiaoZhiUtil.shared.serialNumberGetDevice(
|
||
serialNumber,
|
||
);
|
||
if (checkDevice == null || checkDevice.agent_id == null) {
|
||
//activatefail
|
||
return false;
|
||
} else {
|
||
AppState.shared.showToast("Device activation successful");
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Failed to query device configuration.");
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CupertinoPageScaffold(
|
||
backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom(
|
||
context,
|
||
),
|
||
child: CustomScrollView(
|
||
slivers: [
|
||
CupertinoSliverNavigationBar(
|
||
largeTitle: Text("Select Device"),
|
||
leading: SizedBox.shrink(),
|
||
trailing: CupertinoButton(
|
||
padding: .zero,
|
||
child: Icon(
|
||
CupertinoIcons.xmark_circle_fill,
|
||
size: 25,
|
||
color: CupertinoColors.separator.resolveFrom(context),
|
||
),
|
||
onPressed: () {
|
||
AppState.shared.manualShutdownTime = DateTime.now();
|
||
BlueUtil.shared.characteristicCallback = null;
|
||
BlueUtil.shared.wifiSetCharacteristicCall = null;
|
||
BlueUtil.shared.onReconnectSuccess = null;
|
||
CupertinoSheetRoute.popSheet(context);
|
||
},
|
||
),
|
||
),
|
||
SliverList.list(
|
||
children: [
|
||
Obx(
|
||
() => CupertinoListSection.insetGrouped(
|
||
children: AppState.shared.blueDeviceList.isNotEmpty
|
||
? AppState.shared.blueDeviceList.map((deviceInfo) {
|
||
String? deviceId = _getDeviceId(deviceInfo);
|
||
return CupertinoListTile(
|
||
onTap: () async {
|
||
if (!AppState.shared.isLogin.value) {
|
||
if (mounted) {
|
||
Navigator.of(context).push(
|
||
CupertinoPageRoute(
|
||
builder: (context) =>
|
||
const LoginPage(isWelCome: true),
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
//Repeatclick:Alreadyinconnectin
|
||
final deviceRemoteId = deviceInfo.device.remoteId
|
||
.toString();
|
||
if (deviceRemoteId == connectDeviceId.value) {
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Already connecting... Please wait patiently.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
//startconnect
|
||
isSuccess = false;
|
||
connectDeviceId.value = deviceRemoteId;
|
||
AppState.shared.blueDeviceList.refresh();
|
||
|
||
//newIncrease / Add:connecttimeouttimer
|
||
_connectTimer?.cancel();
|
||
_connectTimer = Timer(
|
||
Duration(seconds: _connectTimeout),
|
||
() {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Device connection timed out. Please try again.",
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
//newIncrease / Add:BluetoothconnectThrowsCatch
|
||
try {
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Connecting to device...",
|
||
);
|
||
}
|
||
await BlueUtil.shared.connect(
|
||
deviceInfo.device,
|
||
);
|
||
} catch (e) {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Bluetooth connection failed: ${e.toString()}",
|
||
);
|
||
}
|
||
}
|
||
},
|
||
leading: Image.asset(
|
||
"assets/image1.png",
|
||
width: 28,
|
||
height: 28,
|
||
),
|
||
title: Text(deviceInfo.device.advName),
|
||
subtitle: deviceId != null
|
||
? Text("ID: $deviceId")
|
||
: null,
|
||
trailing:
|
||
deviceInfo.device.remoteId.toString() ==
|
||
connectDeviceId.value
|
||
? const CupertinoActivityIndicator()
|
||
: SvgPicture.asset(
|
||
"assets/chevron.right.svg",
|
||
width: 15,
|
||
height: 15,
|
||
colorFilter: ColorFilter.mode(
|
||
CupertinoColors.secondaryLabel
|
||
.resolveFrom(context),
|
||
BlendMode.srcIn,
|
||
),
|
||
),
|
||
);
|
||
}).toList()
|
||
: [CupertinoListTile(title: Text("No devices found"))],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
//startverifydevice(senddata)
|
||
void startVerificationEquipment(
|
||
BluetoothCharacteristic characteristic,
|
||
) async {
|
||
try {
|
||
if (mounted) {
|
||
AppState.shared.showToast("Verifying device...");
|
||
}
|
||
//startverifytimeouttimer
|
||
_verifyTimer?.cancel();
|
||
_verifyTimer = Timer(Duration(seconds: _verifyTimeout), () {
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Device verification timed out. Please try again.",
|
||
);
|
||
}
|
||
});
|
||
//data
|
||
final dateTimeString = DateTime.now().millisecondsSinceEpoch.toString();
|
||
final BlueEncryptionDecryption data = BlueEncryptionDecryption(
|
||
cmd: "handshake",
|
||
data: dateTimeString,
|
||
);
|
||
final jsonString = jsonEncode(data.toJson());
|
||
|
||
//senddata
|
||
bool result = await BlueUtil.shared.sendWifiSetData(jsonString);
|
||
if (result) {
|
||
} else {
|
||
_verifyTimer?.cancel();
|
||
AppState.shared.showToast(
|
||
"The equipment may have been disconnected. Please reconfigure it on the StackChan end.",
|
||
);
|
||
_resetConnectState();
|
||
}
|
||
} catch (e) {
|
||
_verifyTimer?.cancel();
|
||
_resetConnectState();
|
||
if (mounted) {
|
||
AppState.shared.showToast(
|
||
"Failed to send device verification command.",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|