625 lines
19 KiB
Dart
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;
|
|
}
|
|
}
|