import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../database/siab_database.dart'; import '../siab_config.dart'; class BLEService extends ChangeNotifier { // Device info BluetoothDevice? _device; BluetoothCharacteristic? _readCharacteristic; BluetoothCharacteristic? _writeCharacteristic; // Connection state bool _isConnected = false; bool _isScanning = false; bool _isConnecting = false; // Streamed data final List _dataPackets = []; int _packetCount = 0; DateTime? _startTime; DateTime? _lastDataTime; // Getters bool get isConnected => _isConnected; bool get isScanning => _isScanning; bool get isConnecting => _isConnecting; List get dataPackets => List.unmodifiable(_dataPackets); int get packetCount => _packetCount; String? get deviceAddress => _device?.remoteId.toString(); String? get deviceName => _device?.platformName; double get packetsPerSecond { if (_startTime == null || _packetCount == 0) return 0.0; final duration = DateTime.now().difference(_startTime!); if (duration.inSeconds == 0) return 0.0; return _packetCount / duration.inSeconds; } Duration? get streamingDuration { if (_startTime == null) return null; return DateTime.now().difference(_startTime!); } /// Latest weight (timbang) in kg from the most recent data packet. double? get lastWeightKg { if (_dataPackets.isEmpty) return null; return _dataPackets.last.weightKg; } // Stable value tracking for manual save double? _currentStableCandidate; DateTime? _lastWeightChangeTime; /// True when the current weight has been stable for at least 2 seconds. bool get hasStableWeight { final candidate = _currentStableCandidate; final lastChange = _lastWeightChangeTime; if (candidate == null || lastChange == null) return false; return DateTime.now().difference(lastChange) >= const Duration(seconds: 2); } /// Latest stable weight in kg, or null if not stable yet. double? get stableWeightKg => hasStableWeight ? _currentStableCandidate : null; // Check if Bluetooth is available Future checkBluetooth() async { try { final adapterState = await FlutterBluePlus.adapterState.first; return adapterState == BluetoothAdapterState.on; } catch (e) { return false; } } // Turn on Bluetooth if off Future turnOnBluetooth() async { try { await FlutterBluePlus.turnOn(); } catch (e) { debugPrint('Error turning on Bluetooth: $e'); } } /// Result of a single SIAB device found during scan. static SiabScanResult scanResultFrom(ScanResult result) { return SiabScanResult( address: result.device.remoteId.toString(), name: result.device.platformName, device: result.device, ); } /// Scan and return all SIAB devices found (does not connect). /// Uses listen + delay because scanResults stream never completes. /// Extra hardening: matches device name by strict (case-insensitive) equality. Future> scanForAllSiabDevices({ String deviceName = SiabConfig.deviceName, int timeoutSeconds = 12, }) async { if (_isScanning) return []; _isScanning = true; notifyListeners(); final list = []; final seen = {}; StreamSubscription>? sub; try { if (!await checkBluetooth()) { await turnOnBluetooth(); await Future.delayed(const Duration(seconds: 2)); } await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds)); sub = FlutterBluePlus.scanResults.listen((scanResult) { for (final result in scanResult) { final name = result.device.platformName; if (name.isNotEmpty && name.toLowerCase() == deviceName.toLowerCase()) { final address = result.device.remoteId.toString(); if (!seen.contains(address)) { seen.add(address); list.add(scanResultFrom(result)); } } } }); await Future.delayed(Duration(seconds: timeoutSeconds)); } catch (e) { debugPrint('Scan error: $e'); } finally { await sub?.cancel(); try { await FlutterBluePlus.stopScan(); } catch (_) {} } _isScanning = false; notifyListeners(); return list; } /// Scan and return Bluetooth devices found. /// /// Extra hardening: by default, only returns devices whose platform name /// strictly equals [SiabConfig.deviceName] (case-insensitive). /// Uses listen + delay because scanResults stream never completes. Future> scanForAllBluetoothDevices({ int timeoutSeconds = 12, bool restrictToSiab = true, String deviceName = SiabConfig.deviceName, }) async { if (_isScanning) return []; _isScanning = true; notifyListeners(); final list = []; final seen = {}; StreamSubscription>? sub; try { if (!await checkBluetooth()) { await turnOnBluetooth(); await Future.delayed(const Duration(seconds: 2)); } await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds)); sub = FlutterBluePlus.scanResults.listen((scanResult) { for (final result in scanResult) { final name = result.device.platformName; if (restrictToSiab && (name.isEmpty || name.toLowerCase() != deviceName.toLowerCase())) { continue; } final address = result.device.remoteId.toString(); if (!seen.contains(address)) { seen.add(address); list.add(scanResultFrom(result)); } } }); await Future.delayed(Duration(seconds: timeoutSeconds)); } catch (e) { debugPrint('Scan error: $e'); } finally { await sub?.cancel(); try { await FlutterBluePlus.stopScan(); } catch (_) {} } _isScanning = false; notifyListeners(); return list; } // Scan for SIAB device (first match only, used by autoConnect). // Uses listen + completer because scanResults stream never completes. // Extra hardening: matches device name by strict (case-insensitive) equality. Future scanForDevice({ String deviceName = SiabConfig.deviceName, int timeoutSeconds = 10, }) async { if (_isScanning) return false; _isScanning = true; notifyListeners(); final completer = Completer(); StreamSubscription>? sub; try { if (!await checkBluetooth()) { await turnOnBluetooth(); await Future.delayed(const Duration(seconds: 2)); } await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds)); sub = FlutterBluePlus.scanResults.listen((scanResult) { if (completer.isCompleted) return; for (final result in scanResult) { final name = result.device.platformName; if (name.isNotEmpty && name.toLowerCase() == deviceName.toLowerCase()) { _device = result.device; if (!completer.isCompleted) completer.complete(true); return; } } }); Future.delayed(Duration(seconds: timeoutSeconds), () { if (!completer.isCompleted) completer.complete(false); }); final found = await completer.future; await sub.cancel(); try { await FlutterBluePlus.stopScan(); } catch (_) {} _isScanning = false; notifyListeners(); return found; } catch (e) { debugPrint('Scan error: $e'); if (!completer.isCompleted) completer.complete(false); await sub?.cancel(); try { await FlutterBluePlus.stopScan(); } catch (_) {} _isScanning = false; notifyListeners(); return false; } } /// Connect to a specific device (e.g. from scan list or after resolving address). Future connectToDevice(BluetoothDevice device) async { if (_isConnecting || _isConnected) return false; _device = device; return connect(); } /// Connect to a device by address (scans all Bluetooth briefly to resolve device). Future connectToAddress(String address) async { if (_isConnecting || _isConnected) return false; final list = await scanForAllBluetoothDevices(timeoutSeconds: 10); SiabScanResult? match; for (final r in list) { if (r.address == address) { match = r; break; } } if (match == null) return false; return connectToDevice(match.device); } // Connect to device Future connect() async { if (_device == null || _isConnecting || _isConnected) return false; _isConnecting = true; notifyListeners(); try { // Connect to device await _device!.connect(timeout: const Duration(seconds: 15)); // Discover services final services = await _device!.discoverServices(); // Find read and write characteristics for (final service in services) { for (final characteristic in service.characteristics) { // Check if it's a notify characteristic if (characteristic.properties.notify || characteristic.properties.indicate) { _readCharacteristic = characteristic; } // Check if it's a write characteristic if (characteristic.properties.write || characteristic.properties.writeWithoutResponse) { _writeCharacteristic = characteristic; } } } // If not found, try to find by UUID (Nordic UART Service) if (_readCharacteristic == null || _writeCharacteristic == null) { for (final service in services) { for (final characteristic in service.characteristics) { final uuid = characteristic.uuid.toString().toLowerCase(); // Nordic UART Service characteristic if (uuid.contains('8ec90001') || uuid.contains('6e400003') || uuid.contains('6e400002')) { if (characteristic.properties.notify || characteristic.properties.indicate) { _readCharacteristic = characteristic; } if (characteristic.properties.write || characteristic.properties.writeWithoutResponse) { _writeCharacteristic = characteristic; } } } } } // Enable notifications if (_readCharacteristic != null) { await _readCharacteristic!.setNotifyValue(true); // Listen to notifications _readCharacteristic!.lastValueStream.listen((value) { _onDataReceived(value); }); } _isConnected = true; _isConnecting = false; _startTime = DateTime.now(); notifyListeners(); return true; } catch (e) { debugPrint('Connection error: $e'); _isConnecting = false; _isConnected = false; notifyListeners(); return false; } } // Disconnect Future disconnect() async { try { if (_readCharacteristic != null) { await _readCharacteristic!.setNotifyValue(false); } if (_device != null) { await _device!.disconnect(); } } catch (e) { debugPrint('Disconnect error: $e'); } _isConnected = false; _isConnecting = false; _readCharacteristic = null; _writeCharacteristic = null; notifyListeners(); } // Send command Future sendCommand(List command) async { if (!_isConnected || _writeCharacteristic == null) return false; try { if (_writeCharacteristic!.properties.writeWithoutResponse) { await _writeCharacteristic!.write(command, withoutResponse: true); } else { await _writeCharacteristic!.write(command); } return true; } catch (e) { debugPrint('Send command error: $e'); return false; } } // Try common start commands Future tryStartCommands() async { final commands = [ [0x01], [0x02], [0xFF], 'START'.codeUnits, 'START\n'.codeUnits, 'ENABLE'.codeUnits, 'ON'.codeUnits, ]; for (final cmd in commands) { await sendCommand(cmd); await Future.delayed(const Duration(milliseconds: 500)); if (_packetCount > 0) break; } } // Handle received data void _onDataReceived(List data) { _packetCount++; _lastDataTime = DateTime.now(); final packet = DataPacket( data: List.from(data), timestamp: DateTime.now(), packetNumber: _packetCount, ); _dataPackets.add(packet); // Keep only last 1000 packets if (_dataPackets.length > 1000) { _dataPackets.removeAt(0); } // Detect stable weight (used for manual saving) final weight = packet.weightKg; if (weight != null) { final now = DateTime.now(); if (_currentStableCandidate == null || (weight - _currentStableCandidate!).abs() > 0.05) { _currentStableCandidate = weight; _lastWeightChangeTime = now; } else { // Weight hasn't changed significantly; just keep last change time // to measure stability duration via hasStableWeight getter. _lastWeightChangeTime ??= now; } } notifyListeners(); } Future _saveStableMeasurement(double weightKg) async { try { final now = DateTime.now(); final file = await _getMeasurementFile(now); // Determine running number ("no") based on existing data lines. int no = 1; if (await file.exists()) { final lines = await file.readAsLines(); final dataLines = lines.where( (l) => l.trim().isNotEmpty && !l.trim().startsWith('#'), ); no = dataLines.length + 1; } else { // New file: write header first. await file.writeAsString( 'no,timestamp,label_id,weight_kg\n', mode: FileMode.write, flush: true, ); } final ts = now.toIso8601String(); final uuid = deviceAddress ?? ''; // Look up human-friendly label from SQLite; fall back to UUID. String labelId = uuid; if (uuid.isNotEmpty) { try { final device = await SiabDatabase().getByUuid(uuid); labelId = device?.label ?? uuid; } catch (_) { // Ignore DB errors; keep labelId as UUID. } } final line = '$no,$ts,$labelId,$weightKg\n'; await file.writeAsString(line, mode: FileMode.append, flush: true); debugPrint('Saved stable measurement: $line'); } catch (e) { debugPrint('Error saving stable measurement: $e'); } } /// Public API: save current stable measurement when user presses a button. /// Returns true if a stable value existed and was saved. Future saveStableMeasurement() async { final weight = stableWeightKg; if (weight == null) { return false; } await _saveStableMeasurement(weight); return true; } Future _getMeasurementFile(DateTime now) async { // Prefer Downloads folder; fall back to app documents if unavailable. Directory dir; try { final downloads = await getDownloadsDirectory(); dir = downloads ?? await getApplicationDocumentsDirectory(); } catch (_) { dir = await getApplicationDocumentsDirectory(); } final datePart = '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; return File(p.join(dir.path, 'siab_measurements_$datePart.csv')); } /// Public helper: get today's measurement CSV file (may not exist yet). Future getTodayMeasurementFile() { return _getMeasurementFile(DateTime.now()); } // Clear data void clearData() { _dataPackets.clear(); _packetCount = 0; _startTime = null; _lastDataTime = null; notifyListeners(); } // Auto connect Future autoConnect({String deviceName = SiabConfig.deviceName, int retries = 3}) async { for (int i = 0; i < retries; i++) { if (await scanForDevice(deviceName: deviceName)) { if (await connect()) { // Try start commands if no data after 2 seconds await Future.delayed(const Duration(seconds: 2)); if (_packetCount == 0) { await tryStartCommands(); } return true; } } if (i < retries - 1) { await Future.delayed(const Duration(seconds: 2)); } } return false; } } class SiabScanResult { final String address; final String name; final BluetoothDevice device; SiabScanResult({ required this.address, required this.name, required this.device, }); } // Data packet model class DataPacket { final List data; final DateTime timestamp; final int packetNumber; DataPacket({ required this.data, required this.timestamp, required this.packetNumber, }); String get hexString { return data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } String get bytesString { // Match Python 'timbang' view: decimal bytes with zero padding return data.map((b) => b.toString().padLeft(2, '0')).join(' '); } // Parse as 32-bit integer (big endian) int? get value32BitUnsigned { if (data.length < 4) return null; return (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; } int? get value32BitSigned { final unsigned = value32BitUnsigned; if (unsigned == null) return null; if (unsigned > 0x7FFFFFFF) { return unsigned - 0x100000000; } return unsigned; } // Parse as 16-bit integer (big endian) int? get value16BitUnsigned { if (data.length < 2) return null; return (data[0] << 8) | data[1]; } int? get value16BitSigned { final unsigned = value16BitUnsigned; if (unsigned == null) return null; if (unsigned > 0x7FFF) { return unsigned - 0x10000; } return unsigned; } String? get asciiString { try { // Web's utf8.decode does not support allowInvalid; use default behavior here. final str = utf8.decode(data); if (str.codeUnits.every((c) => c >= 32 && c < 127)) { return str; } } catch (e) { // Not ASCII } return null; } // Get weight in KG (from 3rd byte, divided by 10) // Matching Python example: raw_weight = data[2]; weight_kg = raw_weight / 10.0 double? get weightKg { if (data.length >= 3) { return data[2] / 10.0; } return null; } }