M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
7.1 KiB
Dart
239 lines
7.1 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
|
|
import '../models/ble_models.dart';
|
|
|
|
class BleService {
|
|
static final Guid _provisionServiceUuid =
|
|
Guid('12345678-1234-5678-1234-56789abcdef0');
|
|
static final Guid _ssidCharacteristicUuid =
|
|
Guid('12345678-1234-5678-1234-56789abcdef1');
|
|
static final Guid _passwordCharacteristicUuid =
|
|
Guid('12345678-1234-5678-1234-56789abcdef2');
|
|
static final Guid _commandCharacteristicUuid =
|
|
Guid('12345678-1234-5678-1234-56789abcdef3');
|
|
static final Guid _statusCharacteristicUuid =
|
|
Guid('12345678-1234-5678-1234-56789abcdef4');
|
|
|
|
BluetoothDevice? _connectedDevice;
|
|
BluetoothCharacteristic? _ssidCharacteristic;
|
|
BluetoothCharacteristic? _passwordCharacteristic;
|
|
BluetoothCharacteristic? _commandCharacteristic;
|
|
BluetoothCharacteristic? _statusCharacteristic;
|
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
|
|
|
Stream<List<BleDevice>> scanForShowenDevices({
|
|
Duration timeout = const Duration(seconds: 6),
|
|
}) {
|
|
final controller = StreamController<List<BleDevice>>();
|
|
final seen = <String, BleDevice>{};
|
|
|
|
unawaited(_scanSubscription?.cancel());
|
|
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
|
|
for (final result in results) {
|
|
final name = _deviceName(result);
|
|
if (name.isEmpty || !name.toLowerCase().contains('showen')) {
|
|
continue;
|
|
}
|
|
final id = result.device.remoteId.str;
|
|
seen[id] = BleDevice(name: name, id: id, rssi: result.rssi);
|
|
}
|
|
|
|
final devices = seen.values.toList(growable: false)
|
|
..sort((a, b) => b.rssi.compareTo(a.rssi));
|
|
if (!controller.isClosed) {
|
|
controller.add(devices);
|
|
}
|
|
});
|
|
|
|
unawaited(FlutterBluePlus.startScan(timeout: timeout));
|
|
Future<void>.delayed(timeout, () async {
|
|
await FlutterBluePlus.stopScan();
|
|
if (!controller.isClosed) {
|
|
await controller.close();
|
|
}
|
|
});
|
|
|
|
controller.onCancel = () async {
|
|
await FlutterBluePlus.stopScan();
|
|
await _scanSubscription?.cancel();
|
|
_scanSubscription = null;
|
|
};
|
|
|
|
return controller.stream;
|
|
}
|
|
|
|
Future<void> connectToDevice(BleDevice device) async {
|
|
await disconnect();
|
|
final bluetoothDevice = BluetoothDevice.fromId(device.id);
|
|
await bluetoothDevice.connect(timeout: const Duration(seconds: 12));
|
|
_connectedDevice = bluetoothDevice;
|
|
await _discoverCharacteristics(bluetoothDevice);
|
|
}
|
|
|
|
Future<Stream<BleStatus>> subscribeToStatus() async {
|
|
final characteristic = _statusCharacteristic;
|
|
if (characteristic == null) {
|
|
return Stream<BleStatus>.value(
|
|
const BleStatus(
|
|
ok: false,
|
|
action: 'status',
|
|
error: '未发现 BLE 状态特征值',
|
|
),
|
|
);
|
|
}
|
|
|
|
await characteristic.setNotifyValue(true);
|
|
return characteristic.lastValueStream
|
|
.where((value) => value.isNotEmpty)
|
|
.map((value) {
|
|
final raw = utf8.decode(value, allowMalformed: true);
|
|
try {
|
|
return BleStatus.fromRawJson(raw);
|
|
} catch (_) {
|
|
return BleStatus(
|
|
ok: true,
|
|
action: 'status',
|
|
state: raw,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<BleStatus> provisionWifi(
|
|
String ssid,
|
|
String password, {
|
|
Duration timeout = const Duration(seconds: 30),
|
|
}) async {
|
|
final ssidChar = _ssidCharacteristic;
|
|
final passwordChar = _passwordCharacteristic;
|
|
final command = _commandCharacteristic;
|
|
|
|
if (_connectedDevice == null) {
|
|
throw StateError('未连接 BLE 设备');
|
|
}
|
|
if (ssidChar == null || passwordChar == null || command == null) {
|
|
throw StateError('未发现 BLE 配网特征值');
|
|
}
|
|
|
|
final statusStream = await subscribeToStatus();
|
|
final completer = Completer<BleStatus>();
|
|
late final StreamSubscription<BleStatus> subscription;
|
|
subscription = statusStream.listen((status) {
|
|
if (!status.isQueued && !completer.isCompleted) {
|
|
completer.complete(status);
|
|
}
|
|
}, onError: (Object error, StackTrace stackTrace) {
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(error, stackTrace);
|
|
}
|
|
});
|
|
|
|
await ssidChar.write(
|
|
utf8.encode(ssid),
|
|
withoutResponse: ssidChar.properties.writeWithoutResponse,
|
|
);
|
|
await passwordChar.write(
|
|
utf8.encode(password),
|
|
withoutResponse: passwordChar.properties.writeWithoutResponse,
|
|
);
|
|
await command.write(
|
|
utf8.encode('connect'),
|
|
withoutResponse: command.properties.writeWithoutResponse,
|
|
);
|
|
|
|
try {
|
|
return await completer.future.timeout(timeout);
|
|
} finally {
|
|
await subscription.cancel();
|
|
}
|
|
}
|
|
|
|
Future<void> sendCommand(String command) async {
|
|
final characteristic = _commandCharacteristic;
|
|
if (_connectedDevice == null) {
|
|
throw StateError('未连接 BLE 设备');
|
|
}
|
|
if (characteristic == null) {
|
|
throw StateError('未发现 BLE 命令特征值');
|
|
}
|
|
|
|
await characteristic.write(
|
|
utf8.encode(command),
|
|
withoutResponse: characteristic.properties.writeWithoutResponse,
|
|
);
|
|
}
|
|
|
|
Future<void> play() => sendCommand('play');
|
|
|
|
Future<void> pause() => sendCommand('pause');
|
|
|
|
Future<void> next() => sendCommand('next');
|
|
|
|
Future<void> previous() => sendCommand('prev');
|
|
|
|
Future<void> disconnect() async {
|
|
await _scanSubscription?.cancel();
|
|
_scanSubscription = null;
|
|
|
|
final device = _connectedDevice;
|
|
_connectedDevice = null;
|
|
_ssidCharacteristic = null;
|
|
_passwordCharacteristic = null;
|
|
_commandCharacteristic = null;
|
|
_statusCharacteristic = null;
|
|
|
|
if (device != null) {
|
|
await device.disconnect();
|
|
}
|
|
}
|
|
|
|
Future<void> dispose() => disconnect();
|
|
|
|
Future<void> _discoverCharacteristics(BluetoothDevice device) async {
|
|
final services = await device.discoverServices();
|
|
|
|
BluetoothCharacteristic? ssid;
|
|
BluetoothCharacteristic? password;
|
|
BluetoothCharacteristic? command;
|
|
BluetoothCharacteristic? status;
|
|
|
|
for (final service in services) {
|
|
final shouldInspect = service.uuid == _provisionServiceUuid ||
|
|
services.length == 1 ||
|
|
service.characteristics.isNotEmpty;
|
|
if (!shouldInspect) {
|
|
continue;
|
|
}
|
|
|
|
for (final characteristic in service.characteristics) {
|
|
if (characteristic.uuid == _ssidCharacteristicUuid) {
|
|
ssid = characteristic;
|
|
} else if (characteristic.uuid == _passwordCharacteristicUuid) {
|
|
password = characteristic;
|
|
} else if (characteristic.uuid == _commandCharacteristicUuid) {
|
|
command = characteristic;
|
|
} else if (characteristic.uuid == _statusCharacteristicUuid) {
|
|
status = characteristic;
|
|
}
|
|
}
|
|
}
|
|
|
|
_ssidCharacteristic = ssid;
|
|
_passwordCharacteristic = password;
|
|
_commandCharacteristic = command;
|
|
_statusCharacteristic = status;
|
|
}
|
|
|
|
String _deviceName(ScanResult result) {
|
|
final platformName = result.device.platformName.trim();
|
|
if (platformName.isNotEmpty) {
|
|
return platformName;
|
|
}
|
|
final advertisedName = result.advertisementData.advName.trim();
|
|
return advertisedName;
|
|
}
|
|
}
|