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>
This commit is contained in:
215
clients/flutter/lib/services/ble_service.dart
Normal file
215
clients/flutter/lib/services/ble_service.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user