bluetooth.js

/**
 * BluetoothConnection - Handles Web Bluetooth connections to GNSS receivers
 */
export class BluetoothConnection {
  constructor(eventEmitter, options = {}) {
    this.eventEmitter = eventEmitter;
    this.device = null;
    this.server = null;
    this.serialService = null;
    this.rxCharacteristic = null;
    this.txCharacteristic = null;
    this.isConnected = false;
    this.isConnecting = false;
    this.autoReconnect = false;
    this.connectionTimeout = 10000; // 10 seconds
    this.pollingEnabled = false;
    this.pollingInterval = null;
    
    // Debug settings
    this.debug = options.debug || { 
      info: false,
      debug: false,
      errors: true,
      parsedSentences: false,
      rtcmMessages: false
    };
    
    // Set up logger functions
    this.logger = {
      info: (...args) => {
        if (this.debug.info) {
          console.info('[BT-INFO]', ...args);
        }
      },
      debug: (...args) => {
        if (this.debug.debug) {
          console.debug('[BT-DEBUG]', ...args);
        }
      },
      error: (...args) => {
        if (this.debug.errors) {
          console.error('[BT-ERROR]', ...args);
        }
      },
      warn: (...args) => {
        if (this.debug.errors) {
          console.warn('[BT-WARN]', ...args);
        }
      }
    };
    
    // Common BLE UART/Serial service UUIDs
    // Prioritizing BLE services first since the Sparkfun Facet RTK Rover uses BLE
    this.SERVICE_UUIDS = [
      // BLE UART Services (most common)
      '6e400001-b5a3-f393-e0a9-e50e24dcca9e', // Nordic UART Service (nRF51822, very common in BLE devices)
      '0000ffe0-0000-1000-8000-00805f9b34fb', // Nordic UART Service (alternate form)
      '49535343-fe7d-4ae5-8fa9-9fafd205e455', // Common HM-10/HM-16/HM-17 BLE Module Service
      '0000fff0-0000-1000-8000-00805f9b34fb', // Common HC-08/HC-10 BLE Service
      
      // Generic BLE services that might be useful
      '00001801-0000-1000-8000-00805f9b34fb', // Generic Attribute Service
      '00001800-0000-1000-8000-00805f9b34fb', // Generic Access Service
      
      // SparkFun RTK-specific services (if they use custom services)
      '0000fe9a-0000-1000-8000-00805f9b34fb',  // Possible custom service
      
      // Legacy Classic Bluetooth services (less likely on BLE devices)
      '00001101-0000-1000-8000-00805f9b34fb', // SPP (Serial Port Profile) - Classic Bluetooth
      
      // Testing/fallback services
      '0000180d-0000-1000-8000-00805f9b34fb'  // Heart Rate Service (for testing)
    ];
    
    // Primary service (the first in our list)
    this.PRIMARY_SERVICE_UUID = this.SERVICE_UUIDS[0];
    
    // Common BLE characteristic UUIDs
    // Nordic UART (nRF UART) characteristic UUIDs - very common in BLE devices
    this.NORDIC_TX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; // TX (device transmits to phone)
    this.NORDIC_RX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; // RX (phone transmits to device)
    
    // Common alternate BLE characteristic UUIDs
    this.BLE_TX_UUIDS = [
      // Nordic UART variants
      '6e400002-b5a3-f393-e0a9-e50e24dcca9e', // Nordic TX
      '0000ffe1-0000-1000-8000-00805f9b34fb', // Common BLE TX
      '0000fff1-0000-1000-8000-00805f9b34fb', // HC-08 TX
      '49535343-8841-43f4-a8d4-ecbe34729bb3', // HM-10 TX
      // SparkFun might use a custom characteristic
      '0000fe9a-0002-1000-8000-00805f9b34fb'  // Possible custom TX
    ];
    
    this.BLE_RX_UUIDS = [
      // Nordic UART variants
      '6e400003-b5a3-f393-e0a9-e50e24dcca9e', // Nordic RX
      '0000ffe2-0000-1000-8000-00805f9b34fb', // Common BLE RX
      '0000fff2-0000-1000-8000-00805f9b34fb', // HC-08 RX
      '49535343-1e4d-4bd9-ba61-23c647249616', // HM-10 RX
      // SparkFun might use a custom characteristic
      '0000fe9a-0003-1000-8000-00805f9b34fb'  // Possible custom RX
    ];
    
    // Legacy names for backward compatibility 
    this.SPP_SERVICE_UUID = '00001101-0000-1000-8000-00805f9b34fb';
    this.SPP_RX_UUID = '00001102-0000-1000-8000-00805f9b34fb';
    this.SPP_TX_UUID = '00001103-0000-1000-8000-00805f9b34fb';
    
    // Bind methods
    this.onDisconnected = this.onDisconnected.bind(this);
  }

  /**
   * Connect to a GNSS receiver
   * @param {Object} options - Connection options
   * @returns {Promise<boolean>} Whether connection was successful
   */
  async connect(options = {}) {
    if (this.isConnected) {
      return true;
    }
    
    if (this.isConnecting) {
      return false;
    }
    
    this.isConnecting = true;
    this.eventEmitter.emit('bluetooth:connecting', {});
    
    try {
      // Browser compatibility check
      if (!navigator.bluetooth) {
        throw new Error('Web Bluetooth API is not supported in this browser');
      }
      
      // Request device with optional filters
      const requestOptions = {
        acceptAllDevices: !options.filters,
        filters: options.filters || [],
        optionalServices: [this.SPP_SERVICE_UUID]
      };
      
      // Allow connecting to last device
      if (options.deviceId) {
        try {
          this.device = await navigator.bluetooth.getDevices()
            .then(devices => devices.find(d => d.id === options.deviceId));
            
          if (!this.device) {
            throw new Error('Device not found');
          }
        } catch (error) {
          console.warn('Failed to reconnect to known device:', error);
          // Fall back to device picker
          this.device = await navigator.bluetooth.requestDevice(requestOptions);
        }
      } else {
        // Show the device picker
        this.device = await navigator.bluetooth.requestDevice(requestOptions);
      }
      
      if (!this.device) {
        throw new Error('No device selected');
      }
      
      // Set up disconnection listener
      this.device.addEventListener('gattserverdisconnected', this.onDisconnected);
      
      // Connect to GATT server
      this.eventEmitter.emit('bluetooth:connecting', { step: 'gatt' });
      this.server = await this.device.gatt.connect();
      
      // Get a suitable UART/Serial service
      this.eventEmitter.emit('bluetooth:connecting', { step: 'service' });
      
      // First, get all services to see what's available
      const allServices = await this.server.getPrimaryServices();
      console.log('All available services:', allServices.map(s => s.uuid));
      
      if (allServices.length === 0) {
        throw new Error('No Bluetooth services found on this device');
      }
      
      // Then try to find one of our known UART services
      let foundService = null;
      let serviceUUID = '';
      
      // Try each of our known service UUIDs
      // BLE devices sometimes use shortened 16-bit UUIDs
      for (const uuid of this.SERVICE_UUIDS) {
        try {
          console.log(`Trying to connect to service: ${uuid}`);
          const service = await this.server.getPrimaryService(uuid);
          if (service) {
            foundService = service;
            serviceUUID = uuid;
            console.log(`Successfully connected to service: ${uuid}`);
            break;
          }
        } catch (e) {
          console.log(`Service ${uuid} not found on device`);
          
          // Try to convert to 16-bit UUID if it might be a standard BLE UUID
          try {
            // For standard 16-bit UUIDs, like "180d" for Heart Rate
            // Extract the 16-bit part if it's a full UUID
            if (uuid.includes('-')) {
              const shortUuid = uuid.split('-')[0].replace('0000', '');
              if (shortUuid.length === 4) {
                console.log(`Trying 16-bit service UUID: ${shortUuid}`);
                const service = await this.server.getPrimaryService(shortUuid);
                if (service) {
                  foundService = service;
                  serviceUUID = shortUuid;
                  console.log(`Successfully connected to 16-bit service: ${shortUuid}`);
                  break;
                }
              }
            }
          } catch (e2) {
            console.log(`16-bit service ID not found either`);
          }
          // Continue to next service UUID
        }
      }
      
      // If we couldn't find a known service, try to use any available service that has characteristics
      if (!foundService) {
        console.warn('Could not find any known UART/Serial services');
        
        // Try each service to find one with suitable characteristics
        for (const service of allServices) {
          try {
            const characteristics = await service.getCharacteristics();
            console.log(`Service ${service.uuid} has ${characteristics.length} characteristics`);
            
            if (characteristics.length > 0) {
              foundService = service;
              serviceUUID = service.uuid;
              console.log(`Using service ${service.uuid} with ${characteristics.length} characteristics`);
              break;
            }
          } catch (e) {
            console.log(`Could not get characteristics for service ${service.uuid}`);
          }
        }
      }
      
      if (!foundService) {
        throw new Error('Could not find a suitable Bluetooth service for communication');
      }
      
      this.serialService = foundService;
      console.log(`Using service: ${serviceUUID}`);
      this.eventEmitter.emit('bluetooth:connecting', { step: 'service-found', serviceUUID });
      
      // Get characteristics
      this.eventEmitter.emit('bluetooth:connecting', { step: 'characteristics' });
      
      // Try to get TX and RX characteristics using a more flexible approach
      // First, get all characteristics from the service
      console.log('Getting characteristics from service:', this.serialService.uuid);
      let characteristics = [];
      try {
        characteristics = await this.serialService.getCharacteristics();
        console.log('All characteristics:', characteristics.map(c => ({
          uuid: c.uuid,
          properties: Object.keys(c.properties).filter(p => c.properties[p])
        })));
      } catch (error) {
        console.error(`Error getting characteristics: ${error.message}`);
        this.eventEmitter.emit('bluetooth:error', {
          message: `Failed to get characteristics: ${error.message}`,
          error
        });
        throw error;
      }
      
      if (characteristics.length === 0) {
        throw new Error('No characteristics found in the selected service');
      }
      
      // Try with known BLE characteristic pairs first
      // Creating all possible combinations of TX and RX characteristics
      const knownCharacteristicPairs = [];
      
      // Nordic UART pair (most common for BLE UART)
      knownCharacteristicPairs.push({ 
        rx: this.NORDIC_TX_UUID, // The TX characteristic from device
        tx: this.NORDIC_RX_UUID  // The RX characteristic to device
      });
      
      // Try all combinations of TX/RX pairs from our lists
      for (const txUuid of this.BLE_TX_UUIDS) {
        for (const rxUuid of this.BLE_RX_UUIDS) {
          // Skip if it's the same as Nordic pair we already added
          if (txUuid === this.NORDIC_TX_UUID && rxUuid === this.NORDIC_RX_UUID) {
            continue;
          }
          knownCharacteristicPairs.push({ rx: txUuid, tx: rxUuid });
        }
      }
      
      // Some devices use the same characteristic for both TX and RX
      for (const txUuid of this.BLE_TX_UUIDS) {
        knownCharacteristicPairs.push({ rx: txUuid, tx: txUuid });
      }
      
      // Add legacy SPP pair at the end
      knownCharacteristicPairs.push({ rx: this.SPP_RX_UUID, tx: this.SPP_TX_UUID });
      
      // Try each known pair
      for (const pair of knownCharacteristicPairs) {
        try {
          console.log(`Trying RX=${pair.rx}, TX=${pair.tx}`);
          const rx = await this.serialService.getCharacteristic(pair.rx);
          const tx = await this.serialService.getCharacteristic(pair.tx);
          
          if (rx && tx) {
            this.rxCharacteristic = rx;
            this.txCharacteristic = tx;
            console.log(`Found matching RX/TX pair: RX=${pair.rx}, TX=${pair.tx}`);
            break;
          }
        } catch (e) {
          console.log(`Characteristic pair not found: ${e.message}`);
          // Continue to next pair
        }
      }
      
      // If we couldn't find a known pair, try to detect based on properties
      if (!this.rxCharacteristic || !this.txCharacteristic) {
        console.log('No known characteristic pair found, detecting based on properties');
        
        // Look for characteristics with the right properties for RX/TX
        // RX: needs notify (for receiving data from device)
        // TX: needs write (for sending data to device)
        const notifyChars = characteristics.filter(char => char.properties.notify);
        const writeChars = characteristics.filter(char => char.properties.write || char.properties.writeWithoutResponse);
        
        console.log(`Found ${notifyChars.length} notify characteristics and ${writeChars.length} write characteristics`);
        
        if (notifyChars.length > 0) {
          this.rxCharacteristic = notifyChars[0];
          console.log(`Using RX characteristic with UUID: ${this.rxCharacteristic.uuid}`);
        } else if (characteristics.length > 0) {
          // Fallback to first characteristic if no notify characteristics
          this.rxCharacteristic = characteristics[0];
          console.log(`Using fallback RX characteristic with UUID: ${this.rxCharacteristic.uuid}`);
        }
        
        if (writeChars.length > 0) {
          this.txCharacteristic = writeChars[0];
          console.log(`Using TX characteristic with UUID: ${this.txCharacteristic.uuid}`);
        } else if (characteristics.length > 1) {
          // Fallback to second characteristic if no write characteristics
          this.txCharacteristic = characteristics[1] || characteristics[0];
          console.log(`Using fallback TX characteristic with UUID: ${this.txCharacteristic.uuid}`);
        } else if (characteristics.length === 1) {
          // If only one characteristic, use it for both TX and RX
          this.txCharacteristic = characteristics[0];
          console.log(`Using single characteristic for both RX and TX: ${this.txCharacteristic.uuid}`);
        }
      }
      
      // Verify we have both characteristics
      if (!this.rxCharacteristic) {
        throw new Error('Could not find a suitable RX characteristic');
      }
      
      if (!this.txCharacteristic) {
        throw new Error('Could not find a suitable TX characteristic');
      }
      
      // Try to start notifications for incoming data
      // But make this optional - some devices might use a polling approach instead
      try {
        console.log(`Starting notifications on RX characteristic: ${this.rxCharacteristic.uuid}`);
        console.log(`RX characteristic properties:`, Object.keys(this.rxCharacteristic.properties).filter(p => this.rxCharacteristic.properties[p]));
        
        // Only attempt to start notifications if the characteristic supports it
        if (this.rxCharacteristic.properties.notify) {
          await this.rxCharacteristic.startNotifications();
          this.rxCharacteristic.addEventListener('characteristicvaluechanged', 
            (event) => this.handleIncomingData(event));
          console.log('Notifications started successfully');
        } else {
          console.log('RX characteristic does not support notifications, will use polling instead');
          // Set up polling for devices that don't support notifications
          this.pollingEnabled = true;
          this.pollingInterval = setInterval(async () => {
            try {
              // Read the characteristic value manually
              if (this.isConnected && this.rxCharacteristic) {
                const value = await this.rxCharacteristic.readValue();
                // Only process if there's actual data
                if (value && value.byteLength > 0) {
                  this.handleIncomingData({ target: { value } });
                }
              }
            } catch (e) {
              console.warn('Error polling characteristic:', e);
            }
          }, 500); // Poll every 500ms
        }
      } catch (error) {
        console.error(`Error setting up data reception: ${error.message}`);
        // This is not a fatal error, we can try a different approach
        this.eventEmitter.emit('bluetooth:warning', {
          message: `Could not set up data reception: ${error.message}, will try polling`,
          error
        });
        
        // Set up polling as a fallback
        this.pollingEnabled = true;
        this.pollingInterval = setInterval(async () => {
          try {
            // Read the characteristic value manually
            if (this.isConnected && this.rxCharacteristic) {
              const value = await this.rxCharacteristic.readValue();
              // Only process if there's actual data
              if (value && value.byteLength > 0) {
                this.handleIncomingData({ target: { value } });
              }
            }
          } catch (e) {
            console.warn('Error polling characteristic:', e);
          }
        }, 500); // Poll every 500ms
      }
      
      this.isConnected = true;
      this.isConnecting = false;
      this.autoReconnect = options.autoReconnect || false;
      
      this.eventEmitter.emit('bluetooth:connected', {
        deviceId: this.device.id,
        deviceName: this.device.name
      });
      
      return true;
    } catch (error) {
      this.isConnecting = false;
      this.eventEmitter.emit('bluetooth:error', {
        message: error.message,
        error
      });
      
      return false;
    }
  }

  /**
   * Disconnect from the device
   */
  async disconnect() {
    if (!this.isConnected || !this.device) {
      return;
    }
    
    try {
      this.autoReconnect = false;
      
      // Clean up notifications or polling
      if (this.rxCharacteristic && this.rxCharacteristic.properties.notify) {
        await this.rxCharacteristic.stopNotifications();
      }
      
      // Clear polling interval if it was used
      if (this.pollingEnabled && this.pollingInterval) {
        clearInterval(this.pollingInterval);
        this.pollingInterval = null;
        this.pollingEnabled = false;
      }
      
      if (this.device.gatt.connected) {
        await this.device.gatt.disconnect();
      }
      
      this.isConnected = false;
      this.eventEmitter.emit('bluetooth:disconnected', {
        deviceId: this.device.id,
        deviceName: this.device.name
      });
    } catch (error) {
      console.error('Error during disconnect:', error);
      this.eventEmitter.emit('bluetooth:error', {
        message: 'Failed to disconnect properly',
        error
      });
    }
  }

  /**
   * Handle device disconnection
   */
  async onDisconnected() {
    const wasConnected = this.isConnected;
    this.isConnected = false;
    
    // Clear polling interval if it was used
    if (this.pollingEnabled && this.pollingInterval) {
      clearInterval(this.pollingInterval);
      this.pollingInterval = null;
      this.pollingEnabled = false;
    }
    
    this.server = null;
    this.serialService = null;
    this.rxCharacteristic = null;
    this.txCharacteristic = null;
    
    if (wasConnected) {
      this.eventEmitter.emit('bluetooth:disconnected', {
        deviceId: this.device?.id,
        deviceName: this.device?.name
      });
      
      // Attempt automatic reconnection if enabled
      if (this.autoReconnect && this.device) {
        setTimeout(() => {
          this.connect({ deviceId: this.device.id, autoReconnect: true });
        }, 1000);
      }
    }
  }

  /**
   * Handle incoming data from the device
   * @param {Event} event - Characteristic value changed event
   */
  handleIncomingData(event) {
    try {
      const value = event.target.value;
      const data = value.buffer;
      
      // Convert ArrayBuffer to string for debugging
      const textDecoder = new TextDecoder('utf-8');
      const dataString = textDecoder.decode(new Uint8Array(data));
      
      // Generate a hex representation for debugging
      const hexValues = Array.from(new Uint8Array(data))
        .map(b => b.toString(16).padStart(2, '0'))
        .join(' ');
      
      // Check for empty or extremely short data
      if (dataString.length < 3) {
        console.log(`Received very short data (${dataString.length} bytes), likely not NMEA. Skipping.`);
        return;
      }
      
      // console.log('Received data:', dataString);
      // console.log('Hex representation:', hexValues);
      
      // More comprehensive detection of NMEA data
      const isNmea = dataString.includes('$');
      const containsCrLf = dataString.includes('\r\n');
      const hasNmeaSentence = /\$(GP|GL|GA|GB|GN)[A-Z]{3},/.test(dataString);
      
      // console.log(`Data analysis - NMEA format: ${isNmea}, Contains CR/LF: ${containsCrLf}, Valid NMEA sentence: ${hasNmeaSentence}`);
      
      // Skip data processing if:
      // 1. This appears to be just the device name repeating
      // 2. The device name has already been reported many times
      if (this.lastDataString === dataString && !isNmea) {
        console.log('Skipping duplicate data (device name)');
        return;
      }
      
      // Remember the last string to detect repeats
      this.lastDataString = dataString;
      
      // Handle potential configuration messages (responses to our PUBX commands)
      if (dataString.includes('PUBX') && !this.configurationResponded) {
        console.log('Detected configuration response from device:', dataString);
        this.configurationResponded = true;
        
        // Try to enable GGA messages again immediately after receiving a response
        setTimeout(async () => {
          try {
            await this.sendData('$PUBX,40,GGA,1,1,1,1,1,0*5A\r\n');
            console.log('Resent GGA enable command after receiving PUBX response');
          } catch (e) {
            console.warn('Failed to send follow-up command:', e);
          }
        }, 300);
        
        // Try to detect if this includes error information
        if (dataString.toLowerCase().includes('error') || dataString.includes('ERR')) {
          console.warn('Device responded with error to configuration commands');
        } else {
          console.log('Device appears to have accepted configuration');
        }
      }
      
      // Look for UBX binary protocol responses
      if (hexValues.startsWith('b5 62')) {
        console.log('Detected UBX binary protocol response');
        
        // Parse UBX message class and ID
        if (hexValues.length >= 10) {
          const msgClass = parseInt(hexValues.split(' ')[2], 16);
          const msgId = parseInt(hexValues.split(' ')[3], 16);
          console.log(`UBX message class: 0x${msgClass.toString(16)}, ID: 0x${msgId.toString(16)}`);
        }
      }
      
      // Sometimes RMC/GGA sentences are actually present but in unusual format
      // Try to extract them even if not perfectly formatted
      if (isNmea && !hasNmeaSentence) {
        // Look for position data with regex pattern matching
        const extractedPosition = dataString.match(/(\d{2})(\d{2}\.\d+),([NS]),(\d{3})(\d{2}\.\d+),([EW])/);
        if (extractedPosition) {
          console.log('Found position data in non-standard format:', extractedPosition[0]);
          // Consider constructing a valid NMEA sentence here if needed
        }
      }
      
      // Emit a raw data event with the string, before any parsing
      this.eventEmitter.emit('bluetooth:raw-data', dataString);
      
      // Only emit for parsing if this looks like NMEA data
      if (isNmea) {
        // Emit the binary data for parsing
        this.eventEmitter.emit('bluetooth:data', data);
      }
    } catch (error) {
      console.error('Error handling incoming data:', error);
    }
  }

  /**
   * Send data to the device
   * @param {string|ArrayBuffer} data - Data to send
   * @returns {Promise<boolean>} Whether send was successful
   */
  async sendData(data) {
    if (!this.isConnected || !this.txCharacteristic) {
      console.error('Cannot send data: device not connected or TX characteristic not available');
      return false;
    }
    
    try {
      // Convert string to ArrayBuffer if needed
      let buffer;
      if (typeof data === 'string') {
        if (this.debug.debug) {
          console.log('Sending string data:', data);
        }
        buffer = new TextEncoder().encode(data);
      } else if (data instanceof ArrayBuffer) {
        const textDecoder = new TextDecoder('utf-8');
        const dataString = textDecoder.decode(new Uint8Array(data));
        if (this.debug.debug) {
          console.log('Sending binary data:', dataString);
        }
        buffer = data;
      } else {
        throw new Error('Invalid data type. Expected string or ArrayBuffer');
      }
      
      // When using Nordic UART Service (which is typical for many GNSS devices):
      // The write characteristic might need writeWithoutResponse
      if (this.txCharacteristic.uuid === '6e400002-b5a3-f393-e0a9-e50e24dcca9e') {
        if (this.debug.debug) {
          console.log('Using writeWithoutResponse for Nordic UART TX');
        }
        
        // Break data into smaller chunks (20 bytes max) to avoid overflow
        const MAX_CHUNK_SIZE = 20;
        const dataArray = new Uint8Array(buffer);
        
        for (let i = 0; i < dataArray.length; i += MAX_CHUNK_SIZE) {
          const chunk = dataArray.slice(i, Math.min(i + MAX_CHUNK_SIZE, dataArray.length));
          // Use writeValueWithoutResponse for Nordic UART
          await this.txCharacteristic.writeValueWithoutResponse(chunk);
          
          // Small delay between chunks
          await new Promise(resolve => setTimeout(resolve, 50));
        }
      } 
      // Otherwise try standard methods
      else {
        // Determine the write type based on the characteristic properties
        let writeOptions = {};
        if (this.txCharacteristic.properties.writeWithoutResponse) {
          if (this.debug.debug) {
            console.log('Using writeWithoutResponse');
          }
          writeOptions = { type: 'writeWithoutResponse' };
        } else {
          if (this.debug.debug) {
            console.log('Using standard write');
          }
        }
        
        await this.txCharacteristic.writeValue(buffer, writeOptions);
      }
      
      console.log('Data sent successfully');
      return true;
    } catch (error) {
      console.error('Failed to send data:', error);
      this.eventEmitter.emit('bluetooth:warning', {
        message: `Failed to send data: ${error.message} - continuing operation`,
        error
      });
      // Return true even if sending fails - we want to continue trying to receive data
      // This helps if the device is in a mode where it's sending but not receiving
      return true; 
    }
  }

  /**
   * Send a command to configure the device
   * @param {string} command - Command string
   * @returns {Promise<boolean>} Whether command was sent successfully
   */
  async sendCommand(command) {
    // Ensure command ends with proper line ending
    if (!command.endsWith('\r\n')) {
      command += '\r\n';
    }
    
    return this.sendData(command);
  }

  /**
   * Check if device is connected
   * @returns {boolean} Whether device is connected
   */
  isDeviceConnected() {
    return this.isConnected && this.device && this.device.gatt.connected;
  }
  
  /**
   * Special connection method for SparkFun Facet RTK Rover
   * @param {Object} options - Connection options
   * @returns {Promise<boolean>} Whether connection was successful
   */
  async connectToSparkFunFacet(options = {}) {
    if (this.isConnected) {
      return true;
    }
    
    if (this.isConnecting) {
      return false;
    }
    
    this.isConnecting = true;
    this.eventEmitter.emit('bluetooth:connecting', { type: 'sparkfun' });
    
    try {
      // Browser compatibility check
      if (!navigator.bluetooth) {
        throw new Error('Web Bluetooth API is not supported in this browser');
      }
      
      // Create a broader list of optionalServices to improve our chances
      // of finding the correct one for the SparkFun device
      
      // Create a comprehensive list of possible BLE UART services - use only full UUIDs
      const allPossibleServices = [
        // The specific known SparkFun service (this may vary by device model)
        '0000ffe0-0000-1000-8000-00805f9b34fb', // Common for HC-05/06/08 modules
        
        // Common UART services in priority order
        '6e400001-b5a3-f393-e0a9-e50e24dcca9e', // Nordic UART Service
        '49535343-fe7d-4ae5-8fa9-9fafd205e455', // HM-10 Service
        '0000fff0-0000-1000-8000-00805f9b34fb', // Alternative UART Service
        
        // Generic services
        '00001800-0000-1000-8000-00805f9b34fb', // Generic Access
        '00001801-0000-1000-8000-00805f9b34fb', // Generic Attribute
        
        // Add all other service UUIDs we know about
        ...this.SERVICE_UUIDS,
        
        // Try some common UUID patterns with different bases
        '0000180a-0000-1000-8000-00805f9b34fb', // Device Information
        '00001809-0000-1000-8000-00805f9b34fb', // Health Thermometer
        '0000180d-0000-1000-8000-00805f9b34fb', // Heart Rate
        '0000180f-0000-1000-8000-00805f9b34fb', // Battery Service
        '0000181a-0000-1000-8000-00805f9b34fb'  // Environmental Sensing
      ];
      
      // Request device with Facet specific filters
      const requestOptions = {
        filters: [
          { namePrefix: 'Facet' },
          { namePrefix: 'SparkFun' },
          { namePrefix: 'RTK' }
        ],
        optionalServices: allPossibleServices
      };
      
      // Show device picker
      console.log('Requesting SparkFun Facet RTK device...');
      this.device = await navigator.bluetooth.requestDevice(requestOptions);
      
      if (!this.device) {
        throw new Error('No device selected');
      }
      
      // Set up disconnection listener
      this.device.addEventListener('gattserverdisconnected', this.onDisconnected);
      
      // Connect to GATT server
      console.log('Connecting to GATT server...');
      this.server = await this.device.gatt.connect();
      
      // Get all available services
      console.log('Getting all services...');
      const allServices = await this.server.getPrimaryServices();
      
      if (allServices.length === 0) {
        throw new Error('No services found on device');
      }
      
      console.log('Available services:', allServices.map(s => s.uuid));
      
      // Find a service we can use for communication
      // Create a priority list of services to try - use only full UUIDs
      const priorityServices = [
        // First priority: Known UART/Serial services
        '0000ffe0-0000-1000-8000-00805f9b34fb', // BLE UART (HC-05/06/08)
        '0000ffe5-0000-1000-8000-00805f9b34fb', // BLE Data Service
        '6e400001-b5a3-f393-e0a9-e50e24dcca9e', // Nordic UART
        '49535343-fe7d-4ae5-8fa9-9fafd205e455', // HM-10/16/17
        '0000fff0-0000-1000-8000-00805f9b34fb', // HC-08/10 Alternative
        
        // Generic services - lowest priority
        '00001800-0000-1000-8000-00805f9b34fb', // Generic Access 
        '00001801-0000-1000-8000-00805f9b34fb'  // Generic Attribute
      ];
      
      // Log all available services for debugging
      console.log('Available services on device:');
      allServices.forEach(service => {
        console.log(`- ${service.uuid}`);
      });
      
      // Try each service in priority order
      for (const serviceId of priorityServices) {
        const service = allServices.find(s => s.uuid === serviceId);
        if (service) {
          console.log(`Found priority service: ${serviceId}`);
          this.serialService = service;
          break;
        }
      }
      
      // If we still don't have a service, try all services
      if (!this.serialService) {
        console.log('No priority service found, trying all services...');
        
        // Try each service and see if it has suitable characteristics
        for (const service of allServices) {
          try {
            const chars = await service.getCharacteristics();
            console.log(`Service ${service.uuid} has ${chars.length} characteristics`);
            
            // Look for characteristics with read property (needed for data reception)
            const readChar = chars.find(c => c.properties.read);
            if (readChar) {
              console.log(`Found service with readable characteristic: ${service.uuid}`);
              this.serialService = service;
              break;
            }
          } catch (e) {
            console.warn(`Error checking characteristics for service ${service.uuid}:`, e);
          }
        }
        
        // Last resort: use the first service
        if (!this.serialService && allServices.length > 0) {
          console.log('Using first available service as last resort');
          this.serialService = allServices[0];
        }
      }
      
      if (!this.serialService) {
        throw new Error('Could not find a suitable service');
      }
      
      console.log('Using service:', this.serialService.uuid);
      
      // Get all characteristics from this service
      console.log('Getting characteristics...');
      const characteristics = await this.serialService.getCharacteristics();
      
      if (characteristics.length === 0) {
        throw new Error('No characteristics found');
      }
      
      console.log('Available characteristics:', characteristics.map(c => ({
        uuid: c.uuid,
        properties: Object.keys(c.properties).filter(p => c.properties[p])
      })));
      
      // For SparkFun devices, we'll prioritize characteristics with read/write permissions
      this.rxCharacteristic = characteristics.find(char => char.properties.read);
      this.txCharacteristic = characteristics.find(char => char.properties.write || char.properties.writeWithoutResponse);
      
      // If we couldn't find a read characteristic, use the first available
      if (!this.rxCharacteristic && characteristics.length > 0) {
        this.rxCharacteristic = characteristics[0];
      }
      
      // If we couldn't find a write characteristic, use the second available or the first one
      if (!this.txCharacteristic && characteristics.length > 1) {
        this.txCharacteristic = characteristics[1];
      } else if (!this.txCharacteristic) {
        this.txCharacteristic = characteristics[0];
      }
      
      console.log('Using RX characteristic:', this.rxCharacteristic.uuid);
      console.log('Using TX characteristic:', this.txCharacteristic.uuid);
      
      // Set up enhanced polling for SparkFun device with better error recovery
      console.log('Setting up enhanced polling for SparkFun device...');
      this.pollingEnabled = true;
      
      // For some devices, we need to try multiple characteristics
      // Get all characteristics from this service
      let allCharacteristics = [];
      try {
        // Get all characteristics for potential polling targets
        allCharacteristics = await this.serialService.getCharacteristics();
        console.log(`Found ${allCharacteristics.length} characteristics to potentially poll`);
      } catch (e) {
        console.warn('Error getting all characteristics:', e);
      }
      
      // Track failure count to potentially switch characteristics
      let failureCount = 0;
      const MAX_FAILURES = 3;
      let currentCharIndex = 0;
      let dataReceived = false;
      let startupDelay = true;
      
      // Give device time to initialize before starting polling
      setTimeout(() => {
        startupDelay = false;
        console.log('Starting data polling after initialization delay');
      }, 3000);
      
      // Use a more robust polling approach with two methods
      this.pollingInterval = setInterval(async () => {
        // Skip polling during startup delay
        if (startupDelay) {
          return;
        }
        
        try {
          // Try notification method first (preferred)
          if (this.isConnected && this.rxCharacteristic && this.rxCharacteristic.properties.notify) {
            if (!this.rxCharacteristic._hasStartedNotifications) {
              try {
                await this.rxCharacteristic.startNotifications();
                this.rxCharacteristic._hasStartedNotifications = true;
                console.log('Successfully started notifications on characteristic');
                
                // Add event listener for notifications
                this.rxCharacteristic.addEventListener('characteristicvaluechanged', 
                  (event) => this.handleIncomingData(event));
              } catch (notifyError) {
                console.warn('Failed to start notifications, falling back to polling:', notifyError);
              }
            }
          }
          
          // Also use direct readValue as backup or alternative
          if (this.isConnected && this.rxCharacteristic) {
            const value = await this.rxCharacteristic.readValue();
            // Only process if there's actual data
            if (value && value.byteLength > 0) {
              this.handleIncomingData({ target: { value } });
              
              // Track successful data receipt
              if (!dataReceived) {
                dataReceived = true;
                console.log('Successfully receiving data from device');
              }
              
              failureCount = 0; // Reset failure count on success
            } else {
              // Empty value could be a soft failure
              failureCount++;
            }
          }
        } catch (e) {
          console.warn('Error polling primary characteristic:', e);
          failureCount++;
          
          // If we've had multiple failures, try switching to another characteristic
          if (failureCount >= MAX_FAILURES && allCharacteristics.length > 1) {
            failureCount = 0;
            currentCharIndex = (currentCharIndex + 1) % allCharacteristics.length;
            
            // Try using a different characteristic
            const nextChar = allCharacteristics[currentCharIndex];
            if (nextChar && nextChar !== this.rxCharacteristic) {
              console.log(`Switching to alternative characteristic: ${nextChar.uuid}`);
              this.rxCharacteristic = nextChar;
              
              // Reset notification tracking for this characteristic
              this.rxCharacteristic._hasStartedNotifications = false;
              
              // Try to start notifications for the new characteristic
              try {
                await this.rxCharacteristic.startNotifications();
                this.rxCharacteristic._hasStartedNotifications = true;
                console.log('Successfully started notifications on new characteristic');
                
                // Add event listener for notifications on the new characteristic
                this.rxCharacteristic.addEventListener('characteristicvaluechanged', 
                  (event) => this.handleIncomingData(event));
                
                // Try to enable GGA messages again with the new characteristic
                setTimeout(async () => {
                  try {
                    // Send specific command with this new characteristic
                    await this.sendData('$PUBX,40,GGA,1,1,1,1,1,0*5A\r\n');
                    console.log('Resent GGA enable command on new characteristic');
                  } catch (e) {
                    console.warn('Failed to send command on new characteristic:', e);
                  }
                }, 500);
              } catch (notifyError) {
                console.warn('Failed to start notifications on new characteristic, continuing with polling');
              }
            }
          }
        }
      }, 250); // Poll more frequently (125ms) for better responsiveness
      
      // Device is now connected
      this.isConnected = true;
      this.isConnecting = false;
      this.autoReconnect = options.autoReconnect || false;
      
      // Nordic UART commands - specifically for SparkFun device
      // Since we know we're already receiving GSA sentences, we'll just try to enable GGA
      setTimeout(async () => {
        try {
          console.log('Sending NMEA configuration commands for Nordic UART (SparkFun)...');
          
          // First wait a full 2 seconds for the device to stabilize its connection
          await new Promise(resolve => setTimeout(resolve, 2000));
          
          console.log('Connection stabilized, beginning command sequence');
          
          // First send some line breaks to wake it up - important for some modules
          await this.sendData('\r\n\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // Send reset command - try to start from a clean state
          // This will revert settings to defaults but ensure consistent behavior
          await this.sendData('$PUBX,40,ZDA,0,0,0,0,0,0*45\r\n');
          await new Promise(resolve => setTimeout(resolve, 300));
          
          // Send the configuration commands in the exact order used by other applications
          // GGA sentence - position data (must be first and with higher rate)
          await this.sendData('$PUBX,40,GGA,0,1,0,0,0,0*5A\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // GGA sentence again with different parameters - matches working app's sequence
          await this.sendData('$PUBX,40,GGA,1,1,1,1,1,0*5A\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // RMC sentence - minimum navigation info
          await this.sendData('$PUBX,40,RMC,1,1,1,1,1,0*47\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // GSA sentence - satellite data
          await this.sendData('$PUBX,40,GSA,1,1,1,1,1,0*4E\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // GST sentence - error statistics 
          await this.sendData('$PUBX,40,GST,1,1,1,1,1,0*5B\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // VTG sentence - course and speed
          await this.sendData('$PUBX,40,VTG,1,1,1,1,1,0*5E\r\n');
          await new Promise(resolve => setTimeout(resolve, 500));
          
          // Save configuration to persist changes
          await this.sendData('$PUBX,00*33\r\n');
          await new Promise(resolve => setTimeout(resolve, 1000));
          
          // Alternative command method that works on some u-blox devices
          // Set message rate directly using CFG-MSG
          const ubxCfgMsgGGA = new Uint8Array([
            0xB5, 0x62,            // Header
            0x06, 0x01,            // CFG-MSG class/id
            0x08, 0x00,            // Payload length
            0xF0, 0x00,            // NMEA GGA message class/id
            0x01,                  // Enable on port 0 (I2C)
            0x01,                  // Enable on port 1 (UART1)
            0x01,                  // Enable on port 2 (UART2)
            0x01,                  // Enable on port 3 (USB)
            0x01,                  // Enable on port 4 (SPI)
            0x00,                  // Reserved
            0x02, 0x32             // Checksum
          ]);
          
          // Try to send the binary command
          try {
            await this.sendData(ubxCfgMsgGGA.buffer);
            await new Promise(resolve => setTimeout(resolve, 500));
          } catch (e) {
            console.log('Binary command failed, continuing with NMEA commands');
          }
          
          // Query current configuration to trigger response
          await this.sendData('$PUBX,00*33\r\n');
          await new Promise(resolve => setTimeout(resolve, 300));
          
          console.log('Complete NMEA configuration commands sent');
        } catch (e) {
          console.warn('Error sending NMEA configuration - device may be in read-only mode:', e);
          console.log('Continuing with available data');
        }
      }, 1000);
      
      this.eventEmitter.emit('bluetooth:connected', {
        deviceId: this.device.id,
        deviceName: this.device.name,
        type: 'sparkfun'
      });
      
      return true;
    } catch (error) {
      this.isConnecting = false;
      this.eventEmitter.emit('bluetooth:error', {
        message: error.message,
        error,
        type: 'sparkfun'
      });
      
      return false;
    }
  }

  /**
   * Get connection status information
   * @returns {Object} Connection status
   */
  getConnectionInfo() {
    return {
      connected: this.isConnected,
      connecting: this.isConnecting,
      deviceId: this.device?.id,
      deviceName: this.device?.name
    };
  }
}

export default BluetoothConnection;