// 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 connectionState = ValueNotifier( SerialConnectionState.disconnected, ); // ValueNotifier to expose the connected device's name. final ValueNotifier connectedDeviceName = ValueNotifier(null); // --- ADDED: ValueNotifier for Sonde ID --- // This will be updated when the Sonde ID is parsed from Level 0 data. final ValueNotifier sondeId = ValueNotifier(null); // --- ADDED: Flag to prevent updates after disposal --- bool _isDisposed = false; // StreamController to broadcast parsed data readings to multiple listeners. final StreamController> _dataStreamController = StreamController>.broadcast(); Stream> 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 _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> getAvailableDevices() async { return UsbSerial.listDevices(); } /// Connects to the specified USB device. /// Handles opening the port, setting parameters, and starting the input stream listener. Future 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, 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 and added async. Future disconnect() async { if (connectionState.value != SerialConnectionState.disconnected) { debugPrint("SerialManager: Disconnecting..."); stopAutoReading(); // Stop any active auto-reading timer and timeout // FIX: Update ValueNotifiers before closing the stream and port. connectionState.value = SerialConnectionState.disconnected; connectedDeviceName.value = null; sondeId.value = null; // Clear Sonde ID on disconnect await _port?.close(); // Now correctly awaiting the close operation _port = null; // Clear port reference _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 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 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 // FIX: Dispose of all ValueNotifiers to prevent "used after dispose" errors connectionState.dispose(); connectedDeviceName.dispose(); sondeId.dispose(); } }