From ada8422d1d7d086a7885eaf4d6d8e803ee3ab2dc Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 16 Sep 2026 10:42:11 +0700 Subject: [PATCH] fix: remove unused variables and field --- lib/screens/cli_screen.dart | 702 +++++++++---------- lib/services/ble_service.dart | 1245 ++++++++++++++++----------------- 2 files changed, 972 insertions(+), 975 deletions(-) diff --git a/lib/screens/cli_screen.dart b/lib/screens/cli_screen.dart index ce5461e..b757dcc 100644 --- a/lib/screens/cli_screen.dart +++ b/lib/screens/cli_screen.dart @@ -1,351 +1,351 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/ble_service.dart'; - -class CLIScreen extends StatefulWidget { - const CLIScreen({super.key}); - - @override - State createState() => _CLIScreenState(); -} - -class _CLIScreenState extends State { - final ScrollController _scrollController = ScrollController(); - final List _logLines = []; - Timer? _statusTimer; - bool _autoScroll = true; - int _lastProcessedPacket = 0; - - @override - void initState() { - super.initState(); - _addHeader(); - _startStatusTimer(); - } - - @override - void dispose() { - _statusTimer?.cancel(); - _scrollController.dispose(); - super.dispose(); - } - - String _repeatChar(String char, int count) { - return List.filled(count, char).join(); - } - - void _addHeader() { - _addLogLine(_repeatChar('=', 70)); - _addLogLine('Enhanced with automatic reconnection'); - _addLogLine(_repeatChar('=', 70)); - _addLogLine(''); - } - - void _addLogLine(String line) { - setState(() { - _logLines.add(line); - // Keep last 5000 lines - if (_logLines.length > 5000) { - _logLines.removeAt(0); - } - }); - - if (_autoScroll) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - } - } - - void _startStatusTimer() { - _statusTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (mounted) { - final ble = Provider.of(context, listen: false); - if (ble.isConnected) { - _updateStatus(ble); - } - } - }); - } - - void _updateStatus(BLEService ble) { - final stats = _getStatistics(ble); - final elapsed = stats['elapsed'] as double; - final packets = stats['packets'] as int; - final rate = stats['rate'] as double; - final connStatus = ble.isConnected ? '✓' : '✗'; - - // Remove old status line if exists (lines ending with specific pattern) - if (_logLines.isNotEmpty && _logLines.last.startsWith('[STATUS]')) { - _logLines.removeLast(); - } - - if (packets > 0) { - _addLogLine( - '[STATUS] Packets: ${packets.toString().padLeft(5)} | ' - 'Rate: ${rate.toStringAsFixed(2).padLeft(5)} pkt/s | ' - 'Time: ${elapsed.toStringAsFixed(1).padLeft(7)}s | ' - 'Conn: $connStatus', - ); - } else { - _addLogLine('[STATUS] Waiting for data... (${elapsed.toStringAsFixed(1)}s)'); - } - } - - Map _getStatistics(BLEService ble) { - final now = DateTime.now(); - final startTime = ble.streamingDuration != null - ? now.subtract(ble.streamingDuration!) - : now; - final elapsed = now.difference(startTime).inMilliseconds / 1000.0; - final packets = ble.packetCount; - final rate = elapsed > 0 ? packets / elapsed : 0.0; - - return { - 'elapsed': elapsed, - 'packets': packets, - 'rate': rate, - }; - } - - String _formatTimestamp(DateTime dt) { - return '${dt.hour.toString().padLeft(2, '0')}:' - '${dt.minute.toString().padLeft(2, '0')}:' - '${dt.second.toString().padLeft(2, '0')}'; - } - - void _onDataReceived(DataPacket packet) { - final timestamp = _formatTimestamp(packet.timestamp); - final hexData = packet.hexString; - final packetNum = packet.packetNumber; - final bytes = packet.data.length; - - _addLogLine(''); - _addLogLine('[$timestamp] Packet #${packetNum.toString().padLeft(5, '0')}: $hexData ($bytes bytes)'); - - // Parse data formats (matching Python example) - if (packet.data.length >= 4) { - try { - final valueU32 = packet.value32BitUnsigned; - final valueS32 = packet.value32BitSigned; - final valueU16 = packet.value16BitUnsigned; - final valueS16 = packet.value16BitSigned; - - // Commented out like in Python example - // _addLogLine(' → 32-bit unsigned: ${valueU32.toString().padLeft(10)} | 32-bit signed: ${valueS32.toString().padLeft(10)}'); - // _addLogLine(' → 16-bit unsigned: ${valueU16.toString().padLeft(10)} | 16-bit signed: ${valueS16.toString().padLeft(10)}'); - - if (valueU32 != null && valueU32 > 0) { - final floatVal = valueU32 / 100.0; - // _addLogLine(' → As float (÷100): ${floatVal.toStringAsFixed(2).padLeft(12)}'); - } - } catch (e) { - _addLogLine(' → Parse error: $e'); - } - } - - // Show weight as 'timbang' in KG (matching Python example) - final weightKg = packet.weightKg; - if (weightKg != null) { - _addLogLine(' → timbang : ${weightKg.toStringAsFixed(1)} KG'); - } else { - // Fallback to showing raw bytes in decimal - final byteStr = packet.data.map((b) => b.toString().padLeft(2, '0')).join(' '); - _addLogLine(' → timbang : $byteStr'); - } - - // Try to decode as ASCII if printable - final asciiStr = packet.asciiString; - if (asciiStr != null && asciiStr.isNotEmpty) { - _addLogLine(" → ASCII: '$asciiStr'"); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: const Text('SIAB Bluetooth CLI', style: TextStyle(color: Colors.green)), - backgroundColor: Colors.grey[900], - iconTheme: const IconThemeData(color: Colors.green), - actions: [ - Consumer( - builder: (context, ble, _) { - if (ble.isConnected) { - return IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.green), - tooltip: 'Clear Log', - onPressed: () { - setState(() { - _logLines.clear(); - _addHeader(); - }); - ble.clearData(); - }, - ); - } - return const SizedBox.shrink(); - }, - ), - IconButton( - icon: Icon( - _autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center, - color: Colors.green, - ), - tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll', - onPressed: () { - setState(() { - _autoScroll = !_autoScroll; - }); - }, - ), - ], - ), - body: Consumer( - builder: (context, ble, _) { - // Process new data packets - if (ble.dataPackets.isNotEmpty) { - final newPackets = ble.dataPackets - .where((p) => p.packetNumber > _lastProcessedPacket) - .toList(); - - for (final packet in newPackets) { - _onDataReceived(packet); - _lastProcessedPacket = packet.packetNumber; - } - } else { - _lastProcessedPacket = 0; - } - - return Column( - children: [ - // Connection status bar (terminal style) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Colors.grey[900], - child: Row( - children: [ - Icon( - ble.isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, - color: ble.isConnected ? Colors.green : Colors.red, - size: 20, - ), - const SizedBox(width: 8), - Text( - ble.isConnected - ? 'Connected: ${ble.deviceName ?? ble.deviceAddress ?? "Unknown"}' - : 'Disconnected', - style: const TextStyle( - color: Colors.green, - fontFamily: 'monospace', - fontSize: 12, - ), - ), - const Spacer(), - if (ble.isScanning || ble.isConnecting) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.green), - ), - ), - ], - ), - ), - - // Terminal output area - Expanded( - child: Container( - color: Colors.black, - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: SelectableText( - _logLines.join('\n'), - style: const TextStyle( - color: Colors.green, - fontFamily: 'monospace', - fontSize: 12, - height: 1.4, - ), - ), - ), - ), - ), - - // Bottom action bar - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Colors.grey[900], - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (ble.isConnecting || ble.isScanning) - const Text( - 'Connecting...', - style: TextStyle(color: Colors.yellow, fontFamily: 'monospace'), - ) - else if (ble.isConnected) - ElevatedButton.icon( - onPressed: () async { - _addLogLine('\n⏹ Disconnecting...'); - await ble.disconnect(); - ble.clearData(); - _addLogLine('✅ Disconnected\n'); - }, - icon: const Icon(Icons.bluetooth_disabled, size: 18), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red[900], - foregroundColor: Colors.white, - ), - ) - else - ElevatedButton.icon( - onPressed: () async { - _addLogLine('\n[1/3] Connecting to SIAB device...'); - final success = await ble.autoConnect(); - if (success) { - _addLogLine('✅ Connected successfully!'); - if (ble.deviceAddress != null) { - _addLogLine(' Device address: ${ble.deviceAddress}'); - } - _addLogLine('\n[2/3] Enabling streaming data notifications...'); - _addLogLine('✅ Notifications enabled!'); - _addLogLine('\n[3/3] Receiving streaming data...'); - _addLogLine(_repeatChar('=', 70)); - _addLogLine('Waiting for data packets...'); - _addLogLine(''); - } else { - _addLogLine('❌ Failed to connect!'); - _addLogLine('\nTroubleshooting:'); - _addLogLine(' • Make sure SIAB device is powered on'); - _addLogLine(' • Check Bluetooth is enabled'); - _addLogLine(' • Verify device name is \'SIAB\''); - } - }, - icon: const Icon(Icons.bluetooth_searching, size: 18), - label: const Text('Connect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green[900], - foregroundColor: Colors.white, - ), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/ble_service.dart'; + +class CLIScreen extends StatefulWidget { + const CLIScreen({super.key}); + + @override + State createState() => _CLIScreenState(); +} + +class _CLIScreenState extends State { + final ScrollController _scrollController = ScrollController(); + final List _logLines = []; + Timer? _statusTimer; + bool _autoScroll = true; + int _lastProcessedPacket = 0; + + @override + void initState() { + super.initState(); + _addHeader(); + _startStatusTimer(); + } + + @override + void dispose() { + _statusTimer?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + String _repeatChar(String char, int count) { + return List.filled(count, char).join(); + } + + void _addHeader() { + _addLogLine(_repeatChar('=', 70)); + _addLogLine('Enhanced with automatic reconnection'); + _addLogLine(_repeatChar('=', 70)); + _addLogLine(''); + } + + void _addLogLine(String line) { + setState(() { + _logLines.add(line); + // Keep last 5000 lines + if (_logLines.length > 5000) { + _logLines.removeAt(0); + } + }); + + if (_autoScroll) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + } + + void _startStatusTimer() { + _statusTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + final ble = Provider.of(context, listen: false); + if (ble.isConnected) { + _updateStatus(ble); + } + } + }); + } + + void _updateStatus(BLEService ble) { + final stats = _getStatistics(ble); + final elapsed = stats['elapsed'] as double; + final packets = stats['packets'] as int; + final rate = stats['rate'] as double; + final connStatus = ble.isConnected ? '✓' : '✗'; + + // Remove old status line if exists (lines ending with specific pattern) + if (_logLines.isNotEmpty && _logLines.last.startsWith('[STATUS]')) { + _logLines.removeLast(); + } + + if (packets > 0) { + _addLogLine( + '[STATUS] Packets: ${packets.toString().padLeft(5)} | ' + 'Rate: ${rate.toStringAsFixed(2).padLeft(5)} pkt/s | ' + 'Time: ${elapsed.toStringAsFixed(1).padLeft(7)}s | ' + 'Conn: $connStatus', + ); + } else { + _addLogLine('[STATUS] Waiting for data... (${elapsed.toStringAsFixed(1)}s)'); + } + } + + Map _getStatistics(BLEService ble) { + final now = DateTime.now(); + final startTime = ble.streamingDuration != null + ? now.subtract(ble.streamingDuration!) + : now; + final elapsed = now.difference(startTime).inMilliseconds / 1000.0; + final packets = ble.packetCount; + final rate = elapsed > 0 ? packets / elapsed : 0.0; + + return { + 'elapsed': elapsed, + 'packets': packets, + 'rate': rate, + }; + } + + String _formatTimestamp(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + void _onDataReceived(DataPacket packet) { + final timestamp = _formatTimestamp(packet.timestamp); + final hexData = packet.hexString; + final packetNum = packet.packetNumber; + final bytes = packet.data.length; + + _addLogLine(''); + _addLogLine('[$timestamp] Packet #${packetNum.toString().padLeft(5, '0')}: $hexData ($bytes bytes)'); + + // Parse data formats (matching Python example) + if (packet.data.length >= 4) { + try { + // valueU32 = packet.value32BitUnsigned; + // valueS32 = packet.value32BitSigned; + // valueU16 = packet.value16BitUnsigned; + // valueS16 = packet.value16BitSigned; + + // Commented out like in Python example + // _addLogLine(' → 32-bit unsigned: ${valueU32.toString().padLeft(10)} | 32-bit signed: ${valueS32.toString().padLeft(10)}'); + // _addLogLine(' → 16-bit unsigned: ${valueU16.toString().padLeft(10)} | 16-bit signed: ${valueS16.toString().padLeft(10)}'); + + // if (valueU32 != null && valueU32 > 0) { + // final floatVal = valueU32 / 100.0; + // _addLogLine(' → As float (÷100): ${floatVal.toStringAsFixed(2).padLeft(12)}'); + // } + } catch (e) { + _addLogLine(' → Parse error: $e'); + } + } + + // Show weight as 'timbang' in KG (matching Python example) + final weightKg = packet.weightKg; + if (weightKg != null) { + _addLogLine(' → timbang : ${weightKg.toStringAsFixed(1)} KG'); + } else { + // Fallback to showing raw bytes in decimal + final byteStr = packet.data.map((b) => b.toString().padLeft(2, '0')).join(' '); + _addLogLine(' → timbang : $byteStr'); + } + + // Try to decode as ASCII if printable + final asciiStr = packet.asciiString; + if (asciiStr != null && asciiStr.isNotEmpty) { + _addLogLine(" → ASCII: '$asciiStr'"); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + title: const Text('SIAB Bluetooth CLI', style: TextStyle(color: Colors.green)), + backgroundColor: Colors.grey[900], + iconTheme: const IconThemeData(color: Colors.green), + actions: [ + Consumer( + builder: (context, ble, _) { + if (ble.isConnected) { + return IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.green), + tooltip: 'Clear Log', + onPressed: () { + setState(() { + _logLines.clear(); + _addHeader(); + }); + ble.clearData(); + }, + ); + } + return const SizedBox.shrink(); + }, + ), + IconButton( + icon: Icon( + _autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center, + color: Colors.green, + ), + tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll', + onPressed: () { + setState(() { + _autoScroll = !_autoScroll; + }); + }, + ), + ], + ), + body: Consumer( + builder: (context, ble, _) { + // Process new data packets + if (ble.dataPackets.isNotEmpty) { + final newPackets = ble.dataPackets + .where((p) => p.packetNumber > _lastProcessedPacket) + .toList(); + + for (final packet in newPackets) { + _onDataReceived(packet); + _lastProcessedPacket = packet.packetNumber; + } + } else { + _lastProcessedPacket = 0; + } + + return Column( + children: [ + // Connection status bar (terminal style) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Colors.grey[900], + child: Row( + children: [ + Icon( + ble.isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, + color: ble.isConnected ? Colors.green : Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Text( + ble.isConnected + ? 'Connected: ${ble.deviceName ?? ble.deviceAddress ?? "Unknown"}' + : 'Disconnected', + style: const TextStyle( + color: Colors.green, + fontFamily: 'monospace', + fontSize: 12, + ), + ), + const Spacer(), + if (ble.isScanning || ble.isConnecting) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.green), + ), + ), + ], + ), + ), + + // Terminal output area + Expanded( + child: Container( + color: Colors.black, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: SelectableText( + _logLines.join('\n'), + style: const TextStyle( + color: Colors.green, + fontFamily: 'monospace', + fontSize: 12, + height: 1.4, + ), + ), + ), + ), + ), + + // Bottom action bar + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Colors.grey[900], + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (ble.isConnecting || ble.isScanning) + const Text( + 'Connecting...', + style: TextStyle(color: Colors.yellow, fontFamily: 'monospace'), + ) + else if (ble.isConnected) + ElevatedButton.icon( + onPressed: () async { + _addLogLine('\n⏹ Disconnecting...'); + await ble.disconnect(); + ble.clearData(); + _addLogLine('✅ Disconnected\n'); + }, + icon: const Icon(Icons.bluetooth_disabled, size: 18), + label: const Text('Disconnect'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[900], + foregroundColor: Colors.white, + ), + ) + else + ElevatedButton.icon( + onPressed: () async { + _addLogLine('\n[1/3] Connecting to SIAB device...'); + final success = await ble.autoConnect(); + if (success) { + _addLogLine('✅ Connected successfully!'); + if (ble.deviceAddress != null) { + _addLogLine(' Device address: ${ble.deviceAddress}'); + } + _addLogLine('\n[2/3] Enabling streaming data notifications...'); + _addLogLine('✅ Notifications enabled!'); + _addLogLine('\n[3/3] Receiving streaming data...'); + _addLogLine(_repeatChar('=', 70)); + _addLogLine('Waiting for data packets...'); + _addLogLine(''); + } else { + _addLogLine('❌ Failed to connect!'); + _addLogLine('\nTroubleshooting:'); + _addLogLine(' • Make sure SIAB device is powered on'); + _addLogLine(' • Check Bluetooth is enabled'); + _addLogLine(' • Verify device name is \'SIAB\''); + } + }, + icon: const Icon(Icons.bluetooth_searching, size: 18), + label: const Text('Connect'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[900], + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/services/ble_service.dart b/lib/services/ble_service.dart index 2a9193b..c24ae9e 100644 --- a/lib/services/ble_service.dart +++ b/lib/services/ble_service.dart @@ -1,624 +1,621 @@ -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; - } -} +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; + + // 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++; + + 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; + 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; + } +}