feat: integrate RTL-TCP server support

This commit is contained in:
Nedifinita
2025-11-01 22:39:52 +08:00
parent 356738ac10
commit 5aa19ada14
379 changed files with 109413 additions and 47 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/models/train_record.dart';
@@ -13,15 +14,25 @@ import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
import 'dart:convert';
class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService;
final RtlTcpService rtlTcpService;
final DateTime? lastReceivedTime;
final DateTime? rtlTcpLastReceivedTime;
final bool rtlTcpEnabled;
final bool rtlTcpConnected;
const _ConnectionStatusWidget({
required this.bleService,
required this.rtlTcpService,
required this.lastReceivedTime,
required this.rtlTcpLastReceivedTime,
required this.rtlTcpEnabled,
required this.rtlTcpConnected,
});
@override
@@ -58,19 +69,32 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
@override
Widget build(BuildContext context) {
final isRtlTcpMode = widget.rtlTcpEnabled;
final rtlTcpConnected = widget.rtlTcpConnected;
final isConnected = isRtlTcpMode ? rtlTcpConnected : _isConnected;
final statusColor = isRtlTcpMode
? (rtlTcpConnected ? Colors.green : Colors.red)
: (_isConnected ? Colors.green : Colors.red);
final statusText = isRtlTcpMode
? (rtlTcpConnected ? '已连接' : '未连接')
: _deviceStatus;
final lastReceivedTime = isRtlTcpMode ? widget.rtlTcpLastReceivedTime : widget.lastReceivedTime;
return Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.lastReceivedTime == null || !_isConnected) ...[
Text(_deviceStatus,
if (lastReceivedTime == null || !isConnected) ...[
Text(statusText,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
_LastReceivedTimeWidget(
lastReceivedTime: widget.lastReceivedTime,
isConnected: _isConnected,
lastReceivedTime: lastReceivedTime,
isConnected: isConnected,
),
],
),
@@ -79,7 +103,7 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isConnected ? Colors.green : Colors.red,
color: statusColor,
shape: BoxShape.circle,
),
),
@@ -179,14 +203,23 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
String _mapType = 'webview';
late final BLEService _bleService;
late final RtlTcpService _rtlTcpService;
final NotificationService _notificationService = NotificationService();
final DatabaseService _databaseService = DatabaseService.instance;
StreamSubscription? _connectionSubscription;
StreamSubscription? _rtlTcpConnectionSubscription;
StreamSubscription? _dataSubscription;
StreamSubscription? _rtlTcpDataSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
StreamSubscription? _rtlTcpLastReceivedTimeSubscription;
StreamSubscription? _settingsSubscription;
DateTime? _lastReceivedTime;
DateTime? _rtlTcpLastReceivedTime;
bool _isHistoryEditMode = false;
bool _rtlTcpEnabled = false;
bool _rtlTcpConnected = false;
bool _isConnected = false;
final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>();
final GlobalKey<RealtimeScreenState> _realtimeScreenKey =
@@ -197,23 +230,47 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
_bleService = BLEService();
_rtlTcpService = RtlTcpService();
_bleService.initialize();
_loadRtlTcpSettings();
_initializeServices();
_checkAndStartBackgroundService();
_setupConnectionListener();
_setupLastReceivedTimeListener();
_setupSettingsListener();
_loadMapType();
}
Future<void> _loadMapType() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
final settings = await DatabaseService.instance.getAllSettings();
if (mounted) {
setState(() {
_mapType = settings['mapType']?.toString() ?? 'webview';
_mapType = settings?['mapType']?.toString() ?? 'webview';
});
}
}
void _loadRtlTcpSettings() async {
developer.log('rtl_tcp: load_settings');
final settings = await _databaseService.getAllSettings();
developer.log('rtl_tcp: settings_loaded: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) {
setState(() {
_rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1;
_rtlTcpConnected = _rtlTcpService.isConnected;
});
if (_rtlTcpEnabled && !_rtlTcpConnected) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423';
developer.log('rtl_tcp: auto_connect');
_connectToRtlTcp(host, port);
} else {
developer.log('rtl_tcp: skip_connect: enabled=$_rtlTcpEnabled, connected=$_rtlTcpConnected');
}
}
}
Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
final backgroundServiceEnabled =
@@ -233,22 +290,92 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
});
}
});
_rtlTcpLastReceivedTimeSubscription =
_rtlTcpService.lastReceivedTimeStream.listen((time) {
if (mounted) {
if (_rtlTcpEnabled) {
setState(() {
_rtlTcpLastReceivedTime = time;
});
}
}
});
}
void _setupSettingsListener() {
developer.log('rtl_tcp: setup_listener');
_settingsSubscription =
DatabaseService.instance.onSettingsChanged((settings) {
if (mounted && _currentIndex == 1) {
_realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
developer.log('rtl_tcp: settings_changed: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) {
final rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1;
if (rtlTcpEnabled != _rtlTcpEnabled) {
setState(() {
_rtlTcpEnabled = rtlTcpEnabled;
});
if (rtlTcpEnabled) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423';
_connectToRtlTcp(host, port);
} else {
_rtlTcpConnectionSubscription?.cancel();
_rtlTcpDataSubscription?.cancel();
_rtlTcpLastReceivedTimeSubscription?.cancel();
_rtlTcpService.disconnect();
setState(() {
_rtlTcpConnected = false;
_rtlTcpLastReceivedTime = null;
});
}
}
if (_currentIndex == 1) {
_realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
}
}
});
}
void _setupConnectionListener() {
_connectionSubscription = _bleService.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_isConnected = connected;
});
}
});
_rtlTcpConnectionSubscription = _rtlTcpService.connectionStream.listen((connected) {
if (mounted) {
if (_rtlTcpEnabled) {
setState(() {
_rtlTcpConnected = connected;
});
}
}
});
}
Future<void> _connectToRtlTcp(String host, String port) async {
developer.log('rtl_tcp: connect: $host:$port');
try {
await _rtlTcpService.connect(host: host, port: port);
developer.log('rtl_tcp: connect_req_sent');
} catch (e) {
developer.log('rtl_tcp: connect_fail: $e');
}
}
@override
void dispose() {
_connectionSubscription?.cancel();
_rtlTcpConnectionSubscription?.cancel();
_dataSubscription?.cancel();
_rtlTcpDataSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
_rtlTcpLastReceivedTimeSubscription?.cancel();
_settingsSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -274,6 +401,18 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_realtimeScreenKey.currentState!.addNewRecord(record);
}
});
_rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) {
developer.log('rtl_tcp: recv_data: train=${record.train}');
developer.log('rtl_tcp: recv_json: ${jsonEncode(record.toJson())}');
_notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.addNewRecord(record);
}
if (_realtimeScreenKey.currentState != null) {
_realtimeScreenKey.currentState!.addNewRecord(record);
}
});
}
void _showConnectionDialog() {
@@ -282,7 +421,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
context: context,
barrierDismissible: true,
builder: (context) =>
_PixelPerfectBluetoothDialog(bleService: _bleService),
_PixelPerfectBluetoothDialog(bleService: _bleService, rtlTcpEnabled: _rtlTcpEnabled),
).then((_) {
_bleService.setAutoConnectBlocked(false);
if (!_bleService.isManualDisconnect) {
@@ -325,19 +464,26 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
),
centerTitle: false,
actions: [
Row(
children: [
_ConnectionStatusWidget(
bleService: _bleService,
lastReceivedTime: _lastReceivedTime,
),
IconButton(
icon: const Icon(Icons.bluetooth, color: Colors.white),
onPressed: _showConnectionDialog,
),
],
),
],
Row(
children: [
_ConnectionStatusWidget(
bleService: _bleService,
rtlTcpService: _rtlTcpService,
lastReceivedTime: _lastReceivedTime,
rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime,
rtlTcpEnabled: _rtlTcpEnabled,
rtlTcpConnected: _rtlTcpConnected,
),
IconButton(
icon: Icon(
_rtlTcpEnabled ? Icons.wifi : Icons.bluetooth,
color: Colors.white,
),
onPressed: _showConnectionDialog,
),
],
),
],
);
}
@@ -418,6 +564,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
SettingsScreen(
onSettingsChanged: () {
_loadMapType();
_loadRtlTcpSettings();
},
),
];
@@ -464,7 +611,8 @@ enum _ScanState { initial, scanning, finished }
class _PixelPerfectBluetoothDialog extends StatefulWidget {
final BLEService bleService;
const _PixelPerfectBluetoothDialog({required this.bleService});
final bool rtlTcpEnabled;
const _PixelPerfectBluetoothDialog({required this.bleService, required this.rtlTcpEnabled});
@override
State<_PixelPerfectBluetoothDialog> createState() =>
_PixelPerfectBluetoothDialogState();
@@ -477,13 +625,28 @@ class _PixelPerfectBluetoothDialogState
StreamSubscription? _connectionSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
StreamSubscription? _rtlTcpConnectionSubscription;
bool _rtlTcpConnected = false;
@override
void initState() {
super.initState();
_connectionSubscription = widget.bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
if (!widget.bleService.isConnected) {
_rtlTcpConnectionSubscription = widget.bleService.rtlTcpService?.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_rtlTcpConnected = connected;
});
}
});
if (widget.rtlTcpEnabled && widget.bleService.rtlTcpService != null) {
_rtlTcpConnected = widget.bleService.rtlTcpService!.isConnected;
}
if (!widget.bleService.isConnected && !widget.rtlTcpEnabled) {
_startScan();
}
}
@@ -491,6 +654,7 @@ class _PixelPerfectBluetoothDialogState
@override
void dispose() {
_connectionSubscription?.cancel();
_rtlTcpConnectionSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
super.dispose();
}
@@ -536,13 +700,15 @@ class _PixelPerfectBluetoothDialogState
Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected;
return AlertDialog(
title: const Text('蓝牙设备'),
title: Text(widget.rtlTcpEnabled ? 'RTL-TCP 模式' : '蓝牙设备'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context),
child: widget.rtlTcpEnabled
? _buildRtlTcpView(context)
: (isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context)),
),
),
actions: [
@@ -605,6 +771,31 @@ class _PixelPerfectBluetoothDialogState
]);
}
Widget _buildRtlTcpView(BuildContext context) {
final isConnected = _rtlTcpConnected;
final currentAddress = widget.bleService.rtlTcpService?.currentAddress ?? '未配置';
return Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.wifi, size: 48, color: isConnected ? Colors.green : Colors.red),
const SizedBox(height: 16),
Text(isConnected ? '已连接' : '未连接',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('$currentAddress',
style: TextStyle(color: isConnected ? Colors.green : Colors.grey)),
const SizedBox(height: 16),
if (_lastReceivedTime != null && isConnected) ...[
_LastReceivedTimeWidget(
lastReceivedTime: _lastReceivedTime,
isConnected: isConnected,
),
],
]);
}
Widget _buildDeviceListView() {
return SizedBox(
height: 200,

View File

@@ -28,6 +28,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
late DatabaseService _databaseService;
late TextEditingController _deviceNameController;
late TextEditingController _rtlTcpHostController;
late TextEditingController _rtlTcpPortController;
String _deviceName = '';
bool _backgroundServiceEnabled = false;
@@ -39,19 +41,143 @@ class _SettingsScreenState extends State<SettingsScreen> {
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map';
bool _rtlTcpEnabled = false;
String _rtlTcpHost = '127.0.0.1';
String _rtlTcpPort = '14423';
@override
void initState() {
super.initState();
_databaseService = DatabaseService.instance;
_deviceNameController = TextEditingController();
_rtlTcpHostController = TextEditingController();
_rtlTcpPortController = TextEditingController();
_loadSettings();
_loadRecordCount();
}
Widget _buildRtlTcpSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.wifi,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('RTL-TCP 接收', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用RTL-TCP接收', style: AppTheme.bodyLarge),
],
),
Switch(
value: _rtlTcpEnabled,
onChanged: (value) {
setState(() {
_rtlTcpEnabled = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
Visibility(
visible: _rtlTcpEnabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器地址',
hintText: '输入RTL-TCP服务器地址',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpHostController,
onChanged: (value) {
setState(() {
_rtlTcpHost = value;
});
_saveSettings();
},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器端口',
hintText: '输入RTL-TCP服务器端口',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpPortController,
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_rtlTcpPort = value;
});
_saveSettings();
},
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_deviceNameController.dispose();
_rtlTcpHostController.dispose();
_rtlTcpPortController.dispose();
super.dispose();
}
@@ -71,6 +197,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
_rtlTcpEnabled = (settingsMap['rtlTcpEnabled'] ?? 0) == 1;
_rtlTcpHost = settingsMap['rtlTcpHost']?.toString() ?? '127.0.0.1';
_rtlTcpPort = settingsMap['rtlTcpPort']?.toString() ?? '14423';
_rtlTcpHostController.text = _rtlTcpHost;
_rtlTcpPortController.text = _rtlTcpPort;
});
}
}
@@ -95,6 +226,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
'mapType': _mapType,
'rtlTcpEnabled': _rtlTcpEnabled ? 1 : 0,
'rtlTcpHost': _rtlTcpHost,
'rtlTcpPort': _rtlTcpPort,
});
widget.onSettingsChanged?.call();
}
@@ -110,6 +244,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 20),
_buildAppSettings(),
const SizedBox(height: 20),
_buildRtlTcpSettings(),
const SizedBox(height: 20),
_buildMergeSettings(),
const SizedBox(height: 20),
_buildDataManagement(),
@@ -204,7 +340,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('后台保活服务', style: AppTheme.bodyLarge),
Text('保持应用在后台运行', style: AppTheme.caption),
],
),
Switch(
@@ -232,8 +367,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LBJ消息通知', style: AppTheme.bodyLarge),
Text('接收LBJ消息通知', style: AppTheme.caption),
Text('通知服务', style: AppTheme.bodyLarge),
],
),
Switch(
@@ -255,8 +389,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('地图显示方式', style: AppTheme.bodyLarge),
Text('选择地图组件类型', style: AppTheme.caption),
Text('地图组件类型', style: AppTheme.bodyLarge),
],
),
DropdownButton<String>(
@@ -293,7 +426,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
@@ -342,7 +474,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用记录合并', style: AppTheme.bodyLarge),
Text('合并相同内容的LBJ记录', style: AppTheme.caption),
],
),
Switch(
@@ -452,7 +583,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏不可分组记录', style: AppTheme.bodyLarge),
Text('不显示无法分组的记录', style: AppTheme.caption),
],
),
Switch(

View File

@@ -12,6 +12,7 @@ const String _notificationChannelName = 'LBJ Console 后台服务';
const String _notificationChannelDescription = '保持蓝牙连接稳定';
const int _notificationId = 114514;
@pragma('vm:entry-point')
class BackgroundService {
static final FlutterBackgroundService _service = FlutterBackgroundService();
static bool _isInitialized = false;

View File

@@ -5,11 +5,17 @@ import 'dart:math';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart';
class BLEService {
static final BLEService _instance = BLEService._internal();
factory BLEService() => _instance;
BLEService._internal();
BLEService._internal() {
_rtlTcpService = RtlTcpService();
}
late final RtlTcpService _rtlTcpService;
RtlTcpService? get rtlTcpService => _rtlTcpService;
static const String TAG = "LBJ_BT_FLUTTER";
static final Guid serviceUuid = Guid("0000ffe0-0000-1000-8000-00805f9b34fb");

View File

@@ -13,7 +13,7 @@ class DatabaseService {
DatabaseService._internal();
static const String _databaseName = 'train_database';
static const _databaseVersion = 7;
static const _databaseVersion = 8;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
@@ -91,6 +91,14 @@ class DatabaseService {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapSettingsTimestamp INTEGER');
}
if (oldVersion < 8) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpEnabled INTEGER NOT NULL DEFAULT 0');
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpHost TEXT NOT NULL DEFAULT "127.0.0.1"');
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpPort TEXT NOT NULL DEFAULT "14423"');
}
}
Future<void> _onCreate(Database db, int version) async {
@@ -141,7 +149,10 @@ class DatabaseService {
timeWindow TEXT NOT NULL DEFAULT 'unlimited',
mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited',
hideUngroupableRecords INTEGER NOT NULL DEFAULT 0,
mapSettingsTimestamp INTEGER
mapSettingsTimestamp INTEGER,
rtlTcpEnabled INTEGER NOT NULL DEFAULT 0,
rtlTcpHost TEXT NOT NULL DEFAULT '127.0.0.1',
rtlTcpPort TEXT NOT NULL DEFAULT '14423'
)
''');
@@ -168,8 +179,11 @@ class DatabaseService {
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
'mapTimeFilter': 'unlimited',
'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null,
'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null,
'rtlTcpEnabled': 0,
'rtlTcpHost': '127.0.0.1',
'rtlTcpPort': '14423',
});
}

View File

@@ -0,0 +1,404 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:gbk_codec/gbk_codec.dart';
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/services/database_service.dart';
const String _lbjInfoAddr = "1234000";
const String _lbjInfo2Addr = "1234002";
const String _lbjSyncAddr = "1234008";
const int _functionDown = 1;
const int _functionUp = 3;
class _LbJState {
String train = "<NUL>";
int direction = -1;
String speed = "NUL";
String positionKm = " <NUL>";
String time = "<NUL>";
String lbjClass = "NA";
String loco = "<NUL>";
String route = "********";
String posLonDeg = "";
String posLonMin = "";
String posLatDeg = "";
String posLatMin = "";
String _info2Hex = "";
void reset() {
train = "<NUL>";
direction = -1;
speed = "NUL";
positionKm = " <NUL>";
time = "<NUL>";
lbjClass = "NA";
loco = "<NUL>";
route = "********";
posLonDeg = "";
posLonMin = "";
posLatDeg = "";
posLatMin = "";
_info2Hex = "";
}
String _recodeBCD(String numericStr) {
return numericStr
.replaceAll('.', 'A')
.replaceAll('U', 'B')
.replaceAll(' ', 'C')
.replaceAll('-', 'D')
.replaceAll(')', 'E')
.replaceAll('(', 'F');
}
int _hexToChar(String hex1, String hex2) {
final String hex = "$hex1$hex2";
return int.tryParse(hex, radix: 16) ?? 0;
}
String _gbkToUtf8(List<int> gbkBytes) {
try {
final validBytes = gbkBytes.where((b) => b != 0).toList();
return gbk.decode(validBytes);
} catch (e) {
return "";
}
}
void updateFromRaw(String addr, int func, String numeric) {
if (func == _functionDown || func == _functionUp) {
direction = func;
}
switch (addr) {
case _lbjInfoAddr:
final RegExp infoRegex = RegExp(r'^\s*(\S+)\s+(\S+)\s+(\S+)');
final match = infoRegex.firstMatch(numeric);
if (match != null) {
train = match.group(1) ?? "<NUL>";
speed = match.group(2) ?? "NUL";
String pos = match.group(3)?.trim() ?? "";
if (pos.isEmpty) {
positionKm = " <NUL>";
} else if (pos.length > 1) {
positionKm =
"${pos.substring(0, pos.length - 1)}.${pos.substring(pos.length - 1)}";
} else {
positionKm = "0.$pos";
}
}
break;
case _lbjInfo2Addr:
String buffer = numeric;
if (buffer.length < 50) return;
_info2Hex = _recodeBCD(buffer);
if (_info2Hex.length >= 4) {
try {
List<int> classBytes = [
_hexToChar(_info2Hex[0], _info2Hex[1]),
_hexToChar(_info2Hex[2], _info2Hex[3]),
];
lbjClass = String.fromCharCodes(classBytes
.where((b) => b > 0x1F && b < 0x7F && b != 0x22 && b != 0x2C));
} catch (e) {}
}
if (buffer.length >= 12) loco = buffer.substring(4, 12);
List<int> routeBytes = List<int>.filled(17, 0);
if (_info2Hex.length >= 18) {
try {
routeBytes[0] = _hexToChar(_info2Hex[14], _info2Hex[15]);
routeBytes[1] = _hexToChar(_info2Hex[16], _info2Hex[17]);
} catch (e) {}
}
if (_info2Hex.length >= 22) {
try {
routeBytes[2] = _hexToChar(_info2Hex[18], _info2Hex[19]);
routeBytes[3] = _hexToChar(_info2Hex[20], _info2Hex[21]);
} catch (e) {}
}
if (_info2Hex.length >= 30) {
try {
routeBytes[4] = _hexToChar(_info2Hex[22], _info2Hex[23]);
routeBytes[5] = _hexToChar(_info2Hex[24], _info2Hex[25]);
routeBytes[6] = _hexToChar(_info2Hex[26], _info2Hex[27]);
routeBytes[7] = _hexToChar(_info2Hex[28], _info2Hex[29]);
} catch (e) {}
}
route = _gbkToUtf8(routeBytes);
if (buffer.length >= 39) {
posLonDeg = buffer.substring(30, 33);
posLonMin = "${buffer.substring(33, 35)}.${buffer.substring(35, 39)}";
}
if (buffer.length >= 47) {
posLatDeg = buffer.substring(39, 41);
posLatMin = "${buffer.substring(41, 43)}.${buffer.substring(43, 47)}";
}
break;
case _lbjSyncAddr:
if (numeric.length >= 5)
time = "${numeric.substring(1, 3)}:${numeric.substring(3, 5)}";
break;
}
}
double _convertMagSqToRssi(double magsqRaw) {
if (magsqRaw <= 0) return -120.0;
double rssi = 10 * log(magsqRaw) / log(10);
return (rssi - 30.0).clamp(-120.0, -20.0);
}
Map<String, dynamic> toTrainRecordJson(double magsqRaw) {
final now = DateTime.now();
final double finalRssi = _convertMagSqToRssi(magsqRaw);
String gpsPosition = "";
if (posLatDeg.isNotEmpty && posLatMin.isNotEmpty) {
gpsPosition = "${posLatDeg}°${posLatMin}";
}
if (posLonDeg.isNotEmpty && posLonMin.isNotEmpty) {
gpsPosition +=
(gpsPosition.isEmpty ? "" : " ") + "${posLonDeg}°${posLonMin}";
}
String kmPosition = positionKm.replaceAll(' <NUL>', '');
final jsonData = {
'uniqueId': '${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}',
'receivedTimestamp': now.millisecondsSinceEpoch,
'timestamp': now.millisecondsSinceEpoch,
'rssi': finalRssi,
'train': train.replaceAll('<NUL>', ''),
'loco': loco.replaceAll('<NUL>', ''),
'speed': speed.replaceAll('NUL', ''),
'position': kmPosition,
'positionInfo': gpsPosition,
'route': route.replaceAll('********', ''),
'lbjClass': lbjClass.replaceAll('NA', ''),
'time': time.replaceAll('<NUL>', ''),
'direction': (direction == 1 || direction == 3) ? direction : 0,
'locoType': "",
};
return jsonData;
}
}
class RtlTcpService {
static final RtlTcpService _instance = RtlTcpService._internal();
factory RtlTcpService() => _instance;
RtlTcpService._internal();
static const _methodChannel =
MethodChannel('org.noxylva.lbjconsole/rtl_tcp_method');
static const _eventChannel =
EventChannel('org.noxylva.lbjconsole/rtl_tcp_event');
final StreamController<String> _statusController =
StreamController<String>.broadcast();
final StreamController<TrainRecord> _dataController =
StreamController<TrainRecord>.broadcast();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
final StreamController<DateTime?> _lastReceivedTimeController =
StreamController<DateTime?>.broadcast();
Stream<String> get statusStream => _statusController.stream;
Stream<TrainRecord> get dataStream => _dataController.stream;
Stream<bool> get connectionStream => _connectionController.stream;
Stream<DateTime?> get lastReceivedTimeStream =>
_lastReceivedTimeController.stream;
String _deviceStatus = "未连接";
bool _isConnected = false;
DateTime? _lastReceivedTime;
StreamSubscription? _eventChannelSubscription;
final _LbJState _state = _LbJState();
Timer? _reconnectTimer;
String _lastHost = '127.0.0.1';
String _lastPort = '14423';
static const Duration _reconnectInterval = Duration(seconds: 2);
bool _isEnabled = false;
static const bool _logRaw = true;
static const bool _logParsed = true;
String _lastRawMessage = "";
bool get isConnected => _isConnected;
String get deviceStatus => _deviceStatus;
String get currentAddress => '$_lastHost:$_lastPort';
bool get isEnabled => _isEnabled;
void _updateConnectionState(bool connected, String status) {
if (_isConnected == connected && _deviceStatus == status) return;
final wasConnected = _isConnected;
_isConnected = connected;
_deviceStatus = status;
if (!connected) {
_lastReceivedTime = null;
if (wasConnected) _state.reset();
if (_isEnabled) _startAutoReconnect();
} else {
_cancelAutoReconnect();
}
_statusController.add(_deviceStatus);
_connectionController.add(_isConnected);
_lastReceivedTimeController.add(_lastReceivedTime);
}
void _startAutoReconnect() {
if (!_isEnabled || _reconnectTimer != null) return;
developer.log('RTL-TCP: 启动自动重连定时器', name: 'RtlTcpService');
_reconnectTimer = Timer.periodic(_reconnectInterval, (timer) {
if (_isConnected || !_isEnabled) {
_cancelAutoReconnect();
return;
}
developer.log('RTL-TCP: 自动重连: 尝试连接...', name: 'RtlTcpService');
_internalConnect();
});
}
void _cancelAutoReconnect() {
if (_reconnectTimer != null) {
developer.log('RTL-TCP: 取消自动重连', name: 'RtlTcpService');
_reconnectTimer!.cancel();
_reconnectTimer = null;
}
}
void _listenToEventChannel() {
if (_eventChannelSubscription != null) {
_eventChannelSubscription?.cancel();
}
_eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
try {
final map = event as Map;
if (map.containsKey('connected')) {
final connected = map['connected'] as bool? ?? false;
if (_isConnected != connected) {
_updateConnectionState(connected, connected ? "已连接" : "已断开");
}
}
if (map.containsKey('address')) {
final addr = map['address'] as String;
if (addr != _lbjInfoAddr &&
addr != _lbjInfo2Addr &&
addr != _lbjSyncAddr) {
return;
}
final func = int.tryParse(map['func'] as String? ?? '-1') ?? -1;
final numeric = map['numeric'] as String;
final magsqRaw = (map['magsqRaw'] as num?)?.toDouble() ?? 0.0;
final String currentRawMessage = "$addr|$func|$numeric";
if (currentRawMessage == _lastRawMessage) {
return;
}
_lastRawMessage = currentRawMessage;
if (_logRaw) {
developer.log('RTL-TCP-RAW: $currentRawMessage',
name: 'RTL-TCP-Data');
}
if (!_isConnected) {
_updateConnectionState(true, "已连接");
}
_lastReceivedTime = DateTime.now();
_lastReceivedTimeController.add(_lastReceivedTime);
_state.updateFromRaw(addr, func, numeric);
if (addr == _lbjInfoAddr || addr == _lbjInfo2Addr) {
final jsonData = _state.toTrainRecordJson(magsqRaw);
final trainRecord = TrainRecord.fromJson(jsonData);
if (_logParsed) {
developer.log('RTL-TCP-PARSED: ${jsonEncode(jsonData)}',
name: 'RTL-TCP-Data');
}
_dataController.add(trainRecord);
DatabaseService.instance.insertRecord(trainRecord);
}
}
} catch (e, s) {
developer.log('RTL-TCP StateMachine Error: $e',
name: 'RTL-TCP', error: e, stackTrace: s);
_updateConnectionState(false, "数据解析错误");
}
},
onError: (dynamic error) {
_updateConnectionState(false, "数据通道错误");
_eventChannelSubscription?.cancel();
_eventChannelSubscription = null;
},
onDone: () {
_updateConnectionState(false, "连接已断开");
_eventChannelSubscription = null;
},
);
}
Future<void> _internalConnect() async {
if (_eventChannelSubscription == null) {
_listenToEventChannel();
}
try {
await _methodChannel
.invokeMethod('connect', {'host': _lastHost, 'port': _lastPort});
} on PlatformException catch (e) {
_updateConnectionState(false, "连接失败: ${e.message}");
}
}
Future<void> connect({String? host, String? port}) async {
final settings = await DatabaseService.instance.getAllSettings();
final dbHost = host ?? settings?['rtl_tcp_host'] ?? '127.0.0.1';
final dbPort = port ?? settings?['rtl_tcp_port'] ?? '14423';
_isEnabled = true;
_lastHost = dbHost;
_lastPort = dbPort;
if (_isConnected) return;
_updateConnectionState(false, "正在连接...");
_internalConnect();
}
Future<void> disconnect() async {
_isEnabled = false;
_cancelAutoReconnect();
await _eventChannelSubscription?.cancel();
_eventChannelSubscription = null;
try {
await _methodChannel.invokeMethod('disconnect');
} catch (e) {
developer.log('RTL-TCP: 原生断开出错: $e', name: 'RTL-TCP');
}
_updateConnectionState(false, "已禁用");
}
void dispose() {
disconnect();
_statusController.close();
_dataController.close();
_connectionController.close();
_lastReceivedTimeController.close();
}
}