environment_monitoring_app/lib/serial/serial_manager.dart

541 lines
26 KiB
Dart

// lib/serial/serial_manager.dart
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; // For debugPrint
import 'package:usb_serial/usb_serial.dart';
import 'utils/converter.dart';
import 'utils/crc_calculator.dart';
import 'utils/parameter_helper.dart';
enum SerialConnectionState { disconnected, connecting, connected }
/// Manages the connection and data communication with a USB Serial device.
class SerialManager {
UsbPort? _port;
// Using ValueNotifier to easily notify UI about connection state changes.
final ValueNotifier<SerialConnectionState> connectionState = ValueNotifier(
SerialConnectionState.disconnected,
);
// ValueNotifier to expose the connected device's name.
final ValueNotifier<String?> connectedDeviceName = ValueNotifier(null);
// --- ADDED: ValueNotifier for Sonde ID ---
// This will be updated when the Sonde ID is parsed from Level 0 data.
final ValueNotifier<String?> sondeId = ValueNotifier<String?>(null);
// --- ADDED: Flag to prevent updates after disposal ---
bool _isDisposed = false;
// StreamController to broadcast parsed data readings to multiple listeners.
final StreamController<Map<String, double>> _dataStreamController =
StreamController<Map<String, double>>.broadcast();
Stream<Map<String, double>> get dataStream => _dataStreamController.stream;
// --- Protocol State Variables ---
int _runningCounter = 0; // Sequence number for commands
int _communicationLevel = 0; // Tracks the current step in the 3-level protocol
String? _parentAddress; // Address parsed from Level 0 response
String? _serialNumber; // Serial number parsed from Level 0 response (used internally for now)
List<String> _parameterList = []; // List of parameters parsed from Level 1 response
Timer? _dataRequestTimer; // Timer for periodic auto-reading
Timer? _responseTimeoutTimer; // Timer to detect if a response is not received in time
// Buffer to accumulate incoming serial data, as messages may arrive in chunks.
final StringBuffer _responseBuffer = StringBuffer();
// String to look for at the start of an expected response (e.g., '7E02 + seqNo')
String? _lookupString;
// Flag to prevent sending new commands before the current read cycle is complete.
bool _isReading = false;
/// Fetches a list of available USB devices connected to the Android device.
Future<List<UsbDevice>> getAvailableDevices() async {
return UsbSerial.listDevices();
}
/// Connects to the specified USB device.
/// Handles opening the port, setting parameters, and starting the input stream listener.
Future<void> connect(UsbDevice device) async {
// Prevent connecting if already connected
if (connectionState.value == SerialConnectionState.connected) {
debugPrint("SerialManager: Already connected. Disconnecting existing connection.");
// Now that disconnect() is async and returns Future<void>, awaiting it is correct.
await disconnect(); // Disconnect cleanly before new connection
}
try {
_isDisposed = false; // Reset disposed flag on new connection
connectionState.value = SerialConnectionState.connecting;
connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early
_port = await device.create();
if (_port == null) {
debugPrint("SerialManager: Failed to create USB serial port for device: ${device.productName}.");
// No need to call disconnect, as nothing was connected yet.
connectionState.value = SerialConnectionState.disconnected; // Reset state
connectedDeviceName.value = null; // Clear device name
throw Exception("Failed to create USB serial port.");
}
bool openResult = await _port!.open();
if (!openResult) {
debugPrint("SerialManager: Failed to open USB serial port for device: ${device.productName}.");
_port = null; // Clear port reference
connectionState.value = SerialConnectionState.disconnected; // Reset state
connectedDeviceName.value = null; // Clear device name
throw Exception("Failed to open USB serial port.");
}
await _port!.setDTR(true);
await _port!.setRTS(true);
_port!.setPortParameters(
115200, // Baud Rate
UsbPort.DATABITS_8,
UsbPort.STOPBITS_1,
UsbPort.PARITY_NONE,
);
// Listen for incoming data from the USB serial port.
_port!.inputStream!.listen(
_onDataReceived,
onError: (e) {
debugPrint("SerialManager: Stream Error: $e");
// Since disconnect() is now async, calling it directly here without await is fine
// because we don't need to wait for it within the error handler.
disconnect();
},
onDone: () {
debugPrint("SerialManager: Stream Closed");
// Same here, no need to await within onDone.
disconnect();
},
);
// --- REVISED: Added null check before updating state ---
if (!_isDisposed) {
connectionState.value = SerialConnectionState.connected;
connectedDeviceName.value = device.productName;
}
debugPrint("SerialManager: Connected to ${connectedDeviceName.value}");
// Start the live reading cycle immediately after connecting
startLiveReading();
} catch (e) {
debugPrint("SerialManager: Connection Error: $e");
// Ensure all states are reset on error during connection
if (!_isDisposed) {
connectionState.value = SerialConnectionState.disconnected;
connectedDeviceName.value = null;
sondeId.value = null; // Clear Sonde ID
}
_port = null;
_responseBuffer.clear();
_isReading = false;
_cancelTimeout();
rethrow; // Re-throw the exception for UI handling
}
}
/// Disconnects from the currently connected USB device.
/// Cleans up resources: cancels timers, closes port, resets state.
// MODIFIED: Changed from void to Future<void> and added async.
Future<void> disconnect() async {
if (connectionState.value != SerialConnectionState.disconnected) {
debugPrint("SerialManager: Disconnecting...");
stopAutoReading(); // Stop any active auto-reading timer and timeout
await _port?.close(); // Now correctly awaiting the close operation
_port = null; // Clear port reference
connectedDeviceName.value = null; // Clear device name
sondeId.value = null; // Clear Sonde ID on disconnect
connectionState.value = SerialConnectionState.disconnected; // Update connection state
_responseBuffer.clear(); // Clear any buffered data
_isReading = false; // Reset reading flag
_communicationLevel = 0; // Reset communication level
_parentAddress = null;
_serialNumber = null;
_parameterList.clear();
debugPrint("SerialManager: Disconnected.");
}
}
/// Starts a periodic timer to automatically request data from the device.
void startAutoReading({Duration interval = const Duration(seconds: 5)}) {
stopAutoReading(); // Stop any existing auto-reading timer first
if (connectionState.value == SerialConnectionState.connected) {
//startLiveReading(); // Initiate the first reading immediately
// Set up a periodic timer to call startLiveReading at the specified interval.
_dataRequestTimer = Timer.periodic(interval, (_) => startLiveReading());
debugPrint("SerialManager: Auto reading started with interval ${interval.inSeconds}s.");
} else {
debugPrint("SerialManager: Cannot start auto reading, not connected.");
}
}
/// Stops the automatic data refresh timer.
void stopAutoReading() {
_dataRequestTimer?.cancel(); // Cancel the periodic timer
_dataRequestTimer = null; // Clear timer reference
_cancelTimeout(); // Also cancel any pending response timeout
_isReading = false; // Ensure the reading flag is reset
debugPrint("SerialManager: Auto reading stopped.");
}
/// Initiates a full 3-step data reading sequence (Level 0, Level 1, Level 2).
/// This function prevents overlapping read cycles using the `_isReading` flag.
void startLiveReading() {
// Only proceed if connected and not already in a reading cycle
if (connectionState.value != SerialConnectionState.connected || _isReading) {
debugPrint("SerialManager: Cannot start live reading. Connected: ${connectionState.value == SerialConnectionState.connected}, Is Reading: $_isReading");
return;
}
_isReading = true; // Mark as busy to prevent re-entry
debugPrint("--- SerialManager: Starting New Read Cycle ---");
_communicationLevel = 0; // Start at communication level 0
_responseBuffer.clear(); // Clear the buffer for a fresh start
_lookupString = null; // Reset lookup string until a new command is sent
_sendCommand(0); // Send the Level 0 command
}
/// Callback function for the serial port's input stream.
/// Appends incoming data chunks to a buffer and attempts to process it.
void _onDataReceived(Uint8List data) {
if (data.isEmpty) return;
String responseHex = Converter.byteArrayToHexString(data);
_responseBuffer.write(responseHex); // Append to the shared buffer
// debugPrint("SerialManager: Received Chunk (Buffer: ${_responseBuffer.length} chars): $responseHex"); // Too verbose normally
_processBuffer(); // Attempt to process the buffer for complete messages
}
/// Attempts to extract and process complete messages from the `_responseBuffer`.
/// Handles partial messages, garbage data, and CRC validation.
void _processBuffer() {
String buffer = _responseBuffer.toString();
// Cannot process without an expected lookup string (set after sending a command)
if (_lookupString == null) {
// debugPrint("SerialManager: _processBuffer: No lookup string set. Waiting for command."); // Too verbose
return;
}
int startIndex = buffer.indexOf(_lookupString!); // Find the start of the expected message
if (startIndex == -1) {
// debugPrint("SerialManager: _processBuffer: Lookup string not found in buffer. Waiting for more data."); // Too verbose
return; // Expected start of message not found, wait for more data
}
// Discard any data before the expected start of the message (garbage or old data)
if (startIndex > 0) {
debugPrint("SerialManager: _processBuffer: Discarding ${startIndex} garbage characters from buffer.");
buffer = buffer.substring(startIndex); // Remove leading garbage
_responseBuffer.clear(); // Clear and rewrite the buffer to contain only valid data from startIndex
_responseBuffer.write(buffer);
}
// A minimum of 34 hex characters are needed to parse the dataBlockLength (offset 30, length 4)
if (buffer.length < 34) {
// debugPrint("SerialManager: _processBuffer: Buffer too short for header (${buffer.length} < 34). Waiting for more data."); // Too verbose
return; // Not enough data to even read the length field
}
try {
// Parse the data block length from the message header
int dataBlockLength = int.parse(buffer.substring(30, 34), radix: 16);
// Calculate the total expected length of the complete message (Header + Data Block + CRC)
int totalMessageLength = 34 + (dataBlockLength * 2) + 4; // 34 chars for header, dataBlockLength bytes * 2 chars/byte, 4 chars for CRC
// If the buffer contains a complete message
if (buffer.length >= totalMessageLength) {
_cancelTimeout(); // Message received, cancel the response timeout timer
String completeResponse = buffer.substring(0, totalMessageLength);
// --- CRC Validation ---
Uint8List responseBytes = Converter.hexStringToByteArray(completeResponse);
// CRC is calculated over the entire message *excluding* the final 2 CRC bytes themselves
Uint8List dataBytesForCrc = responseBytes.sublist(0, responseBytes.length - 2);
String calculatedCrc = Crc16Ccitt.computeCrc16Ccitt(dataBytesForCrc);
String receivedCrc = completeResponse.substring(completeResponse.length - 4); // Last 4 hex chars are the CRC
if (calculatedCrc.toUpperCase() != receivedCrc.toUpperCase()) {
debugPrint("SerialManager: CRC Mismatch! Calculated: $calculatedCrc, Received: $receivedCrc for response: $completeResponse. Discarding message.");
// Discard the invalid message from the buffer and attempt to process the rest
_responseBuffer.clear();
_responseBuffer.write(buffer.substring(totalMessageLength));
_isReading = false; // Consider ending the cycle or signaling an error that might require a retry.
if(_responseBuffer.isNotEmpty) _processBuffer(); // Check if more messages are in the buffer
return;
}
// Message is valid. Remove it from the buffer.
_responseBuffer.clear();
_responseBuffer.write(buffer.substring(totalMessageLength));
debugPrint("SerialManager: Processing Full Valid Response (Lvl: $_communicationLevel): $completeResponse");
_handleResponse(completeResponse); // Route the complete message for parsing
// Recursively call _processBuffer in case multiple messages arrived in one chunk,
// or a new message started immediately after the processed one.
if(_responseBuffer.isNotEmpty) _processBuffer();
}
} catch (e) {
debugPrint("SerialManager: Buffer processing error: $e. Resetting cycle and clearing buffer.");
_responseBuffer.clear();
_isReading = false; // End this cycle on error
}
}
/// Dispatches the complete and validated response string to the appropriate handler
/// based on the current communication level.
void _handleResponse(String response) {
// --- REVISED: Added null check before dispatching ---
if (_isDisposed) {
debugPrint("SerialManager: Ignoring response on disposed manager.");
return;
}
switch (_communicationLevel) {
case 0:
_handleResponseLevel0(response);
break;
case 1:
_handleResponseLevel1(response);
break;
case 2:
_handleResponseLevel2(response);
break;
default:
debugPrint("SerialManager: Unknown communication level: $_communicationLevel for response: $response");
_isReading = false; // Ensure reading lock is released if level is invalid
}
}
/// Handles and parses the response for communication Level 0.
/// Extracts parent address and serial number.
void _handleResponseLevel0(String responseHex) {
try {
// Minimum length check to ensure substrings are safe
// 34 (header) + 40 (data block for L0) + 4 (CRC) = 78 expected min length for full response
// It looks like your previous code assumed data block starts at 34 and contains 4 bytes (8 chars)
// for parent address at offset 86, and 9 bytes (18 chars) for serial at 68.
// Let's refine based on typical structure.
// If `responseHex.substring(68, 86)` and `responseHex.substring(86, 94)` are correct from your sensor,
// then total length for serial number (18) + parent address (8) + other header/footer must be respected.
// 18 chars for serial, 8 chars for parent. Total 26 chars / 2 = 13 bytes data length.
// So, total expected response length = 34 + (13*2) + 4 = 34 + 26 + 4 = 64.
// If your sensor gives 94 chars: 34 (header) + (94-34-4=56 data chars) + 4 (CRC) -> 28 bytes data.
// This means offsets 68-86 and 86-94 might be within a larger data block.
// This response example is for a specific sensor protocol. Adjust offsets if yours differs.
if (responseHex.length < 94) { // Assuming 94 is the correct full length including all L0 data.
debugPrint("SerialManager: Error: Level 0 response too short. Length: ${responseHex.length} (expected at least 94)");
_isReading = false;
return;
}
// Re-evaluate based on your specific protocol:
// The offsets 68-86 and 86-94 suggest a specific structure within the *data block* of the response.
// The `dataBlockLength` field (at 30-34) specifies the length of the data portion *after* the header.
// Let's assume these substrings are correct as per your sensor's documentation:
_parentAddress = responseHex.substring(86, 94); // Extract parent address (8 hex chars = 4 bytes)
_serialNumber = Converter.hexToAscii(responseHex.substring(68, 86)); // Extract serial number (18 hex chars = 9 bytes, then convert to ASCII)
debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber");
// --- Update sondeId ValueNotifier here ---
// --- REVISED: Added null check before updating ValueNotifier ---
if (!_isDisposed) {
if (_serialNumber != null && _serialNumber!.isNotEmpty) {
sondeId.value = _serialNumber;
debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}");
} else {
sondeId.value = "N/A"; // Or handle empty/null serial number appropriately
}
}
if (_parentAddress != "00000000") { // Check for valid parent address
_communicationLevel = 1; // Advance to Level 1
// Delay before sending next command to allow device time to process
Future.delayed(const Duration(milliseconds: 200), () => _sendCommand(1));
} else {
debugPrint("SerialManager: Parent address is 00000000, not proceeding to Level 1. Ending cycle.");
_isReading = false; // End the cycle if parent address is invalid
}
} catch (e) {
debugPrint("SerialManager: Error parsing L0: $e");
_isReading = false; // End this cycle on error
}
}
/// Handles and parses the response for communication Level 1.
/// Extracts the list of available parameters.
void _handleResponseLevel1(String responseHex) {
try {
if (responseHex.length < 34) {
debugPrint("SerialManager: Error: Level 1 response too short for dataBlockLength. Length: ${responseHex.length}");
_isReading = false;
return;
}
int dataBlockLength = int.parse(responseHex.substring(30, 34), radix: 16);
int expectedParamsBlockEnd = 34 + (dataBlockLength * 2);
if (expectedParamsBlockEnd > responseHex.length) {
debugPrint("SerialManager: Error: Level 1 parameters block calculated end index ($expectedParamsBlockEnd) exceeds received data length (${responseHex.length}). dataBlockLength was: $dataBlockLength (0x${dataBlockLength.toRadixString(16).padLeft(4, '0')})");
_isReading = false;
return;
}
String paramsBlock = responseHex.substring(34, expectedParamsBlockEnd);
_parameterList.clear();
// Each parameter entry is 6 hex characters (3 bytes).
// Parameter code (2 bytes) is typically at index 2-5 within each 6-char block.
// Example: '005A00' -> code '5A00'
for (int i = 0; i <= paramsBlock.length - 6; i += 6) {
String parameterCode = paramsBlock.substring(i + 2, i + 6);
_parameterList.add(ParameterHelper.getDescription(parameterCode));
}
debugPrint("SerialManager: Parsed L1 -> Parameters: $_parameterList");
_communicationLevel = 2; // Advance to Level 2
Future.delayed(const Duration(milliseconds: 200), () => _sendCommand(2));
} catch (e) {
debugPrint("SerialManager: Error parsing L1: $e");
_isReading = false;
}
}
/// Handles and parses the response for communication Level 2.
/// Extracts the actual values for the previously identified parameters.
void _handleResponseLevel2(String responseHex) {
try {
if (responseHex.length < 34) {
debugPrint("SerialManager: Error: Level 2 response too short for dataBlockLength. Length: ${responseHex.length}");
_isReading = false;
return;
}
int dataBlockLength = int.parse(responseHex.substring(30, 34), radix: 16);
int expectedValuesBlockEnd = 34 + (dataBlockLength * 2);
if (expectedValuesBlockEnd > responseHex.length) {
debugPrint("SerialManager: Error: Level 2 values block calculated end index ($expectedValuesBlockEnd) exceeds received data length (${responseHex.length}). dataBlockLength was: $dataBlockLength (0x${dataBlockLength.toRadixString(16).padLeft(4, '0')})");
_isReading = false;
return;
}
String valuesBlock = responseHex.substring(34, expectedValuesBlockEnd);
List<double> values = [];
// Each value is 8 hex characters (4 bytes) representing a float
for (int i = 0; i <= valuesBlock.length - 8; i += 8) {
String valueHex = valuesBlock.substring(i, i + 8);
try {
values.add(Converter.hexToFloat(valueHex));
} catch (e) {
debugPrint("SerialManager: Error converting hex '$valueHex' to float in Level 2: $e");
values.add(double.nan); // Add NaN or a default error value if conversion fails
}
}
// Ensure the number of parsed parameters matches the number of parsed values.
if (_parameterList.length == values.length) {
Map<String, double> finalReadings = Map.fromIterables(_parameterList, values);
// --- REVISED: Added null check before adding to the stream controller ---
if (!_isDisposed) {
_dataStreamController.add(finalReadings); // Broadcast the final parsed readings
}
debugPrint("SerialManager: Final Parsed Readings: $finalReadings");
} else {
debugPrint("SerialManager: L2 Data Mismatch: ${values.length} values for ${_parameterList.length} parameters. Parameter list: $_parameterList, Values: $values");
}
} catch (e) {
debugPrint("SerialManager: Error parsing L2: $e");
} finally {
_isReading = false; // Mark the read cycle as complete, allowing the next one to start
debugPrint("--- SerialManager: Read Cycle Complete ---");
}
}
/// Constructs and sends the appropriate command based on the current communication level.
void _sendCommand(int level) async {
// Generate a sequence number (0-255), padded to 2 hex characters.
String seqNo = (_runningCounter++ & 255).toRadixString(16).padLeft(2, '0').toUpperCase();
// Set the lookup string for the expected response. This is the start of the message
// that the `_processBuffer` will look for to identify a complete response.
_lookupString = '7E02${seqNo}'; // Assuming responses start with 7E02 and the same sequence number.
String commandBody;
switch (level) {
case 0:
commandBody = '0000000002000000200000010000'; // Command for Level 0
break;
case 1:
if (_parentAddress == null) {
debugPrint("SerialManager: Error: _parentAddress is null for Level 1 command. Cannot send. Ending cycle.");
_isReading = false; // Release the reading lock
// Call disconnect (now async void) without await here as we don't need to block
disconnect(); // Disconnect due to invalid state
return;
}
commandBody = '${_parentAddress}02000000200000180000'; // Command for Level 1, includes parent address
break;
case 2:
if (_parentAddress == null) {
debugPrint("SerialManager: Error: _parentAddress is null for Level 2 command. Cannot send. Ending cycle.");
_isReading = false; // Release the reading lock
// Call disconnect (now async void) without await here as we don't need to block
disconnect(); // Disconnect due to invalid state
return;
}
commandBody = '${_parentAddress}02000000200000190000'; // Command for Level 2, includes parent address
break;
default:
debugPrint("SerialManager: Attempted to send command for unknown level: $level. Resetting cycle.");
_isReading = false;
return;
}
String commandHex = '7E02$seqNo$commandBody'; // Full command without CRC
Uint8List commandBytes = Converter.hexStringToByteArray(commandHex); // Convert command hex to bytes
String crc = Crc16Ccitt.computeCrc16Ccitt(commandBytes); // Compute CRC for the command bytes
String finalPacket = commandHex + crc; // Append CRC to the command
try {
await _port?.write(Converter.hexStringToByteArray(finalPacket)); // Send the full packet over serial
debugPrint("SerialManager: Sent (Lvl: $level): $finalPacket (Expecting lookup: $_lookupString)");
// Set a timeout. If no valid response is received within this duration,
// it's assumed the command failed, and the reading cycle is reset.
_cancelTimeout(); // Cancel any previous timeout
_responseTimeoutTimer = Timer(const Duration(seconds: 3), () {
debugPrint("SerialManager: Response timeout for Level $level. Unlocking for next cycle.");
_isReading = false; // Allow the next read cycle to start
_responseBuffer.clear(); // Clear buffer on timeout to avoid stale or partial data confusing future reads
// Optionally, you might want to trigger a retry for the current level here
// or just let the auto-reading timer trigger the next full cycle.
});
} catch (e) {
debugPrint("SerialManager: Error sending command (Lvl: $level): $e. Disconnecting.");
_isReading = false; // Release the reading lock on send error
// Call disconnect (now async void) without await here as we don't need to block
disconnect(); // Disconnect if sending fails
}
}
/// Cancels the current response timeout timer if it's active.
void _cancelTimeout() {
_responseTimeoutTimer?.cancel();
_responseTimeoutTimer = null;
}
/// Cleans up all resources held by the SerialManager when it's no longer needed.
void dispose() {
debugPrint("SerialManager: Disposing.");
_isDisposed = true; // Set the flag immediately
disconnect(); // Ensure full disconnection and cleanup
_dataStreamController.close(); // Close the data stream controller
connectionState.dispose(); // Dispose the ValueNotifier
connectedDeviceName.dispose(); // Dispose the ValueNotifier
sondeId.dispose(); // Dispose the Sonde ID ValueNotifier
}
}