initial
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user