vtj_bluetooth_package 1.0.3
vtj_bluetooth_package: ^1.0.3 copied to clipboard
A Flutter package for seamless Bluetooth Low Energy (BLE) communication with Ventriject medical devices. Provides easy-to-use APIs for device discovery, connection management, and data exchange.
Ventriject Bluetooth Package #
A Flutter package that provides seamless Bluetooth Low Energy (BLE) communication with Ventriject Seismofit medical devices. This package handles device discovery, connection management, authentication, measurement recording, and data transfer using Ventriject's proprietary BLE protocol.
Table of Contents #
- Features
- Getting Started
- Architecture Overview
- Device Discovery
- Connection Management
- Authentication
- Device Commands
- Measurements
- Data Transfer
- Error Handling
- Troubleshooting
Features #
- 🔍 Device Discovery - Automatic filtering and validation of Ventriject devices via BLE advertising data
- � Advertising Data Parsing - Battery level, device state, and MAC address available before connection
- � Device Authentication - Secure key-based authentication
- 📊 Measurement Recording - Configurable accelerometer measurements with progress tracking
- 📥 Data Transfer - Download measurement data with CRC validation and progress updates
- 💡 Device Identification - LED blink pattern to identify physical device
- 🔄 DFU Support - Enter bootloader mode for firmware updates
- 📱 Platform Support - Android and iOS
Getting Started #
Installation #
Add the package to your pubspec.yaml:
dependencies:
vtj_bluetooth_package: ^1.0.0
Android Configuration #
Add these permissions to your AndroidManifest.xml:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
iOS Configuration #
Add these entries to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Need BLE permission to connect to Ventriject devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Need BLE permission to connect to Ventriject devices</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Need location permission to scan for BLE devices</string>
Architecture Overview #
The package follows a repository pattern with two main components:
import 'package:vtj_bluetooth_package/vtj_bluetooth_package.dart';
// Create the datasource (handles low-level BLE communication)
final datasource = BluePlusVtjDatasource();
// Create the repository (provides high-level API)
final repository = VtjDevicesRepositoryImpl(datasource);
All repository methods return Either<VtjFailure, T> for error handling using functional programming patterns from the fpdart package.
Device Discovery #
Starting a Scan #
// Start scanning for Ventriject devices
final scanResult = await repository.startScan();
scanResult.fold(
(failure) => print('Failed to start scan: ${failure.message}'),
(_) => print('Scan started successfully'),
);
Listening to Scan Results #
The getScanResults() method returns a stream of discovered devices. The package automatically filters and validates Ventriject devices based on advertising data.
final subscription = repository.getScanResults().listen((result) {
result.fold(
(failure) => print('Scan error: ${failure.message}'),
(devices) {
for (final device in devices) {
print('Found: ${device.name}');
print(' MAC Address: ${device.macAddress}');
print(' Battery: ${device.batteryPercentage}%');
print(' State: ${device.state.displayName}');
print(' Available: ${device.isAvailable}');
}
},
);
});
// Cancel subscription when done
await subscription.cancel();
VtjDevice Properties #
Each discovered device (VtjDevice) contains:
| Property | Type | Description |
|---|---|---|
device |
BluetoothDevice |
Underlying Flutter Blue Plus device |
advertisingData |
VtjAdvertisingData |
Parsed advertising data |
name |
String |
Device name (e.g., "VTJ-XXXX") |
macAddress |
String |
MAC address (e.g., "AA:BB:CC:DD:EE:FF") |
batteryPercentage |
int |
Battery level 0-100% |
state |
DeviceState |
Current device state |
isAvailable |
bool |
Whether device can be connected (not busy) |
firmwareVersion |
String? |
Firmware version (null until connected) |
Device States (from Advertising) #
| State | Description | Can Connect? |
|---|---|---|
DeviceState.ready |
No measurement, no data | ✅ Yes |
DeviceState.busy |
Recording in progress | ❌ No |
DeviceState.dataReady |
Has measurement data | ✅ Yes |
Connection Management #
Connecting to a Device #
final result = await repository.connectToDevice(vtjDevice);
result.fold(
(failure) => print('Connection failed: ${failure.message}'),
(connectedDevice) {
print('Connected to ${connectedDevice.name}');
// Now you can execute commands using connectedDevice.device
},
);
Disconnecting #
final result = await repository.disconnect(device.device);
result.fold(
(failure) => print('Disconnect failed: ${failure.message}'),
(_) => print('Disconnected successfully'),
);
Authentication #
After connecting, you must authenticate before executing most commands.
// Device key is provided with each Ventriject device
const deviceKey = 'your-device-key';
final result = await repository.authenticateDevice(device.device, deviceKey);
result.fold(
(failure) => print('Authentication failed: ${failure.message}'),
(success) {
if (success) {
print('Authentication successful');
} else {
print('Authentication failed: Invalid key');
}
},
);
Note: Authentication is required before using commands like startMeasurement, fetchMeasurement, deleteMeasurement, etc.
Device Commands #
Get Version #
Retrieves the firmware version of the connected device.
final result = await repository.getVersion(device.device);
result.fold(
(error) => print('Error: $error'),
(versionResult) => print('Firmware: ${versionResult.version}'),
);
Get Device State #
Retrieves detailed state information about the device and any stored measurement.
final result = await repository.getState(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(state) {
print('State: ${state.stateDescription}');
print('Has Data: ${state.hasData}');
print('Is Recording: ${state.isRecording}');
print('Is Idle: ${state.isIdle}');
// These return null when no valid measurement exists
if (state.hasData) {
print('Measurement Length: ${state.validMeasurementLength} seconds');
print('Size: ${state.validSize} bytes');
print('CRC: 0x${state.validCrc?.toRadixString(16)}');
}
},
);
GetDeviceStateResult Properties
| Property | Type | Description |
|---|---|---|
measurementState |
int |
Raw state value (0, 1, or 2) |
stateDescription |
String |
Human-readable state |
hasData |
bool |
True if measurement data is ready |
isRecording |
bool |
True if measurement in progress |
isIdle |
bool |
True if no measurement/data |
validMeasurementLength |
int? |
Duration in seconds (null if no data) |
validTotalLength |
int? |
Duration in 100ths of seconds (null if no data) |
validSize |
int? |
Data size in bytes (null if no data) |
validCrc |
int? |
CRC checksum (null if no data) |
Identify Blink #
Makes the device LED blink to identify the physical device.
final result = await repository.identifyBlink(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(blinkResult) => blinkResult.when(
success: () => print('Device LED is blinking'),
failure: (message, code) => print('Failed: $message'),
),
);
Delete Measurement #
Deletes any stored measurement data from the device.
final result = await repository.deleteMeasurement(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(deleteResult) => deleteResult.when(
success: () => print('Measurement deleted'),
failure: (message, code) => print('Failed: $message (code: $code)'),
),
);
Enter DFU Bootloader #
Reboots the device into DFU (Device Firmware Update) bootloader mode for firmware updates.
final result = await repository.enterDfuBootloader(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(dfuResult) => dfuResult.when(
success: () => print('Device entering DFU mode...'),
failure: (message, code) => print('Failed: $message'),
),
);
Warning: After entering DFU mode, the device will disconnect and reappear as a DFU target.
Measurements #
Measurement Configuration #
Configure measurements using MeasurementConfig:
final config = MeasurementConfig(
durationSeconds: 15, // 1-600 seconds
measurementType: MeasurementType.hz1100, // Sample rate
filterType: FilterType.fir, // Filter type
axesType: AxesType.zOnly, // Which axes to record
dataFormat: DataFormat.compressed, // Storage format
);
MeasurementType Options
| Type | Sample Rate | Max Duration | Bandwidth |
|---|---|---|---|
MeasurementType.hz550 |
550 Hz | 30 seconds | 235 Hz |
MeasurementType.hz1100 |
1100 Hz | 15 seconds | 440 Hz |
FilterType Options
| Type | Description |
|---|---|
FilterType.fir |
FIR filter (Linear Phase) - Recommended |
FilterType.iir |
IIR filter (Non-Linear Phase) |
AxesType Options
| Type | Description | Data Size |
|---|---|---|
AxesType.zOnly |
Only Z-axis | Smaller |
AxesType.xyz |
All three axes (X, Y, Z) | 3x larger |
DataFormat Options
| Type | Description |
|---|---|
DataFormat.uncompressed |
Raw sample data |
DataFormat.compressed |
Delta-encoded compression |
Starting a Measurement #
// 1. First, subscribe to measurement progress BEFORE starting
final progressSubscription = repository.measurementProgressStream(device.device)
.listen((progress) {
progress.when(
idle: () => print('Measurement idle'),
inProgress: (elapsed, total, fraction) {
print('Recording: $elapsed / $total seconds (${(fraction! * 100).toInt()}%)');
},
completed: (totalSeconds) {
print('Measurement completed! Duration: $totalSeconds seconds');
},
failed: (message, code, elapsed) {
print('Measurement failed: $message');
},
);
});
// 2. Create configuration
final config = MeasurementConfig(
durationSeconds: 15,
measurementType: MeasurementType.hz1100,
filterType: FilterType.fir,
axesType: AxesType.zOnly,
dataFormat: DataFormat.compressed,
);
// 3. Start measurement
final result = await repository.startMeasurement(device.device, config);
result.fold(
(failure) => print('Failed to start: ${failure.message}'),
(startResult) => startResult.when(
success: () => print('Measurement started!'),
failure: (message, code) => print('Error: $message (code: $code)'),
),
);
// 4. Clean up when done
await progressSubscription.cancel();
repository.resetMeasurementState(device.device);
Tracking Measurement Progress #
The MeasurementProgress class has four states:
progress.when(
idle: () {
// Initial state or after reset
},
inProgress: (int elapsedSeconds, int totalSeconds, double? fraction) {
// Recording in progress
// fraction is 0.0 to 1.0 for progress bar
},
completed: (int totalSeconds) {
// Recording finished successfully
// Data is now stored on device
},
failed: (String message, int? code, int? elapsedSeconds) {
// Recording failed
},
);
MeasurementProgress Helper Properties
| Property | Type | Description |
|---|---|---|
isActive |
bool |
True if currently recording |
isDone |
bool |
True if completed or failed |
isSuccess |
bool |
True if completed successfully |
Stopping a Measurement #
To prematurely stop an ongoing measurement:
final result = await repository.stopMeasurement(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(stopResult) => stopResult.when(
success: () => print('Measurement stopped'),
failure: (message, code) => print('Failed: $message'),
),
);
Data Transfer #
Fetching Measurement Data #
After a measurement completes, download the data from the device.
// 1. Subscribe to fetch progress BEFORE starting fetch
final fetchSubscription = repository.fetchProgressStream(device.device)
.listen((progress) {
progress.when(
idle: () => print('Fetch idle'),
inProgress: (received) {
print('Downloading... $received bytes received');
},
completed: (Uint8List data, int totalBytes, bool crcValid) {
print('Download complete!');
print(' Size: $totalBytes bytes');
print(' CRC Valid: $crcValid');
if (crcValid) {
// Process the measurement data
processData(data);
} else {
print('WARNING: CRC validation failed - data may be corrupted');
}
},
failed: (String message, int? code, int? receivedBytes) {
print('Download failed: $message');
if (receivedBytes != null) {
print(' Received before failure: $receivedBytes bytes');
}
},
);
});
// 2. Start fetch
final result = await repository.fetchMeasurement(device.device);
result.fold(
(failure) => print('Failed to start fetch: ${failure.message}'),
(fetchResult) => fetchResult.when(
success: () => print('Fetch started'),
failure: (message, code) => print('Error: $message'),
),
);
// 3. Clean up when done
await fetchSubscription.cancel();
repository.resetFetchState(device.device);
Tracking Fetch Progress #
The FetchProgress class has four states:
progress.when(
idle: () {
// Initial state or after reset
},
inProgress: (int receivedBytes) {
// Download in progress
// Shows bytes received so far (use a spinner, not progress bar)
},
completed: (Uint8List data, int totalBytes, bool crcValid) {
// Download finished
// data contains the raw measurement bytes
// crcValid indicates if data integrity check passed
},
failed: (String message, int? code, int? receivedBytes) {
// Download failed
// receivedBytes can be used for resume functionality
},
);
Note: Fetch completion is detected when the device sends an empty array (per BLE spec), not by tracking expected bytes. Use a spinner for UI feedback during download.
FetchProgress Helper Properties
| Property | Type | Description |
|---|---|---|
isActive |
bool |
True if currently downloading |
isDone |
bool |
True if completed or failed |
isSuccess |
bool |
True if completed successfully |
Resuming a Fetch #
If a fetch is interrupted, you can resume from where it left off:
// Resume from byte 5000
final result = await repository.fetchMeasurement(
device.device,
startIndex: 5000,
);
Stopping a Fetch #
To cancel an ongoing download:
final result = await repository.stopFetchMeasurement(device.device);
result.fold(
(failure) => print('Error: ${failure.message}'),
(stopResult) => stopResult.when(
success: () => print('Fetch stopped'),
failure: (message, code) => print('Failed: $message'),
),
);
Error Handling #
All repository methods return Either<VtjFailure, T>:
final result = await repository.connectToDevice(device);
// Using fold
result.fold(
(failure) {
// Handle failure
print('Error: ${failure.message}');
},
(success) {
// Handle success
},
);
// Using pattern matching
result.when(
left: (failure) => handleError(failure),
right: (success) => handleSuccess(success),
);
// Using isLeft/isRight
if (result.isLeft()) {
final failure = result.getLeft().getOrElse(() => throw Exception());
print('Failed: ${failure.message}');
}
Common VtjFailure Types #
| Failure | Description |
|---|---|
VtjFailure.scanningError() |
BLE scanning failed |
VtjFailure.connectionFailed() |
Could not connect to device |
VtjFailure.commandFailed() |
BLE command execution failed |
VtjFailure.timeout() |
Operation timed out |
VtjFailure.permissionDenied() |
Missing BLE/location permissions |
Troubleshooting #
Common Issues #
-
Bluetooth not enabled:
if (await FlutterBluePlus.isAvailable) { // Bluetooth is available } if (await FlutterBluePlus.isOn) { // Bluetooth is turned on } -
Location Services: On Android, ensure location services are enabled for BLE scanning.
-
Permissions: Always check and request necessary permissions before scanning:
// Check and request permissions before scanning // Use permission_handler package -
Device shows as "Busy": The device is currently recording. Wait for measurement to complete or power cycle the device.
-
Authentication fails: Verify the device key is correct. Each device has a unique key.
-
CRC validation fails: Data corruption during transfer. Try fetching again.
-
Fetch incomplete: BLE connection may have dropped. Reconnect and resume from last received byte.
Debug Logging #
Enable Flutter Blue Plus logging for debugging:
FlutterBluePlus.setLogLevel(LogLevel.verbose);
Complete Workflow Example #
import 'package:vtj_bluetooth_package/vtj_bluetooth_package.dart';
Future<void> completeWorkflow() async {
final datasource = BluePlusVtjDatasource();
final repository = VtjDevicesRepositoryImpl(datasource);
// 1. Scan for devices
await repository.startScan();
VtjDevice? targetDevice;
final scanSub = repository.getScanResults().listen((result) {
result.fold(
(f) => print('Scan error'),
(devices) {
if (devices.isNotEmpty && targetDevice == null) {
targetDevice = devices.first;
}
},
);
});
// Wait for device discovery
await Future.delayed(Duration(seconds: 5));
await scanSub.cancel();
if (targetDevice == null) {
print('No device found');
return;
}
// 2. Connect
final connectResult = await repository.connectToDevice(targetDevice!);
if (connectResult.isLeft()) {
print('Connection failed');
return;
}
final device = targetDevice!.device;
// 3. Authenticate
final authResult = await repository.authenticateDevice(device, 'your-key');
if (authResult.isLeft() || !authResult.getOrElse(() => false)) {
print('Authentication failed');
return;
}
// 4. Check device state
final stateResult = await repository.getState(device);
stateResult.fold(
(f) => print('Error getting state'),
(state) {
if (state.hasData) {
print('Device has existing data - delete or fetch first');
}
},
);
// 5. Start measurement with progress tracking
final measureSub = repository.measurementProgressStream(device).listen((p) {
p.when(
idle: () {},
inProgress: (e, t, f) => print('Recording: $e/$t sec'),
completed: (t) => print('Recording done!'),
failed: (m, c, e) => print('Recording failed: $m'),
);
});
final config = MeasurementConfig(
durationSeconds: 10,
measurementType: MeasurementType.hz550,
);
await repository.startMeasurement(device, config);
// Wait for completion
await Future.delayed(Duration(seconds: 12));
await measureSub.cancel();
// 6. Fetch data with progress tracking
final fetchSub = repository.fetchProgressStream(device).listen((p) {
p.when(
idle: () {},
inProgress: (received) => print('Downloading... $received bytes received'),
completed: (data, size, valid) {
print('Downloaded $size bytes, CRC valid: $valid');
// Process data here
},
failed: (m, c, r) => print('Download failed: $m'),
);
});
await repository.fetchMeasurement(device);
// Wait for download
await Future.delayed(Duration(seconds: 30));
await fetchSub.cancel();
// 7. Disconnect
await repository.disconnect(device);
print('Workflow complete!');
}
License #
This project is licensed under the MIT License - see the LICENSE file for details.
Support #
For support, please open an issue in the GitHub repository or contact support@ventriject.com.