Files
TimbanganSiab/lib/services/ble_service.dart
2026-01-29 16:17:31 +07:00

625 lines
19 KiB
Dart

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<DataPacket> _dataPackets = [];
int _packetCount = 0;
DateTime? _startTime;
DateTime? _lastDataTime;
// Getters
bool get isConnected => _isConnected;
bool get isScanning => _isScanning;
bool get isConnecting => _isConnecting;
List<DataPacket> 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<bool> checkBluetooth() async {
try {
final adapterState = await FlutterBluePlus.adapterState.first;
return adapterState == BluetoothAdapterState.on;
} catch (e) {
return false;
}
}
// Turn on Bluetooth if off
Future<void> 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<List<SiabScanResult>> scanForAllSiabDevices({
String deviceName = SiabConfig.deviceName,
int timeoutSeconds = 12,
}) async {
if (_isScanning) return [];
_isScanning = true;
notifyListeners();
final list = <SiabScanResult>[];
final seen = <String>{};
StreamSubscription<List<ScanResult>>? 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<List<SiabScanResult>> scanForAllBluetoothDevices({
int timeoutSeconds = 12,
bool restrictToSiab = true,
String deviceName = SiabConfig.deviceName,
}) async {
if (_isScanning) return [];
_isScanning = true;
notifyListeners();
final list = <SiabScanResult>[];
final seen = <String>{};
StreamSubscription<List<ScanResult>>? 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<bool> scanForDevice({
String deviceName = SiabConfig.deviceName,
int timeoutSeconds = 10,
}) async {
if (_isScanning) return false;
_isScanning = true;
notifyListeners();
final completer = Completer<bool>();
StreamSubscription<List<ScanResult>>? 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<bool> 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<bool> 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<bool> 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<void> 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<bool> sendCommand(List<int> 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<void> 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<int> 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<void> _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<bool> saveStableMeasurement() async {
final weight = stableWeightKg;
if (weight == null) {
return false;
}
await _saveStableMeasurement(weight);
return true;
}
Future<File> _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<File> getTodayMeasurementFile() {
return _getMeasurementFile(DateTime.now());
}
// Clear data
void clearData() {
_dataPackets.clear();
_packetCount = 0;
_startTime = null;
_lastDataTime = null;
notifyListeners();
}
// Auto connect
Future<bool> 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<int> 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;
}
}