Merge pull request #272 from just-stuff-tm/tcp
feat: Add TCP connection support and UI integrationchore/offband-rebrand
commit
e90742be25
@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../services/tcp_transport_service.dart';
|
||||
|
||||
/// Manages TCP transport for MeshCore devices.
|
||||
///
|
||||
/// Owns the [TcpTransportService] and TCP-specific connection state.
|
||||
/// The main [MeshCoreConnector] delegates all TCP operations here.
|
||||
class MeshCoreTcpConnector {
|
||||
final TcpTransportService _service = TcpTransportService();
|
||||
AppDebugLogService? _debugLog;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
|
||||
// --- Getters ---
|
||||
String? get activeEndpoint => _service.activeEndpoint;
|
||||
bool get isConnected => _service.isConnected;
|
||||
|
||||
// --- Configuration ---
|
||||
void setDebugLogService(AppDebugLogService? service) {
|
||||
_debugLog = service;
|
||||
_service.setDebugLogService(service);
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
Future<void> connect({required String host, required int port}) async {
|
||||
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
await _service.connect(host: host, port: port);
|
||||
_debugLog?.info(
|
||||
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
|
||||
tag: 'TCP',
|
||||
);
|
||||
}
|
||||
|
||||
StreamSubscription<Uint8List> listenFrames({
|
||||
required void Function(Uint8List) onFrame,
|
||||
required void Function(Object, StackTrace?) onError,
|
||||
required void Function() onDone,
|
||||
}) {
|
||||
_frameSubscription = _service.frameStream.listen(
|
||||
onFrame,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
);
|
||||
return _frameSubscription!;
|
||||
}
|
||||
|
||||
Future<void> cancelFrameSubscription() async {
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!_service.isConnected && _frameSubscription == null) return;
|
||||
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
await _service.disconnect();
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_service.dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,282 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'usb_screen.dart';
|
||||
|
||||
class TcpScreen extends StatefulWidget {
|
||||
const TcpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TcpScreen> createState() => _TcpScreenState();
|
||||
}
|
||||
|
||||
class _TcpScreenState extends State<TcpScreen> {
|
||||
late final TextEditingController _hostController;
|
||||
late final TextEditingController _portController;
|
||||
late final MeshCoreConnector _connector;
|
||||
late final VoidCallback _connectionListener;
|
||||
bool _navigatedToContacts = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hostController = TextEditingController();
|
||||
_portController = TextEditingController(text: '5000');
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
|
||||
_connectionListener = () {
|
||||
if (!mounted) return;
|
||||
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_navigatedToContacts = false;
|
||||
}
|
||||
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||
_connector.isTcpTransportConnected &&
|
||||
!_navigatedToContacts) {
|
||||
_navigatedToContacts = true;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
);
|
||||
}
|
||||
};
|
||||
_connector.addListener(_connectionListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
_connector.removeListener(_connectionListener);
|
||||
if (!_navigatedToContacts &&
|
||||
_connector.activeTransport == MeshCoreTransportType.tcp &&
|
||||
_connector.state != MeshCoreConnectionState.disconnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_connector.disconnect(manual: true));
|
||||
});
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isConnecting =
|
||||
connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp;
|
||||
final isButtonDisabled =
|
||||
isConnecting ||
|
||||
connector.state == MeshCoreConnectionState.scanning;
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatusBar(context, connector),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _hostController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.tcpHostLabel,
|
||||
hintText: context.l10n.tcpHostHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: !isConnecting,
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _portController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.tcpPortLabel,
|
||||
hintText: context.l10n.tcpPortHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: !isConnecting,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
key: const Key('tcp_connect_button'),
|
||||
onPressed: isButtonDisabled ? null : _connectTcp,
|
||||
icon: isConnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lan),
|
||||
label: Text(
|
||||
isConnecting
|
||||
? context.l10n.scanner_connecting
|
||||
: context.l10n.common_connect,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (PlatformInfo.supportsUsbSerial)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||
);
|
||||
},
|
||||
heroTag: 'tcp_usb_action',
|
||||
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
icon: const Icon(Icons.usb),
|
||||
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||
),
|
||||
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
heroTag: 'tcp_ble_action',
|
||||
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
if (connector.isTcpTransportConnected) {
|
||||
statusText = l10n.scanner_connectedTo(
|
||||
connector.activeTcpEndpoint ?? 'TCP',
|
||||
);
|
||||
statusColor = Colors.green;
|
||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
statusText = l10n.tcpStatus_connectingTo(
|
||||
'${_hostController.text}:${_portController.text}',
|
||||
);
|
||||
statusColor = Colors.orange;
|
||||
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
statusText = l10n.scanner_disconnecting;
|
||||
statusColor = Colors.orange;
|
||||
} else {
|
||||
statusText = l10n.tcpStatus_notConnected;
|
||||
statusColor = Colors.grey;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 12, color: statusColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connectTcp() async {
|
||||
if (_connector.state == MeshCoreConnectionState.connecting ||
|
||||
_connector.state == MeshCoreConnectionState.connected ||
|
||||
_connector.state == MeshCoreConnectionState.disconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
final host = _hostController.text.trim();
|
||||
final parsedPort = int.tryParse(_portController.text.trim());
|
||||
if (host.isEmpty) {
|
||||
_showError(context.l10n.tcpErrorHostRequired);
|
||||
return;
|
||||
}
|
||||
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||
_showError(context.l10n.tcpErrorPortInvalid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _connector.connectTcp(host: host, port: parsedPort);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
_showError(_friendlyErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
String _friendlyErrorMessage(Object error) {
|
||||
if (error is UnsupportedError) {
|
||||
return context.l10n.tcpErrorUnsupported;
|
||||
}
|
||||
if (error is TimeoutException) {
|
||||
return context.l10n.tcpErrorTimedOut;
|
||||
}
|
||||
if (error is StateError) {
|
||||
return context.l10n.tcpConnectionFailed(error.message);
|
||||
}
|
||||
if (error is ArgumentError) {
|
||||
return context.l10n.tcpConnectionFailed(
|
||||
error.message?.toString() ?? error.toString(),
|
||||
);
|
||||
}
|
||||
return context.l10n.tcpConnectionFailed(error.toString());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export 'tcp_transport_service_native.dart'
|
||||
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
|
||||
@ -0,0 +1,210 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'app_debug_log_service.dart';
|
||||
import 'usb_serial_frame_codec.dart';
|
||||
|
||||
class TcpTransportService {
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||
|
||||
StreamSubscription<Uint8List>? _socketSubscription;
|
||||
Socket? _socket;
|
||||
AppDebugLogService? _debugLogService;
|
||||
TcpTransportStatus _status = TcpTransportStatus.disconnected;
|
||||
String? _activeHost;
|
||||
int? _activePort;
|
||||
Future<void> _pendingWrite = Future<void>.value();
|
||||
int _connectGeneration = 0;
|
||||
|
||||
TcpTransportStatus get status => _status;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get isConnected => _status == TcpTransportStatus.connected;
|
||||
String? get activeEndpoint => _activeHost == null || _activePort == null
|
||||
? null
|
||||
: '$_activeHost:$_activePort';
|
||||
|
||||
void setDebugLogService(AppDebugLogService? service) {
|
||||
_debugLogService = service;
|
||||
}
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
if (_status == TcpTransportStatus.connected ||
|
||||
_status == TcpTransportStatus.connecting) {
|
||||
throw StateError('TCP transport is already active');
|
||||
}
|
||||
final trimmedHost = host.trim();
|
||||
if (trimmedHost.isEmpty) {
|
||||
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
|
||||
}
|
||||
|
||||
_status = TcpTransportStatus.connecting;
|
||||
final generation = ++_connectGeneration;
|
||||
_frameDecoder.reset();
|
||||
|
||||
try {
|
||||
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
|
||||
if (generation != _connectGeneration ||
|
||||
_status != TcpTransportStatus.connecting) {
|
||||
try {
|
||||
await socket.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||
_socket = socket;
|
||||
_activeHost = trimmedHost;
|
||||
_activePort = port;
|
||||
_socketSubscription = socket.listen(
|
||||
_handleSocketData,
|
||||
onError: _handleSocketError,
|
||||
onDone: _handleSocketDone,
|
||||
);
|
||||
_status = TcpTransportStatus.connected;
|
||||
_debugLogService?.info(
|
||||
'TCP transport opened endpoint=$activeEndpoint',
|
||||
tag: 'TCP',
|
||||
);
|
||||
} catch (error) {
|
||||
await _cleanupFailedConnect();
|
||||
_status = TcpTransportStatus.disconnected;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (!isConnected || _socket == null) {
|
||||
throw StateError('TCP transport is not connected');
|
||||
}
|
||||
|
||||
final packet = wrapUsbSerialTxFrame(data);
|
||||
_logFrameSummary('TCP TX frame', data);
|
||||
|
||||
final writeTask = _pendingWrite.then((_) async {
|
||||
final socket = _socket;
|
||||
if (!isConnected || socket == null) {
|
||||
throw StateError('TCP transport is not connected');
|
||||
}
|
||||
socket.add(packet);
|
||||
await socket.flush();
|
||||
});
|
||||
|
||||
_pendingWrite = writeTask.catchError((_) {});
|
||||
await writeTask;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_connectGeneration += 1;
|
||||
if (_status == TcpTransportStatus.disconnected) return;
|
||||
|
||||
final endpoint = activeEndpoint;
|
||||
_status = TcpTransportStatus.disconnecting;
|
||||
_frameDecoder.reset();
|
||||
_activeHost = null;
|
||||
_activePort = null;
|
||||
|
||||
final subscription = _socketSubscription;
|
||||
_socketSubscription = null;
|
||||
await subscription?.cancel();
|
||||
|
||||
final socket = _socket;
|
||||
_socket = null;
|
||||
try {
|
||||
await socket?.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
_status = TcpTransportStatus.disconnected;
|
||||
_debugLogService?.info(
|
||||
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
|
||||
tag: 'TCP',
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
unawaited(disconnect().whenComplete(_closeFrameController));
|
||||
}
|
||||
|
||||
Future<void> _cleanupFailedConnect() async {
|
||||
final subscription = _socketSubscription;
|
||||
_socketSubscription = null;
|
||||
await subscription?.cancel();
|
||||
final socket = _socket;
|
||||
_socket = null;
|
||||
try {
|
||||
await socket?.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
_activeHost = null;
|
||||
_activePort = null;
|
||||
_frameDecoder.reset();
|
||||
}
|
||||
|
||||
void _handleSocketData(Uint8List bytes) {
|
||||
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||
if (!packet.isRxFrame) {
|
||||
_debugLogService?.info(
|
||||
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||
tag: 'TCP',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
_addFrame(packet.payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
|
||||
_addFrameError(error, stackTrace);
|
||||
unawaited(disconnect());
|
||||
}
|
||||
|
||||
void _handleSocketDone() {
|
||||
if (_status == TcpTransportStatus.disconnecting ||
|
||||
_status == TcpTransportStatus.disconnected) {
|
||||
return;
|
||||
}
|
||||
_addFrameError(StateError('TCP socket closed by remote endpoint'));
|
||||
unawaited(disconnect());
|
||||
}
|
||||
|
||||
void _addFrame(Uint8List payload) {
|
||||
if (_frameController.isClosed) return;
|
||||
_frameController.add(payload);
|
||||
}
|
||||
|
||||
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||
if (_frameController.isClosed) return;
|
||||
_frameController.addError(error, stackTrace);
|
||||
}
|
||||
|
||||
void _logFrameSummary(String prefix, Uint8List payload) {
|
||||
final code = payload.isNotEmpty ? payload.first : -1;
|
||||
_debugLogService?.info(
|
||||
'$prefix code=$code len=${payload.length}',
|
||||
tag: 'TCP',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _closeFrameController() async {
|
||||
if (_frameController.isClosed) return;
|
||||
await _frameController.close();
|
||||
}
|
||||
}
|
||||
|
||||
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
|
||||
@ -0,0 +1,35 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'app_debug_log_service.dart';
|
||||
|
||||
class TcpTransportService {
|
||||
AppDebugLogService? _debugLogService;
|
||||
|
||||
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
|
||||
bool get isConnected => false;
|
||||
String? get activeEndpoint => null;
|
||||
|
||||
void setDebugLogService(AppDebugLogService? service) {
|
||||
_debugLogService = service;
|
||||
}
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
_debugLogService?.warn(
|
||||
'TCP transport requested on web for $host:$port',
|
||||
tag: 'TCP',
|
||||
);
|
||||
throw UnsupportedError('TCP transport is not supported on web.');
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
throw UnsupportedError('TCP transport is not supported on web.');
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
|
||||
void main() {
|
||||
group('shouldIgnoreLateTcpConnectError', () {
|
||||
test('returns true for manual cancel during disconnecting state', () {
|
||||
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||
manualDisconnect: true,
|
||||
state: MeshCoreConnectionState.disconnecting,
|
||||
activeTransport: MeshCoreTransportType.bluetooth,
|
||||
tcpManagerConnected: false,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns true for manual cancel after reaching disconnected state',
|
||||
() {
|
||||
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||
manualDisconnect: true,
|
||||
state: MeshCoreConnectionState.disconnected,
|
||||
activeTransport: MeshCoreTransportType.bluetooth,
|
||||
tcpManagerConnected: false,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('returns false when not a manual disconnect', () {
|
||||
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||
manualDisconnect: false,
|
||||
state: MeshCoreConnectionState.disconnecting,
|
||||
activeTransport: MeshCoreTransportType.bluetooth,
|
||||
tcpManagerConnected: false,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('returns false for connected state handshake failures', () {
|
||||
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||
manualDisconnect: true,
|
||||
state: MeshCoreConnectionState.connected,
|
||||
activeTransport: MeshCoreTransportType.tcp,
|
||||
tcpManagerConnected: true,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when TCP is still active while disconnecting', () {
|
||||
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||
manualDisconnect: true,
|
||||
state: MeshCoreConnectionState.disconnecting,
|
||||
activeTransport: MeshCoreTransportType.tcp,
|
||||
tcpManagerConnected: true,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldResetStateAfterTcpConnectAbort', () {
|
||||
test('returns true when TCP connect is still in connecting state', () {
|
||||
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||
state: MeshCoreConnectionState.connecting,
|
||||
activeTransport: MeshCoreTransportType.tcp,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('returns false when state is already disconnected', () {
|
||||
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||
state: MeshCoreConnectionState.disconnected,
|
||||
activeTransport: MeshCoreTransportType.tcp,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when transport switched away from TCP', () {
|
||||
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||
state: MeshCoreConnectionState.connecting,
|
||||
activeTransport: MeshCoreTransportType.bluetooth,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||
import 'package:meshcore_open/screens/tcp_screen.dart';
|
||||
|
||||
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||
_FakeMeshCoreConnector();
|
||||
|
||||
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
|
||||
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
|
||||
String? initialEndpoint;
|
||||
int connectTcpCalls = 0;
|
||||
String? lastHost;
|
||||
int? lastPort;
|
||||
|
||||
@override
|
||||
MeshCoreConnectionState get state => initialState;
|
||||
|
||||
@override
|
||||
MeshCoreTransportType get activeTransport => initialTransport;
|
||||
|
||||
@override
|
||||
bool get isTcpTransportConnected =>
|
||||
initialState == MeshCoreConnectionState.connected &&
|
||||
initialTransport == MeshCoreTransportType.tcp;
|
||||
|
||||
@override
|
||||
String? get activeTcpEndpoint => initialEndpoint;
|
||||
|
||||
@override
|
||||
Future<void> connectTcp({required String host, required int port}) async {
|
||||
connectTcpCalls += 1;
|
||||
lastHost = host;
|
||||
lastPort = port;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTestApp({
|
||||
required MeshCoreConnector connector,
|
||||
required Widget child,
|
||||
Locale? locale,
|
||||
}) {
|
||||
return ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: connector,
|
||||
child: MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
|
||||
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
|
||||
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
|
||||
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TcpScreen validation errors are localized', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '');
|
||||
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
|
||||
expect(connector.connectTcpCalls, 0);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
|
||||
await tester.enterText(find.byType(TextField).at(1), '99999');
|
||||
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connector.connectTcpCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('TCP Bluetooth action returns to existing scanner route', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TcpScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TcpScreen), findsNothing);
|
||||
expect(find.byType(ScannerScreen), findsOneWidget);
|
||||
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
expect(navigatorState.canPop(), isFalse);
|
||||
|
||||
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
|
||||
// Drain that debounce timer before test teardown.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('TcpScreen disables connect button while connector is scanning', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector()
|
||||
..initialState = MeshCoreConnectionState.scanning;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<ButtonStyleButton>(
|
||||
find.byKey(const Key('tcp_connect_button')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
expect(connector.connectTcpCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('TcpScreen narrow width long status text does not overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector = _FakeMeshCoreConnector()
|
||||
..initialState = MeshCoreConnectionState.connected
|
||||
..initialTransport = MeshCoreTransportType.tcp
|
||||
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(
|
||||
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/tcp_transport_service_native.dart';
|
||||
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
|
||||
|
||||
final class _DelayedConnectOverrides extends IOOverrides {
|
||||
_DelayedConnectOverrides(this.delay);
|
||||
|
||||
final Duration delay;
|
||||
|
||||
@override
|
||||
Future<Socket> socketConnect(
|
||||
host,
|
||||
int port, {
|
||||
sourceAddress,
|
||||
int sourcePort = 0,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
return super.socketConnect(
|
||||
host,
|
||||
port,
|
||||
sourceAddress: sourceAddress,
|
||||
sourcePort: sourcePort,
|
||||
timeout: timeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('connect/disconnect updates TCP transport state', () async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final service = TcpTransportService();
|
||||
|
||||
try {
|
||||
await service.connect(
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: server.port,
|
||||
);
|
||||
|
||||
expect(service.isConnected, isTrue);
|
||||
expect(
|
||||
service.activeEndpoint,
|
||||
'${InternetAddress.loopbackIPv4.address}:${server.port}',
|
||||
);
|
||||
|
||||
await service.disconnect();
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect is safe when already disconnected', () async {
|
||||
final service = TcpTransportService();
|
||||
|
||||
await service.disconnect();
|
||||
await service.disconnect();
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
});
|
||||
|
||||
test('emits only RX frames from socket stream', () async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final acceptedSocket = Completer<Socket>();
|
||||
final service = TcpTransportService();
|
||||
final receivedFrames = <Uint8List>[];
|
||||
|
||||
final serverSub = server.listen((socket) {
|
||||
if (!acceptedSocket.isCompleted) {
|
||||
acceptedSocket.complete(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
final frameSub = service.frameStream.listen(receivedFrames.add);
|
||||
|
||||
try {
|
||||
await service.connect(
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: server.port,
|
||||
);
|
||||
|
||||
final socket = await acceptedSocket.future.timeout(
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
socket.add(<int>[usbSerialTxFrameStart, 0x01, 0x00, 0x11]);
|
||||
socket.add(<int>[usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]);
|
||||
await socket.flush();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(receivedFrames, hasLength(1));
|
||||
expect(receivedFrames.single, orderedEquals(<int>[0x33, 0x44]));
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await frameSub.cancel();
|
||||
await serverSub.cancel();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'disconnect during in-flight connect keeps transport disconnected',
|
||||
() async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final service = TcpTransportService();
|
||||
final host = InternetAddress.loopbackIPv4.address;
|
||||
|
||||
try {
|
||||
await IOOverrides.runWithIOOverrides(() async {
|
||||
final connectFuture = service.connect(host: host, port: server.port);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
await service.disconnect();
|
||||
await connectFuture;
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.status, TcpTransportStatus.disconnected);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
}, _DelayedConnectOverrides(const Duration(milliseconds: 120)));
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in new issue