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>
268 lines
8.0 KiB
Dart
268 lines
8.0 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
|
|
import '../models/app_event.dart';
|
|
|
|
enum WsConnectionState { connected, connecting, disconnected }
|
|
|
|
@Deprecated('Use WsConnectionState instead')
|
|
enum SocketConnectionStatus { disconnected, connecting, connected }
|
|
|
|
class WebSocketService {
|
|
static const Duration _initialReconnectDelay = Duration(seconds: 2);
|
|
static const Duration _maxReconnectDelay = Duration(seconds: 60);
|
|
|
|
WebSocketChannel? _channel;
|
|
StreamSubscription<dynamic>? _subscription;
|
|
Timer? _reconnectTimer;
|
|
String? _deviceIp;
|
|
int _devicePort = 5000;
|
|
bool _manualDisconnect = false;
|
|
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
|
int _retryCount = 0;
|
|
Duration _nextReconnectDelay = _initialReconnectDelay;
|
|
|
|
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<WsConnectionState> _connectionStateController =
|
|
StreamController<WsConnectionState>.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 connectionState =>
|
|
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
|
Stream<WsConnectionState> get connectionStateStream =>
|
|
_connectionStateController.stream;
|
|
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
|
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
|
|
|
WsConnectionState get wsConnectionState => _connectionState;
|
|
SocketConnectionStatus get connectionStatus =>
|
|
_toLegacyConnectionStatus(_connectionState);
|
|
bool get isConnected => _connectionState == WsConnectionState.connected;
|
|
int get retryCount => _retryCount;
|
|
|
|
Future<void> connect(String deviceIp, {int port = 5000}) async {
|
|
_manualDisconnect = false;
|
|
_deviceIp = _normalizeDeviceIp(deviceIp);
|
|
_devicePort = _normalizePort(port);
|
|
_reconnectTimer?.cancel();
|
|
|
|
await _establishConnection(resetBackoff: true);
|
|
}
|
|
|
|
Future<void> manualReconnect() async {
|
|
final deviceIp = _deviceIp;
|
|
if (deviceIp == null || deviceIp.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
_manualDisconnect = false;
|
|
_reconnectTimer?.cancel();
|
|
await _establishConnection(resetBackoff: true);
|
|
}
|
|
|
|
Future<void> _establishConnection({bool resetBackoff = false}) async {
|
|
if (resetBackoff) {
|
|
_retryCount = 0;
|
|
_nextReconnectDelay = _initialReconnectDelay;
|
|
}
|
|
|
|
await _subscription?.cancel();
|
|
_subscription = null;
|
|
await _channel?.sink.close();
|
|
_channel = null;
|
|
|
|
_setConnectionState(WsConnectionState.connecting);
|
|
|
|
try {
|
|
final url = Uri.parse('ws://$_deviceIp:$_devicePort/ws');
|
|
_channel = WebSocketChannel.connect(url);
|
|
await _channel!.ready;
|
|
_subscription = _channel!.stream.listen(
|
|
_handleMessage,
|
|
onDone: _handleSocketClosed,
|
|
onError: (_) => _handleSocketClosed(),
|
|
cancelOnError: true,
|
|
);
|
|
|
|
_retryCount = 0;
|
|
_nextReconnectDelay = _initialReconnectDelay;
|
|
_setConnectionState(WsConnectionState.connected);
|
|
} catch (_) {
|
|
_channel = null;
|
|
_subscription = null;
|
|
if (_manualDisconnect) {
|
|
_setConnectionState(WsConnectionState.disconnected);
|
|
return;
|
|
}
|
|
|
|
await reconnect();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_setConnectionState(WsConnectionState.connecting);
|
|
_scheduleReconnect();
|
|
}
|
|
|
|
void _scheduleReconnect() {
|
|
if (_manualDisconnect) {
|
|
return;
|
|
}
|
|
|
|
_reconnectTimer?.cancel();
|
|
final delay = _nextReconnectDelay;
|
|
_retryCount += 1;
|
|
_nextReconnectDelay = _nextReconnectDelay * 2;
|
|
if (_nextReconnectDelay > _maxReconnectDelay) {
|
|
_nextReconnectDelay = _maxReconnectDelay;
|
|
}
|
|
|
|
_reconnectTimer = Timer(delay, () {
|
|
unawaited(_establishConnection());
|
|
});
|
|
}
|
|
|
|
Future<void> disconnect() async {
|
|
_manualDisconnect = true;
|
|
_reconnectTimer?.cancel();
|
|
_retryCount = 0;
|
|
_nextReconnectDelay = _initialReconnectDelay;
|
|
await _subscription?.cancel();
|
|
_subscription = null;
|
|
await _channel?.sink.close();
|
|
_channel = null;
|
|
_setConnectionState(WsConnectionState.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 _connectionStateController.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;
|
|
if (_manualDisconnect) {
|
|
_retryCount = 0;
|
|
_nextReconnectDelay = _initialReconnectDelay;
|
|
_setConnectionState(WsConnectionState.disconnected);
|
|
return;
|
|
}
|
|
|
|
unawaited(reconnect());
|
|
}
|
|
|
|
void _setConnectionState(WsConnectionState state) {
|
|
_connectionState = state;
|
|
_connectionStateController.add(state);
|
|
}
|
|
|
|
SocketConnectionStatus _toLegacyConnectionStatus(WsConnectionState state) {
|
|
switch (state) {
|
|
case WsConnectionState.connected:
|
|
return SocketConnectionStatus.connected;
|
|
case WsConnectionState.connecting:
|
|
return SocketConnectionStatus.connecting;
|
|
case WsConnectionState.disconnected:
|
|
return SocketConnectionStatus.disconnected;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
int _normalizePort(int value) {
|
|
if (value <= 0 || value > 65535) {
|
|
return 5000;
|
|
}
|
|
return value;
|
|
}
|
|
}
|