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

@@ -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();
}
}