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>
431 lines
14 KiB
Dart
431 lines
14 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
import '../models/ble_status.dart';
|
||
import '../models/device_status.dart';
|
||
import '../models/player_status.dart';
|
||
import '../models/wifi_status.dart';
|
||
import '../services/device_storage_service.dart';
|
||
import '../services/http_api_service.dart';
|
||
import '../services/web_socket_service.dart';
|
||
import 'debug_provider.dart';
|
||
|
||
class DeviceProvider extends ChangeNotifier {
|
||
DeviceProvider({
|
||
required HttpApiService httpApiService,
|
||
required WebSocketService webSocketService,
|
||
required DeviceStorageService deviceStorageService,
|
||
required DebugProvider debugProvider,
|
||
String initialDeviceIp = '127.0.0.1',
|
||
int initialDevicePort = 5000,
|
||
String? initialDeviceName,
|
||
}) : _httpApiService = httpApiService,
|
||
_webSocketService = webSocketService,
|
||
_debugProvider = debugProvider,
|
||
_deviceStorageService = deviceStorageService,
|
||
_deviceIp = _normalizeDeviceIp(initialDeviceIp),
|
||
_devicePort = _normalizePort(initialDevicePort),
|
||
_deviceName = _normalizeDeviceName(initialDeviceName) {
|
||
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
|
||
_handleConnectionChanged,
|
||
);
|
||
_statusSubscription = _webSocketService.onStatusUpdate.listen(
|
||
_handleStatusUpdate,
|
||
);
|
||
_wifiSubscription =
|
||
_webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
||
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
|
||
}
|
||
|
||
final HttpApiService _httpApiService;
|
||
final WebSocketService _webSocketService;
|
||
final DebugProvider _debugProvider;
|
||
final DeviceStorageService _deviceStorageService;
|
||
|
||
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
|
||
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
||
late final StreamSubscription<Map<String, dynamic>> _bleSubscription;
|
||
|
||
DeviceStatus _status = DeviceStatus.initial();
|
||
bool _isLoading = false;
|
||
String? _errorMessage;
|
||
bool _webSocketConnected = false;
|
||
String _deviceIp;
|
||
int _devicePort;
|
||
String _deviceName;
|
||
List<SavedDevice> _deviceList = const <SavedDevice>[];
|
||
|
||
DeviceStatus get status => _status;
|
||
bool get isLoading => _isLoading;
|
||
String? get errorMessage => _errorMessage;
|
||
bool get webSocketConnected => _webSocketConnected;
|
||
String get deviceIp => _deviceIp;
|
||
int get devicePort => _devicePort;
|
||
List<SavedDevice> get deviceList =>
|
||
List<SavedDevice>.unmodifiable(_deviceList);
|
||
HttpApiService get httpApiService => _httpApiService;
|
||
|
||
Future<void> initialize() async {
|
||
_debugProvider.addHttpLog(
|
||
'Initialize device provider',
|
||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||
);
|
||
final lastDevice = await _deviceStorageService.getLastDevice();
|
||
if (lastDevice != null) {
|
||
_applyDevice(
|
||
ip: lastDevice.ip, port: lastDevice.port, name: lastDevice.name);
|
||
}
|
||
await _refreshDeviceList();
|
||
await refresh();
|
||
await connect();
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
_setLoading(true);
|
||
_debugProvider.addHttpLog(
|
||
'Refresh device overview',
|
||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||
);
|
||
try {
|
||
final results = await Future.wait<dynamic>([
|
||
_httpApiService.getPlaybackStatus(),
|
||
_httpApiService.getWifiStatus(),
|
||
_httpApiService.getBleStatus(),
|
||
]);
|
||
|
||
_status = _buildStatus(
|
||
playerStatus: results[0] as PlayerStatus,
|
||
wifiStatus: results[1] as WifiStatus,
|
||
bleStatus: results[2] as BleServiceStatus,
|
||
);
|
||
_errorMessage = null;
|
||
_debugProvider.addHttpLog('Device overview refreshed');
|
||
} catch (error) {
|
||
_errorMessage = error.toString();
|
||
_debugProvider.addHttpLog(
|
||
'Refresh device overview failed',
|
||
details: error,
|
||
);
|
||
_status = _status.copyWith(
|
||
connected: false,
|
||
connectionType: 'offline',
|
||
deviceName: _deviceName,
|
||
ipAddress: '$_deviceIp:$_devicePort',
|
||
);
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
Future<void> loadDeviceOverview() => refresh();
|
||
|
||
Future<void> connect() async {
|
||
_debugProvider.addWsLog(
|
||
'Connect WebSocket',
|
||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||
);
|
||
try {
|
||
await _webSocketService.connect(_deviceIp, port: _devicePort);
|
||
_webSocketConnected = _webSocketService.isConnected;
|
||
_errorMessage = null;
|
||
_debugProvider.addWsLog('WebSocket connect request completed');
|
||
notifyListeners();
|
||
} catch (error) {
|
||
_webSocketConnected = false;
|
||
_errorMessage = error.toString();
|
||
_debugProvider.addWsLog('WebSocket connect failed', details: error);
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
Future<void> switchDevice(String input, {String? name}) async {
|
||
final nextDevice = _parseDeviceInput(input, fallbackPort: _devicePort);
|
||
final nextName =
|
||
_normalizeDeviceName(name ?? _status.deviceName ?? _deviceName);
|
||
_setLoading(true);
|
||
_debugProvider.addHttpLog(
|
||
'Switch device to ${nextDevice.ip}:${nextDevice.port}',
|
||
details: <String, Object>{'name': nextName},
|
||
);
|
||
try {
|
||
await _validateDeviceReachable(nextDevice.ip, nextDevice.port);
|
||
|
||
await _webSocketService.disconnect();
|
||
_applyDevice(ip: nextDevice.ip, port: nextDevice.port, name: nextName);
|
||
notifyListeners();
|
||
|
||
await _deviceStorageService.saveDevice(
|
||
nextDevice.ip, nextDevice.port, nextName);
|
||
await _refreshDeviceList();
|
||
await refresh();
|
||
await connect();
|
||
|
||
final resolvedName = _normalizeDeviceName(_status.deviceName ?? nextName);
|
||
if (resolvedName != nextName) {
|
||
_deviceName = resolvedName;
|
||
await _deviceStorageService.saveDevice(
|
||
nextDevice.ip,
|
||
nextDevice.port,
|
||
resolvedName,
|
||
);
|
||
await _refreshDeviceList();
|
||
notifyListeners();
|
||
}
|
||
_debugProvider.addHttpLog(
|
||
'Device switched successfully',
|
||
details: <String, Object>{'device': '${nextDevice.ip}:${nextDevice.port}'},
|
||
);
|
||
} catch (error) {
|
||
_errorMessage = error.toString();
|
||
_debugProvider.addHttpLog('Switch device failed', details: error);
|
||
notifyListeners();
|
||
rethrow;
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
Future<void> updateDeviceIp(String ip) async {
|
||
await switchDevice(ip);
|
||
}
|
||
|
||
Future<void> removeStoredDevice(SavedDevice device) async {
|
||
await _deviceStorageService.removeDevice(device.ip, device.port);
|
||
await _refreshDeviceList();
|
||
_debugProvider.addHttpLog(
|
||
'Removed saved device ${device.address}',
|
||
details: <String, Object>{'name': device.name},
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> startBle({String? deviceName}) async {
|
||
_setLoading(true);
|
||
_debugProvider.addHttpLog(
|
||
'Start BLE service',
|
||
details: <String, Object?>{'deviceName': deviceName},
|
||
);
|
||
try {
|
||
await _httpApiService.startBle(deviceName);
|
||
await refresh();
|
||
_debugProvider.addHttpLog('BLE service started');
|
||
} catch (error) {
|
||
_errorMessage = error.toString();
|
||
_debugProvider.addHttpLog('Start BLE service failed', details: error);
|
||
notifyListeners();
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
Future<void> stopBle() async {
|
||
_setLoading(true);
|
||
_debugProvider.addHttpLog('Stop BLE service');
|
||
try {
|
||
await _httpApiService.stopBle();
|
||
await refresh();
|
||
_debugProvider.addHttpLog('BLE service stopped');
|
||
} catch (error) {
|
||
_errorMessage = error.toString();
|
||
_debugProvider.addHttpLog('Stop BLE service failed', details: error);
|
||
notifyListeners();
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
|
||
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
|
||
_debugProvider.addWsLog(
|
||
'Device provider connection state: ${connectionStatus.name}',
|
||
);
|
||
if (!_webSocketConnected) {
|
||
_status = _status.copyWith(connectionType: _status.connectionType);
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
||
_debugProvider.addWsLog('Received status update', details: payload);
|
||
final playerStatus = PlayerStatus.fromJson(payload);
|
||
_status = _buildStatus(
|
||
playerStatus: playerStatus,
|
||
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
|
||
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
void _handleWifiUpdate(Map<String, dynamic> payload) {
|
||
_debugProvider.addWsLog('Received wifi update', details: payload);
|
||
final wifiStatus = WifiStatus.fromJson(payload);
|
||
_status = _buildStatus(
|
||
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||
wifiStatus: wifiStatus,
|
||
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
void _handleBleUpdate(Map<String, dynamic> payload) {
|
||
_debugProvider.addBleLog('Received BLE update', details: payload);
|
||
final normalized = <String, dynamic>{
|
||
'running': payload['running'] ?? payload['ready'] ?? false,
|
||
'embedded': payload['embedded'] ?? false,
|
||
'device_name': payload['device_name'] ?? payload['name'],
|
||
};
|
||
final bleStatus = BleServiceStatus.fromJson(normalized);
|
||
_status = _buildStatus(
|
||
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
|
||
bleStatus: bleStatus,
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
DeviceStatus _buildStatus({
|
||
required PlayerStatus playerStatus,
|
||
required WifiStatus wifiStatus,
|
||
required BleServiceStatus bleStatus,
|
||
}) {
|
||
final connectionType = wifiStatus.connected
|
||
? 'wifi'
|
||
: bleStatus.running
|
||
? 'ble'
|
||
: 'offline';
|
||
|
||
return DeviceStatus(
|
||
connected:
|
||
wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
||
connectionType: connectionType,
|
||
deviceName: bleStatus.deviceName ?? _deviceName,
|
||
ipAddress: wifiStatus.ip ?? '$_deviceIp:$_devicePort',
|
||
playerStatus: playerStatus,
|
||
wifiStatus: wifiStatus,
|
||
bleStatus: bleStatus,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
Future<void> _refreshDeviceList() async {
|
||
_deviceList = await _deviceStorageService.getDevices();
|
||
}
|
||
|
||
Future<void> _validateDeviceReachable(String ip, int port) async {
|
||
final probeService = HttpApiService(baseUrl: _buildBaseUrl(ip, port));
|
||
try {
|
||
await probeService.getStatus().timeout(const Duration(seconds: 3));
|
||
} on TimeoutException {
|
||
throw Exception('设备不可达:连接超时(3 秒)');
|
||
} on Exception catch (error) {
|
||
throw Exception('设备不可达:$error');
|
||
} finally {
|
||
probeService.dispose();
|
||
}
|
||
}
|
||
|
||
void _applyDevice({
|
||
required String ip,
|
||
required int port,
|
||
String? name,
|
||
}) {
|
||
_deviceIp = _normalizeDeviceIp(ip);
|
||
_devicePort = _normalizePort(port);
|
||
_deviceName = _normalizeDeviceName(name);
|
||
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||
_status = _status.copyWith(
|
||
deviceName: _deviceName,
|
||
ipAddress: '$_deviceIp:$_devicePort',
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
void _setLoading(bool value) {
|
||
_isLoading = value;
|
||
notifyListeners();
|
||
}
|
||
|
||
static String _buildBaseUrl(String ip, int port) => 'http://$ip:$port';
|
||
|
||
static String _normalizeDeviceIp(String input) {
|
||
var value = input.trim();
|
||
if (value.startsWith('http://')) {
|
||
value = value.substring(7);
|
||
} else if (value.startsWith('https://')) {
|
||
value = value.substring(8);
|
||
} else if (value.startsWith('ws://')) {
|
||
value = value.substring(5);
|
||
} else if (value.startsWith('wss://')) {
|
||
value = value.substring(6);
|
||
}
|
||
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.isEmpty ? '127.0.0.1' : value;
|
||
}
|
||
|
||
static int _normalizePort(int input) {
|
||
if (input <= 0 || input > 65535) {
|
||
return 5000;
|
||
}
|
||
return input;
|
||
}
|
||
|
||
static String _normalizeDeviceName(String? input) {
|
||
final value = input?.trim() ?? '';
|
||
return value.isEmpty ? 'Showen' : value;
|
||
}
|
||
|
||
static SavedDevice _parseDeviceInput(String input,
|
||
{int fallbackPort = 5000}) {
|
||
var value = input.trim();
|
||
if (value.startsWith('http://')) {
|
||
value = value.substring(7);
|
||
} else if (value.startsWith('https://')) {
|
||
value = value.substring(8);
|
||
} else if (value.startsWith('ws://')) {
|
||
value = value.substring(5);
|
||
} else if (value.startsWith('wss://')) {
|
||
value = value.substring(6);
|
||
}
|
||
|
||
final slashIndex = value.indexOf('/');
|
||
if (slashIndex >= 0) {
|
||
value = value.substring(0, slashIndex);
|
||
}
|
||
|
||
final colonIndex = value.lastIndexOf(':');
|
||
final hasPort = colonIndex > 0 && colonIndex < value.length - 1;
|
||
final ip =
|
||
_normalizeDeviceIp(hasPort ? value.substring(0, colonIndex) : value);
|
||
final port = hasPort
|
||
? _normalizePort(
|
||
int.tryParse(value.substring(colonIndex + 1)) ?? fallbackPort)
|
||
: _normalizePort(fallbackPort);
|
||
|
||
return SavedDevice(
|
||
ip: ip,
|
||
port: port,
|
||
name: 'Showen',
|
||
lastUsedAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
unawaited(_connectionSubscription.cancel());
|
||
unawaited(_statusSubscription.cancel());
|
||
unawaited(_wifiSubscription.cancel());
|
||
unawaited(_bleSubscription.cancel());
|
||
super.dispose();
|
||
}
|
||
}
|