Files
ShowenV2/clients/flutter/lib/services/ble_service.dart
showen bff9ec535d feat: Flutter 客户端 App + Web UI APK 下载入口
- 新增 Flutter 跨平台客户端项目 (clients/flutter/)
  - 29 个 Dart 文件: 服务层/状态管理/5个页面/BLE配网
  - BLE 蓝牙配网: 扫描设备、写入WiFi凭据、配网状态监听
  - HTTP API 客户端: 覆盖全部端点 (播放/场景/WiFi/视频/配置/文件/插件)
  - WebSocket 实时通信: 事件流 + 自动重连
  - 暗色主题 Material 3 UI, 中文界面
  - Android 配置: minSdkVersion 21, BLE/网络权限
  - PRD 产品需求文档 + 开发任务看板
- Web UI 添加 APK 下载入口 (routes.rs)
  - 下载弹窗 + 二维码 + /download/{filename} 静态文件路由
- BLE 插件增加自动重连循环 (ble/mod.rs)
- BLE 默认设备名修正为 'Showen' (config.rs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 02:09:52 +08:00

216 lines
6.5 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> 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;
}
}