feat: enhance audio input handling and add message polling functionality
This commit is contained in:
@@ -21,6 +21,7 @@ class _ConnectionStatusWidget extends StatefulWidget {
|
||||
final RtlTcpService rtlTcpService;
|
||||
final DateTime? lastReceivedTime;
|
||||
final DateTime? rtlTcpLastReceivedTime;
|
||||
final DateTime? audioLastReceivedTime;
|
||||
final InputSource inputSource;
|
||||
final bool rtlTcpConnected;
|
||||
|
||||
@@ -29,6 +30,7 @@ class _ConnectionStatusWidget extends StatefulWidget {
|
||||
required this.rtlTcpService,
|
||||
required this.lastReceivedTime,
|
||||
required this.rtlTcpLastReceivedTime,
|
||||
required this.audioLastReceivedTime,
|
||||
required this.inputSource,
|
||||
required this.rtlTcpConnected,
|
||||
});
|
||||
@@ -92,7 +94,7 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
|
||||
isConnected = AudioInputService().isListening;
|
||||
statusColor = isConnected ? Colors.green : Colors.red;
|
||||
statusText = isConnected ? '监听中' : '已停止';
|
||||
displayTime = widget.rtlTcpLastReceivedTime ?? widget.lastReceivedTime;
|
||||
displayTime = widget.audioLastReceivedTime;
|
||||
break;
|
||||
case InputSource.bluetooth:
|
||||
isConnected = _isConnected;
|
||||
@@ -229,13 +231,17 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
|
||||
StreamSubscription? _connectionSubscription;
|
||||
StreamSubscription? _rtlTcpConnectionSubscription;
|
||||
StreamSubscription? _audioConnectionSubscription;
|
||||
StreamSubscription? _dataSubscription;
|
||||
StreamSubscription? _rtlTcpDataSubscription;
|
||||
StreamSubscription? _audioDataSubscription;
|
||||
StreamSubscription? _lastReceivedTimeSubscription;
|
||||
StreamSubscription? _rtlTcpLastReceivedTimeSubscription;
|
||||
StreamSubscription? _audioLastReceivedTimeSubscription;
|
||||
StreamSubscription? _settingsSubscription;
|
||||
DateTime? _lastReceivedTime;
|
||||
DateTime? _rtlTcpLastReceivedTime;
|
||||
DateTime? _audioLastReceivedTime;
|
||||
bool _isHistoryEditMode = false;
|
||||
|
||||
InputSource _inputSource = InputSource.bluetooth;
|
||||
@@ -277,11 +283,13 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
final sourceStr = settings?['inputSource'] as String? ?? 'bluetooth';
|
||||
|
||||
if (mounted) {
|
||||
final newSource = InputSource.values.firstWhere(
|
||||
(e) => e.name == sourceStr,
|
||||
orElse: () => InputSource.bluetooth,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_inputSource = InputSource.values.firstWhere(
|
||||
(e) => e.name == sourceStr,
|
||||
orElse: () => InputSource.bluetooth,
|
||||
);
|
||||
_inputSource = newSource;
|
||||
_rtlTcpConnected = _rtlTcpService.isConnected;
|
||||
});
|
||||
|
||||
@@ -324,6 +332,15 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_audioLastReceivedTimeSubscription =
|
||||
AudioInputService().lastReceivedTimeStream.listen((time) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_audioLastReceivedTime = time;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setupSettingsListener() {
|
||||
@@ -331,13 +348,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
DatabaseService.instance.onSettingsChanged((settings) {
|
||||
if (mounted) {
|
||||
final sourceStr = settings['inputSource'] as String? ?? 'bluetooth';
|
||||
print('[MainScreen] Settings changed: inputSource=$sourceStr');
|
||||
final newInputSource = InputSource.values.firstWhere(
|
||||
(e) => e.name == sourceStr,
|
||||
orElse: () => InputSource.bluetooth,
|
||||
);
|
||||
|
||||
print('[MainScreen] Current: $_inputSource, New: $newInputSource');
|
||||
|
||||
setState(() {
|
||||
_inputSource = newInputSource;
|
||||
@@ -348,7 +362,6 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
setState(() {
|
||||
_rtlTcpConnected = _rtlTcpService.isConnected;
|
||||
});
|
||||
print('[MainScreen] RTL-TCP mode, connected: $_rtlTcpConnected');
|
||||
break;
|
||||
case InputSource.audioInput:
|
||||
setState(() {});
|
||||
@@ -385,6 +398,12 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_audioConnectionSubscription = AudioInputService().connectionStream.listen((listening) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connectToRtlTcp(String host, String port) async {
|
||||
@@ -399,10 +418,13 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
void dispose() {
|
||||
_connectionSubscription?.cancel();
|
||||
_rtlTcpConnectionSubscription?.cancel();
|
||||
_audioConnectionSubscription?.cancel();
|
||||
_dataSubscription?.cancel();
|
||||
_rtlTcpDataSubscription?.cancel();
|
||||
_audioDataSubscription?.cancel();
|
||||
_lastReceivedTimeSubscription?.cancel();
|
||||
_rtlTcpLastReceivedTimeSubscription?.cancel();
|
||||
_audioLastReceivedTimeSubscription?.cancel();
|
||||
_settingsSubscription?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
@@ -426,7 +448,13 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
});
|
||||
|
||||
_rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) {
|
||||
if (_inputSource != InputSource.bluetooth) {
|
||||
if (_inputSource == InputSource.rtlTcp) {
|
||||
_processRecord(record);
|
||||
}
|
||||
});
|
||||
|
||||
_audioDataSubscription = AudioInputService().dataStream.listen((record) {
|
||||
if (_inputSource == InputSource.audioInput) {
|
||||
_processRecord(record);
|
||||
}
|
||||
});
|
||||
@@ -503,6 +531,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
rtlTcpService: _rtlTcpService,
|
||||
lastReceivedTime: _lastReceivedTime,
|
||||
rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime,
|
||||
audioLastReceivedTime: _audioLastReceivedTime,
|
||||
inputSource: _inputSource,
|
||||
rtlTcpConnected: _rtlTcpConnected,
|
||||
),
|
||||
|
||||
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late TextEditingController _rtlTcpHostController;
|
||||
late TextEditingController _rtlTcpPortController;
|
||||
|
||||
bool _settingsLoaded = false;
|
||||
|
||||
String _deviceName = '';
|
||||
bool _backgroundServiceEnabled = false;
|
||||
bool _notificationsEnabled = true;
|
||||
@@ -83,11 +85,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
(e) => e.name == sourceStr,
|
||||
orElse: () => InputSource.bluetooth,
|
||||
);
|
||||
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
if (!_settingsLoaded) return;
|
||||
|
||||
await _databaseService.updateSettings({
|
||||
'deviceName': _deviceName,
|
||||
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
|
||||
@@ -667,7 +673,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_buildActionButton(
|
||||
icon: Icons.share,
|
||||
title: '分享数据',
|
||||
subtitle: '将记录分享为JSON文件',
|
||||
subtitle: '将记录分享为 JSON 文件',
|
||||
onTap: _shareData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -1,6 +1,194 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'dart:developer' as developer;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toTrainRecordJson() {
|
||||
final now = DateTime.now();
|
||||
|
||||
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': 0.0,
|
||||
'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 AudioInputService {
|
||||
static final AudioInputService _instance = AudioInputService._internal();
|
||||
@@ -8,9 +196,107 @@ class AudioInputService {
|
||||
AudioInputService._internal();
|
||||
|
||||
static const _methodChannel = MethodChannel('org.noxylva.lbjconsole/audio_input');
|
||||
|
||||
static const _eventChannel = EventChannel('org.noxylva.lbjconsole/audio_input_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;
|
||||
|
||||
bool _isListening = false;
|
||||
DateTime? _lastReceivedTime;
|
||||
StreamSubscription? _eventChannelSubscription;
|
||||
final _LbJState _state = _LbJState();
|
||||
String _lastRawMessage = "";
|
||||
|
||||
bool get isListening => _isListening;
|
||||
DateTime? get lastReceivedTime => _lastReceivedTime;
|
||||
|
||||
void _updateListeningState(bool listening, String status) {
|
||||
if (_isListening == listening) return;
|
||||
_isListening = listening;
|
||||
if (!listening) {
|
||||
_lastReceivedTime = null;
|
||||
_state.reset();
|
||||
}
|
||||
_statusController.add(status);
|
||||
_connectionController.add(listening);
|
||||
_lastReceivedTimeController.add(_lastReceivedTime);
|
||||
}
|
||||
|
||||
void _listenToEventChannel() {
|
||||
if (_eventChannelSubscription != null) {
|
||||
_eventChannelSubscription?.cancel();
|
||||
}
|
||||
|
||||
_eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
(dynamic event) {
|
||||
try {
|
||||
final map = event as Map;
|
||||
|
||||
if (map.containsKey('listening')) {
|
||||
final listening = map['listening'] as bool? ?? false;
|
||||
if (_isListening != listening) {
|
||||
_updateListeningState(listening, listening ? "监听中" : "已停止");
|
||||
}
|
||||
}
|
||||
|
||||
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 String currentRawMessage = "$addr|$func|$numeric";
|
||||
if (currentRawMessage == _lastRawMessage) {
|
||||
return;
|
||||
}
|
||||
_lastRawMessage = currentRawMessage;
|
||||
|
||||
developer.log('Audio-RAW: $currentRawMessage', name: 'AudioInput');
|
||||
|
||||
if (!_isListening) {
|
||||
_updateListeningState(true, "监听中");
|
||||
}
|
||||
_lastReceivedTime = DateTime.now();
|
||||
_lastReceivedTimeController.add(_lastReceivedTime);
|
||||
|
||||
_state.updateFromRaw(addr, func, numeric);
|
||||
|
||||
if (addr == _lbjInfoAddr || addr == _lbjInfo2Addr) {
|
||||
final jsonData = _state.toTrainRecordJson();
|
||||
final trainRecord = TrainRecord.fromJson(jsonData);
|
||||
|
||||
_dataController.add(trainRecord);
|
||||
DatabaseService.instance.insertRecord(trainRecord);
|
||||
}
|
||||
}
|
||||
} catch (e, s) {
|
||||
developer.log('Audio StateMachine Error: $e',
|
||||
name: 'AudioInput', error: e, stackTrace: s);
|
||||
}
|
||||
},
|
||||
onError: (dynamic error) {
|
||||
_updateListeningState(false, "数据通道错误");
|
||||
_eventChannelSubscription?.cancel();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> startListening() async {
|
||||
if (_isListening) return true;
|
||||
@@ -25,8 +311,11 @@ class AudioInputService {
|
||||
}
|
||||
|
||||
try {
|
||||
_listenToEventChannel();
|
||||
await _methodChannel.invokeMethod('start');
|
||||
_isListening = true;
|
||||
_statusController.add("监听中");
|
||||
_connectionController.add(true);
|
||||
developer.log('Audio input started', name: 'AudioInput');
|
||||
return true;
|
||||
} on PlatformException catch (e) {
|
||||
@@ -41,9 +330,22 @@ class AudioInputService {
|
||||
try {
|
||||
await _methodChannel.invokeMethod('stop');
|
||||
_isListening = false;
|
||||
_lastReceivedTime = null;
|
||||
_state.reset();
|
||||
_statusController.add("已停止");
|
||||
_connectionController.add(false);
|
||||
_lastReceivedTimeController.add(null);
|
||||
developer.log('Audio input stopped', name: 'AudioInput');
|
||||
} catch (e) {
|
||||
developer.log('Error stopping audio input: $e', name: 'AudioInput');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_eventChannelSubscription?.cancel();
|
||||
_statusController.close();
|
||||
_dataController.close();
|
||||
_connectionController.close();
|
||||
_lastReceivedTimeController.close();
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,10 @@ class DatabaseService {
|
||||
where: 'id = 1',
|
||||
);
|
||||
if (result > 0) {
|
||||
_notifySettingsChanged(settings);
|
||||
final currentSettings = await getAllSettings();
|
||||
if (currentSettings != null) {
|
||||
_notifySettingsChanged(currentSettings);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -447,7 +450,6 @@ class DatabaseService {
|
||||
}
|
||||
|
||||
void _notifySettingsChanged(Map<String, dynamic> settings) {
|
||||
print('[Database] Notifying ${_settingsListeners.length} settings listeners');
|
||||
for (final listener in _settingsListeners) {
|
||||
listener(settings);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user