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;
|
||||
}
|
||||
}
|
||||
503
clients/flutter/lib/services/http_api_service.dart
Normal file
503
clients/flutter/lib/services/http_api_service.dart
Normal file
@@ -0,0 +1,503 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/api_response.dart';
|
||||
import '../models/ble_status.dart';
|
||||
import '../models/player_status.dart';
|
||||
import '../models/video_item.dart';
|
||||
import '../models/wifi_network.dart';
|
||||
import '../models/wifi_status.dart';
|
||||
|
||||
class HttpApiService {
|
||||
HttpApiService({required String baseUrl, http.Client? client})
|
||||
: _baseUrl = _normalizeBaseUrl(baseUrl),
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
String _baseUrl;
|
||||
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
set baseUrl(String value) {
|
||||
_baseUrl = _normalizeBaseUrl(value);
|
||||
}
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
||||
return Uri.parse('$_baseUrl$normalizedPath').replace(
|
||||
queryParameters: queryParameters == null || queryParameters.isEmpty
|
||||
? null
|
||||
: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> fetchConsolePage() async {
|
||||
final response = await _client.get(_uri('/'));
|
||||
_ensureSuccess(response);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
Future<String> fetchConsoleIndexHtml() async {
|
||||
final response = await _client.get(_uri('/index.html'));
|
||||
_ensureSuccess(response);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
Future<PlayerStatus> getStatus() => getPlaybackStatus();
|
||||
|
||||
Future<PlayerStatus> getPlaybackStatus() async {
|
||||
final response = await _client.get(_uri('/api/status'));
|
||||
_ensureSuccess(response);
|
||||
return PlayerStatus.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> play() => _postCommand('/api/play');
|
||||
|
||||
Future<ApiResponse> pause() => _postCommand('/api/pause');
|
||||
|
||||
Future<ApiResponse> next() => _postCommand('/api/next');
|
||||
|
||||
Future<ApiResponse> previous() => _postCommand('/api/previous');
|
||||
|
||||
Future<ApiResponse> goto(int index) => _postCommand('/api/goto/$index');
|
||||
|
||||
Future<ApiResponse> gotoIndex(int index) => goto(index);
|
||||
|
||||
Future<List<String>> getPlaylist() async {
|
||||
final response = await _client.get(_uri('/api/playlist'));
|
||||
_ensureSuccess(response);
|
||||
final decoded = _decodeJson(response.body);
|
||||
if (decoded is! List) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
return decoded.map<String>((dynamic item) {
|
||||
if (item is String) {
|
||||
return item;
|
||||
}
|
||||
if (item is Map<String, dynamic>) {
|
||||
return item['name']?.toString() ?? jsonEncode(item);
|
||||
}
|
||||
if (item is Map) {
|
||||
return item['name']?.toString() ?? jsonEncode(item);
|
||||
}
|
||||
return item.toString();
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ApiResponse> changeScene(String name) {
|
||||
return _postCommand('/api/scene/${Uri.encodeComponent(name)}');
|
||||
}
|
||||
|
||||
Future<ApiResponse> switchScene(String name) => changeScene(name);
|
||||
|
||||
Future<ApiResponse> trigger(String name, [String? value]) {
|
||||
final suffix = value == null || value.isEmpty
|
||||
? ''
|
||||
: '/${Uri.encodeComponent(value)}';
|
||||
return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix');
|
||||
}
|
||||
|
||||
Future<ApiResponse> triggerEvent(String name, {String? value}) {
|
||||
return trigger(name, value);
|
||||
}
|
||||
|
||||
Future<WifiStatus> getWifiStatus() async {
|
||||
final response = await _client.get(_uri('/api/wifi/status'));
|
||||
_ensureSuccess(response);
|
||||
return WifiStatus.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<List<WifiNetwork>> scanWifi() async {
|
||||
try {
|
||||
final response = await _client.get(_uri('/api/wifi/scan'));
|
||||
_ensureSuccess(response);
|
||||
return _decodeWifiNetworks(response.body);
|
||||
} on ApiException {
|
||||
final response = await _client.post(_uri('/api/wifi/scan'));
|
||||
_ensureSuccess(response);
|
||||
return _decodeWifiNetworks(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<WifiNetwork>> scanWifiViaGet() => scanWifi();
|
||||
|
||||
Future<List<WifiNetwork>> scanWifiViaPost() => scanWifi();
|
||||
|
||||
Future<ApiResponse> connectWifi(String ssid, String password) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/wifi/connect'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'ssid': ssid,
|
||||
'password': password,
|
||||
}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> startAP([String? ssid, String? password]) async {
|
||||
final body = <String, dynamic>{
|
||||
if (ssid != null && ssid.isNotEmpty) 'ssid': ssid,
|
||||
if (password != null && password.isNotEmpty) 'password': password,
|
||||
};
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
_uri('/api/wifi/ap/start'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
} on ApiException {
|
||||
final response = await _client.post(
|
||||
_uri('/api/wifi/hotspot/start'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse> startAccessPoint({String? ssid, String? password}) {
|
||||
return startAP(ssid, password);
|
||||
}
|
||||
|
||||
Future<ApiResponse> startHotspot({String? ssid, String? password}) {
|
||||
return startAP(ssid, password);
|
||||
}
|
||||
|
||||
Future<ApiResponse> stopAP() async {
|
||||
try {
|
||||
return await _postCommand('/api/wifi/ap/stop');
|
||||
} on ApiException {
|
||||
return _postCommand('/api/wifi/hotspot/stop');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse> stopAccessPoint() => stopAP();
|
||||
|
||||
Future<ApiResponse> stopHotspot() => stopAP();
|
||||
|
||||
Future<BleServiceStatus> getBleStatus() async {
|
||||
final response = await _client.get(_uri('/api/ble/status'));
|
||||
_ensureSuccess(response);
|
||||
return BleServiceStatus.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> startBle([String? deviceName]) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/ble/start'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
if (deviceName != null && deviceName.isNotEmpty)
|
||||
'device_name': deviceName,
|
||||
}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> stopBle() => _postCommand('/api/ble/stop');
|
||||
|
||||
Future<List<VideoItem>> getVideos() async {
|
||||
final response = await _client.get(_uri('/api/videos'));
|
||||
_ensureSuccess(response);
|
||||
final decoded = _decodeJson(response.body);
|
||||
if (decoded is! List) {
|
||||
return const <VideoItem>[];
|
||||
}
|
||||
|
||||
return decoded.map<VideoItem>((dynamic item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
return VideoItem.fromJson(item);
|
||||
}
|
||||
if (item is Map) {
|
||||
return VideoItem.fromJson(Map<String, dynamic>.from(item));
|
||||
}
|
||||
return const VideoItem(name: '', size: 0);
|
||||
}).where((item) => item.name.isNotEmpty).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideo(File file) {
|
||||
return _uploadSingleFile('/api/videos/upload', file);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) {
|
||||
throw const ApiException('未选择上传文件');
|
||||
}
|
||||
|
||||
final request = http.MultipartRequest('POST', _uri('/api/videos/upload'));
|
||||
for (final filePath in filePaths) {
|
||||
request.files.add(await http.MultipartFile.fromPath('file', filePath));
|
||||
}
|
||||
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> deleteVideo(String name) {
|
||||
return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getConfig() async {
|
||||
final response = await _client.get(_uri('/api/config'));
|
||||
_ensureSuccess(response);
|
||||
return _decodeMap(response.body);
|
||||
}
|
||||
|
||||
Future<ApiResponse> updateConfig(Map<String, dynamic> config) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/config'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(config),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getDisplayConfig() async {
|
||||
final response = await _client.get(_uri('/api/config/display'));
|
||||
_ensureSuccess(response);
|
||||
return _decodeMap(response.body);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getAvailableConfigs() async {
|
||||
final response = await _client.get(_uri('/api/config/available'));
|
||||
_ensureSuccess(response);
|
||||
return _decodeMap(response.body);
|
||||
}
|
||||
|
||||
Future<ApiResponse> switchConfig(String filename) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/config/switch'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{'filename': filename}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
|
||||
final response = await _client.get(
|
||||
_uri('/api/files/$dirKey', _pathQuery(path)),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
final decoded = _decodeJson(response.body);
|
||||
if (decoded is! List) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map((entry) => Map<String, dynamic>.from(entry))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadFile(String dirKey, File file, [String? path]) {
|
||||
return _uploadSingleFile(
|
||||
'/api/files/$dirKey/upload',
|
||||
file,
|
||||
directoryPath: path,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> downloadFile(String dirKey, String path) async {
|
||||
final response = await _client.get(
|
||||
_uri('/api/files/$dirKey/download', <String, String>{'path': path}),
|
||||
);
|
||||
_ensureSuccess(response, expectJson: false);
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
Future<ApiResponse> deleteFile(String dirKey, String path) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/files/$dirKey/delete'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{'path': path}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> mkdir(String dirKey, String path) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/files/$dirKey/mkdir'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{'path': path}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPlugins() async {
|
||||
final response = await _client.get(_uri('/api/plugins'));
|
||||
_ensureSuccess(response);
|
||||
final decoded = _decodeJson(response.body);
|
||||
if (decoded is! List) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map((plugin) => Map<String, dynamic>.from(plugin))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ApiResponse> enablePlugin(String id) {
|
||||
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable');
|
||||
}
|
||||
|
||||
Future<ApiResponse> disablePlugin(String id) {
|
||||
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable');
|
||||
}
|
||||
|
||||
Future<ApiResponse> installPlugin(String id, [String? version]) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/plugins/install'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'id': id,
|
||||
if (version != null && version.isNotEmpty) 'version': version,
|
||||
}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> _uploadSingleFile(
|
||||
String endpoint,
|
||||
File file, {
|
||||
String? directoryPath,
|
||||
}) async {
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
_uri(endpoint, _pathQuery(directoryPath)),
|
||||
);
|
||||
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> _postCommand(String path) async {
|
||||
final response = await _client.post(_uri(path));
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> _deleteCommand(String path) async {
|
||||
final response = await _client.delete(_uri(path));
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
List<WifiNetwork> _decodeWifiNetworks(String body) {
|
||||
final decoded = _decodeJson(body);
|
||||
if (decoded is! List) {
|
||||
return const <WifiNetwork>[];
|
||||
}
|
||||
|
||||
return decoded.map<WifiNetwork>((dynamic item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
return WifiNetwork.fromJson(item);
|
||||
}
|
||||
if (item is Map) {
|
||||
return WifiNetwork.fromJson(Map<String, dynamic>.from(item));
|
||||
}
|
||||
return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown');
|
||||
}).where((network) => network.ssid.isNotEmpty).toList(growable: false);
|
||||
}
|
||||
|
||||
dynamic _decodeJson(String body) {
|
||||
try {
|
||||
return jsonDecode(body);
|
||||
} on FormatException catch (error) {
|
||||
throw ApiException('JSON 解析失败: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeMap(String body) {
|
||||
final decoded = _decodeJson(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
throw const ApiException('期望返回 JSON 对象');
|
||||
}
|
||||
|
||||
Map<String, String>? _pathQuery(String? path) {
|
||||
if (path == null || path.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return <String, String>{'path': path};
|
||||
}
|
||||
|
||||
void _ensureSuccess(http.Response response, {bool expectJson = true}) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectJson) {
|
||||
throw ApiException(
|
||||
'请求失败 (${response.statusCode}): ${response.body}',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
String message = response.body;
|
||||
try {
|
||||
final decoded = _decodeJson(response.body);
|
||||
if (decoded is Map) {
|
||||
message = decoded['message']?.toString() ?? response.body;
|
||||
}
|
||||
} on ApiException {
|
||||
message = response.body;
|
||||
}
|
||||
|
||||
throw ApiException(message, statusCode: response.statusCode);
|
||||
}
|
||||
|
||||
Map<String, String> get _jsonHeaders => const <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
|
||||
static String _normalizeBaseUrl(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const ApiException('baseUrl 不能为空');
|
||||
}
|
||||
|
||||
final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||
? trimmed
|
||||
: 'http://$trimmed';
|
||||
return withScheme.endsWith('/')
|
||||
? withScheme.substring(0, withScheme.length - 1)
|
||||
: withScheme;
|
||||
}
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
172
clients/flutter/lib/services/web_socket_service.dart
Normal file
172
clients/flutter/lib/services/web_socket_service.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../models/app_event.dart';
|
||||
|
||||
enum SocketConnectionStatus { disconnected, connecting, connected }
|
||||
|
||||
class WebSocketService {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Timer? _reconnectTimer;
|
||||
String? _deviceIp;
|
||||
bool _manualDisconnect = false;
|
||||
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
|
||||
|
||||
final StreamController<AppEvent> _eventController =
|
||||
StreamController<AppEvent>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _statusController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _stateController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _configController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _wifiController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _bleController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<SocketConnectionStatus> _connectionController =
|
||||
StreamController<SocketConnectionStatus>.broadcast();
|
||||
|
||||
Stream<AppEvent> get events => _eventController.stream;
|
||||
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
||||
Stream<Map<String, dynamic>> get onStateUpdate => _stateController.stream;
|
||||
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
||||
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
||||
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
||||
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
||||
_connectionController.stream;
|
||||
|
||||
SocketConnectionStatus get connectionStatus => _connectionStatus;
|
||||
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
|
||||
|
||||
Future<void> connect(String deviceIp) async {
|
||||
_manualDisconnect = false;
|
||||
_deviceIp = _normalizeDeviceIp(deviceIp);
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
await _subscription?.cancel();
|
||||
await _channel?.sink.close();
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connecting);
|
||||
|
||||
final url = Uri.parse('ws://$_deviceIp:8080/ws');
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onDone: _handleSocketClosed,
|
||||
onError: (_) => _handleSocketClosed(),
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connected);
|
||||
}
|
||||
|
||||
void sendCommand(Map<String, dynamic> command) {
|
||||
if (!isConnected || _channel == null) {
|
||||
throw StateError('WebSocket 未连接');
|
||||
}
|
||||
_channel!.sink.add(jsonEncode(command));
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
final deviceIp = _deviceIp;
|
||||
if (deviceIp == null || deviceIp.isEmpty || _manualDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
||||
unawaited(connect(deviceIp));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_manualDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _eventController.close();
|
||||
await _statusController.close();
|
||||
await _stateController.close();
|
||||
await _configController.close();
|
||||
await _wifiController.close();
|
||||
await _bleController.close();
|
||||
await _connectionController.close();
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
final raw = data is String ? data : utf8.decode(data as List<int>);
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final event = AppEvent.fromJson(Map<String, dynamic>.from(decoded));
|
||||
_eventController.add(event);
|
||||
|
||||
switch (event.type) {
|
||||
case 'status_update':
|
||||
_statusController.add(event.payload);
|
||||
break;
|
||||
case 'state_update':
|
||||
_stateController.add(event.payload);
|
||||
break;
|
||||
case 'config_update':
|
||||
_configController.add(event.payload);
|
||||
break;
|
||||
case 'wifi_update':
|
||||
_wifiController.add(event.payload);
|
||||
break;
|
||||
case 'ble_update':
|
||||
_bleController.add(event.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSocketClosed() {
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
unawaited(reconnect());
|
||||
}
|
||||
|
||||
void _setConnectionStatus(SocketConnectionStatus status) {
|
||||
_connectionStatus = status;
|
||||
_connectionController.add(status);
|
||||
}
|
||||
|
||||
String _normalizeDeviceIp(String raw) {
|
||||
var value = raw.trim();
|
||||
if (value.startsWith('ws://')) {
|
||||
value = value.substring(5);
|
||||
} else if (value.startsWith('wss://')) {
|
||||
value = value.substring(6);
|
||||
} else if (value.startsWith('http://')) {
|
||||
value = value.substring(7);
|
||||
} else if (value.startsWith('https://')) {
|
||||
value = value.substring(8);
|
||||
}
|
||||
|
||||
final slashIndex = value.indexOf('/');
|
||||
if (slashIndex >= 0) {
|
||||
value = value.substring(0, slashIndex);
|
||||
}
|
||||
|
||||
final colonIndex = value.indexOf(':');
|
||||
if (colonIndex >= 0) {
|
||||
value = value.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user