Skip to content

Instantly share code, notes, and snippets.

@espresso3389
Created October 17, 2018 15:29
Show Gist options
  • Save espresso3389/046aba8c5b88cc50c006d5556818f84c to your computer and use it in GitHub Desktop.
Save espresso3389/046aba8c5b88cc50c006d5556818f84c to your computer and use it in GitHub Desktop.
/*
* The MIT License
*
* Further resources on the MIT License
* Copyright 2018 Cuminas Corporation/@espresso3389
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import 'dart:async';
import 'dart:typed_data';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_blue/flutter_blue.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:rxdart/subjects.dart';
import 'package:rxdart/rxdart.dart';
import 'package:synchronized/synchronized.dart';
import 'logUtils.dart';
import 'binary.dart';
import 'nativeChannel.dart';
/// UUIDs defined on [GATT Specifications](https://www.bluetooth.com/specifications/gatt/generic-attributes-overview)
class GattUUIDs {
/// Service: 0x180F=[Battey Service](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.battery_service.xml)
static final batteryService = uuid16(0x180F);
/// Characteristic: 0x2A19=[Battery Level](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.battery_level.xml)
static final batteryLevel = uuid16(0x2A19);
/// Service: 0x180A=[Device Information](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.device_information.xml)
static final deviceInformation = uuid16(0x180A);
/// Characteristic: 0x2A29=[Manufacturer Name String](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.manufacturer_name_string.xml)
static final manufacturerName = uuid16(0x2A29);
/// Characteristic: 0x2A24=[Model Number String](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.model_number_string.xml)
static final modelNumber = uuid16(0x2A24);
/// Characteristic: 0x2A25=[Serial Number String](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.serial_number_string.xml)
static final serialNumber = uuid16(0x2A25);
/// Characteristic: 0x2A26=[Firmware Revision String](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.firmware_revision_string.xml)
static final firmwareRevision = uuid16(0x2A26);
/// Characteristic: 0x2A28=[Software Revision String](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.software_revision_string.xml)
static final softwareRevision = uuid16(0x2A28);
/// Characteristic: 0x2A50=[PnP ID](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.pnp_id.xml)
static final pnPID = uuid16(0x2A50);
/// BLE defines uuid16 to describe services/characteristics in shorter and easier
/// form.
/// https://www.bluetooth.com/specifications/gatt/services
static Guid uuid16(int uuid16) {
var b4 = uuid16.toRadixString(16);
b4 = b4.padLeft(4 - b4.length, '0');
return Guid('0000$b4-0000-1000-8000-00805F9B34FB');
}
}
/// BLE UUIDs for Wacom Bamboo Slate services/chracteristics.
class BambooUUIDs {
static final uartService = Guid("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
static final uartTX = Guid("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
static final uartRX = Guid("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
static final offlineService = Guid("FFEE0001-BBAA-9988-7766-554433221100");
static final offlinePenData = Guid("FFEE0003-BBAA-9988-7766-554433221100");
static final stateNotificationService = Guid("3A340720-C572-11E5-86C5-0002A5D5C51B");
static final stateNotificationC1 = Guid("3A340721-C572-11E5-86C5-0002A5D5C51B");
}
/// Class to manage multiple Bamboo Slate devices.
class BambooSlateManager {
BambooSlateManager();
/// not to run discoverServices concurrently on several devices.
final _exclusiveLock = Lock();
/// BLE Device ID to Bamboo Device mapping.
/// Note that the device ID is iOS device-wide and could not be shared between
/// iOS devices.
final _bambooDevices = Map<DeviceIdentifier, BambooDevice>();
/// Stream to notify BLE/Bamboo Slate events to subscribers.
final _changeStream = BehaviorSubject<List<BambooDevice>>();
/// Subscription for scanning to stop it.
BehaviorSubject<ScanResult> _scanSub;
/// Preference instance.
SharedPreferences _prefs;
void dispose() {
LogUtils.print('Releasing BambooSlateManager...');
stopScan();
_changeStream.close();
_bambooDevices.values.forEach((b) => b.dispose());
_bambooDevices.clear();
}
Future<SharedPreferences> _getPrefs() async {
if (_prefs == null)
_prefs = await SharedPreferences.getInstance();
return _prefs;
}
/// Get Bamboo Slate devices currently managed by the instance.
List<BambooDevice> get bambooDevices => _bambooDevices.values.toList();
Stream<List<BambooDevice>> get changes => _changeStream.stream;
/// FlutterBlue scan function wrapper.
Observable<ScanResult> _scanBle({Duration timeout, bool useFoundList = true}) {
stopScan();
final found = Set<String>();
final sub = _scanSub = BehaviorSubject<ScanResult>(seedValue: null);
final scan = FlutterBlue.instance.scan().listen(
(scanResult) {
LogUtils.print("Found: ${scanResult.device.id.id}: connectable=${scanResult.advertisementData.connectable}, adv=${scanResult.advertisementData.manufacturerData.length}");
// If the device is either not-connectable or no advertisement data,
// anyway, we will ignore that device.
if (!scanResult.advertisementData.connectable ||
scanResult.advertisementData.manufacturerData.length == 0)
return;
if (useFoundList && found.contains(scanResult.device.id.id))
return;
LogUtils.print('Found: ${scanResult.device.id}');
found.add(scanResult.device.id.id);
sub.add(scanResult);
});
sub.listen(null,
onDone: () {
LogUtils.print('Scanning stopped.');
scan.cancel();
},
onError: (e) {
LogUtils.print('Scanning stopped due to error: $e');
scan.cancel();
});
if (timeout != null)
Future.delayed(timeout, () => sub.close());
return sub.stream;
}
void stopScan() {
if (_scanSub != null && !_scanSub.isClosed)
_scanSub.close();
_scanSub = null;
}
/// Scan known (pre-paired) devices.
/// If you don't specify [timeout], the scanning does not stop without calling
/// [stopScan] method.
Future<Stream<BambooDevice>> scanKnownDevices({Duration timeout = const Duration(seconds: 15)}) async {
stopScan();
await _getPrefs();
var knownDevices = Set<String>.from(_prefs.getStringList('devices') ?? <String>[]);
if (knownDevices.length == 0) {
LogUtils.print('No pre-registered device.');
return Stream<BambooDevice>.empty(); // No devices yet!
}
LogUtils.print('Trying to resume connection to ${knownDevices.length} known device(s).');
knownDevices.forEach((d) => LogUtils.print(' $d'));
// publish known devices anyway
knownDevices.forEach((id) {
final did = DeviceIdentifier(id);
if (_bambooDevices.containsKey(did))
return;
final device = BluetoothDevice(id: did);
final bdev = BambooDevice._(device, this);
_bambooDevices[device.id] = bdev;
_notify();
});
// scanning will update device status.
LogUtils.print('Scanning known devices...');
var count = 0;
return _scanBle(timeout: timeout).map(
(scanResult) {
final device = scanResult.device;
if (knownDevices.contains(device.id.id)) {
if (++count == knownDevices.length) {
LogUtils.print('OK, all of known devices found; stop scanning.');
stopScan();
}
final bdev = _bambooDevices[device.id];
bdev?.ensureConnected();
return bdev;
}
return null;
}
).where((bdev) => bdev != null);
}
/// Pair a new device.
/// If you don't specify [timeout], the scanning does not stop without calling
/// [stopScan] method.
Future<BambooDevice> scanToPairDevice(void promptUserToPressButton(), {Duration timeout}) async {
stopScan();
await _getPrefs();
LogUtils.print('Scanning for a new device...');
final comp = Completer<BambooDevice>();
final invesigating = Set<DeviceIdentifier>();
var deviceFound = false;
_scanBle(timeout: timeout, useFoundList: false).listen(
(scanResult) async {
try {
if (!_precheckBambooSlate(scanResult)) {
return; // not our interest :(
}
/// initiate the actual pairing process.
var device = scanResult.device;
deviceFound = true;
if (_bambooDevices.containsKey(device.id)) {
var bdevKnown = _bambooDevices[device.id];
LogUtils.print('${bdevKnown.shortId}: known device; ignore it.');
return;
}
if (invesigating.contains(device.id))
return;
invesigating.add(device.id);
var bdev = BambooDevice._(device, this);
if (!await bdev.register(promptUserToPressButton)) {
LogUtils.print('${bdev.shortId}: register failed :(');
bdev.dispose();
invesigating.remove(device.id);
return;
}
// OK, success.
_bambooDevices[device.id] = bdev;
invesigating.remove(device.id);
comp.complete(bdev);
_notify();
stopScan();
} catch (e) {
}
},
onDone: () async {
stopScan();
if (!deviceFound)
comp.complete(null);
});
return await comp.future;
}
/// Determine whether the scanned device is our target or not.
bool _precheckBambooSlate(ScanResult scanResult) {
//_dumpScanResult(scanResult);
// Data to tell Bamboo Slate devices from others.
const manufactureDataKey = 18261;
const expectedManufactureData = '2d434c52'; // '2d434c522e7361'
var adv = scanResult.advertisementData;
if (!adv.manufacturerData.containsKey(manufactureDataKey))
return false;
var manufactureData = hex(adv.manufacturerData[manufactureDataKey]);
return manufactureData.startsWith(expectedManufactureData);
}
void _dumpScanResult(ScanResult scanResult) {
var adv = scanResult.advertisementData;
print('${scanResult.device.id}: ${scanResult.advertisementData.localName}');
for (var key in adv.manufacturerData.keys) {
final data = adv.manufacturerData[key];
print('$key: ${hex(data)}');
}
}
/// Get the connect key for the device.
List<int> _getConnectKeyForDevice(BambooDevice device) {
var keyStr = _prefs.getString(_keyStoreNameForDevice(device));
if (keyStr == null)
return null;
return unhex(keyStr);
}
/// Save (cache) name for the device.
Future<void> _setDeviceName(BambooDevice device, String name) async {
await _prefs.setString('devices/${device.shortId}/name', name);
await _prefs.commit();
}
/// Get cached device name.
String _getDeviceName(BambooDevice device) => _prefs.getString('devices/${device.shortId}/name');
/// Memorize (add) the device with its associated [connectKey].
Future<void> _memorizeDevice(BambooDevice device, List<int> connectKey) async {
await _prefs.setString(_keyStoreNameForDevice(device), hex(connectKey));
var devices = Set<String>.from(_prefs.getStringList('devices') ?? <String>[]);
devices.add(device.device.id.id);
await _prefs.setStringList('devices', devices.toList());
await _prefs.commit();
LogUtils.print('${device.shortId}: registered.');
_bambooDevices[device.device.id] = device;
_notify();
}
/// Forgot (remove) the device.
Future<void> _forgetDevice(BambooDevice device) async {
await _prefs.remove(_keyStoreNameForDevice(device));
var devices = Set<String>.from(_prefs.getStringList('devices') ?? <String>[]);
devices.remove(device.device.id.id);
await _prefs.setStringList('devices', devices.toList());
await _prefs.commit();
LogUtils.print('${device.shortId}: remove from registry.');
_bambooDevices.remove(device.device.id);
_notify();
}
void _notify() {
_changeStream.sink.add(_bambooDevices.values.toList());
}
/// Key for the connect key store.
String _keyStoreNameForDevice(BambooDevice device) => 'devices/${device.device.id}/key';
}
enum _BambooDeviceType {
unknown,
other,
bambooSlate
}
enum BambooDeviceVerifyState {
failToConnect,
unknown,
otherTypeDevice,
bambooSlateVerified,
needReregister,
readyToUse,
}
enum BambooChargeStatus {
unknown,
notCharging,
charging,
}
class BambooDevice {
final _lock = Lock(reentrant: true);
final _subject = BehaviorSubject<String>();
final BluetoothDevice device;
final BambooSlateManager manager;
List<int> _connectKey;
StreamSubscription<BluetoothDeviceState> _sub;
_BambooCommandQueueWithUART _commandQueue;
StreamSubscription<List<int>> _mysSub;
StreamSubscription<List<int>> _batSub;
BluetoothCharacteristic _offlineData;
Timer _keepAliveTimer;
int _timerCount = 0;
Future<bool> _futConnect;
Future<_BambooDeviceType> _futGetThingsReady;
Int32List _drawings;
final _syncTimeoutWatchers = List<Timer>();
String _name;
int _width = 0;
int _height = 0;
int _batteryLevel = -1;
BambooChargeStatus _charging = BambooChargeStatus.unknown;
String _firmwareVersion;
BambooDeviceVerifyState _verifyState = BambooDeviceVerifyState.unknown;
int _countDrawingsAvailableOnDevice = 0;
int _countUnexpected = 0;
DateTime _connectionTimeStamp;
DateTime _lastRxTimeStamp;
int _syncingDrawings = 0;
int _bytesTotal = 0;
int _bytesReceived = 0;
List<Uint8List> _lastDrawingBytes;
BambooDevice._(this.device, this.manager) {
_connectKey = manager._getConnectKeyForDevice(this);
_name = manager._getDeviceName(this);
}
/// Notify changes of the device; sometimes the changes are too much to
/// use directly with GUI; use [Observable.bufferTime] or such to reduce
/// frequent changes.
/// ### Buffer change events every 300 ms.
///
/// BambooDevice dev = ...;
/// dev.changes
/// .bufferTime(Duration(milliseconds: 300))
/// .listen((events) {
/// // Do some UI invalidation here
/// })
///
Observable<String> get changes => _subject.stream;
/// First 4 bytes of device UUID.
String get shortId => device.id.toString().substring(0, 8);
/// Whether the device is known to the manager (used in the past) or not.
bool get isKnownDevice => _connectKey != null;
/// Human-readable name of device.
String get name => _name;
/// Whether device is verified at least once or not.
bool get isVerified => _width != 0;
/// Width of canvas in pixels.
int get width => _width;
/// Height of canvas in pixels.
int get height => _height;
/// Battery level in [0 100] or -1 for error.
int get batteryLevel => _batteryLevel;
/// Whether battey is charging or not.
BambooChargeStatus get chargeStatus => _charging;
/// Firmware version.
String get firmwareVersion => _firmwareVersion;
/// Device verify state.
BambooDeviceVerifyState get deviceVerifyState => _verifyState;
/// Number of drawings available on device.
int get countDrawingsAvailableOnDevice => _countDrawingsAvailableOnDevice;
/// Connection established timestamp.
DateTime get connectionTimeStamp => _connectionTimeStamp;
/// Last RX timestamp.
/// Please note that changes on the member is not normally notified by [changes].
DateTime get lastRxTimeStamp => _lastRxTimeStamp;
/// Whether synchronizing drawings or not.
bool get isSynchronizingDrawings => _syncingDrawings > 0;
/// Drawing bytes receiving total.
int get bytesTotal => _bytesTotal;
/// Drawign bytes downloaded so far.
int get bytesReceived => _bytesReceived;
/// For debugging purpose only.
List<Uint8List> get lastDrawingBytes => _lastDrawingBytes;
/// Identify the returned (known) device.
Future<BambooDeviceVerifyState> ensureConnected({bool regitered = false}) {
return _sync('ensureConnected', () async {
var oldState = _verifyState;
// if the pairing is broken, we do not do anything with the device.
if (oldState == BambooDeviceVerifyState.needReregister)
return oldState;
_verifyState = await _ensureDeviceReady(regitered);
if (oldState != _verifyState)
_subject.add('verifystate');
return _verifyState;
}, timeout: Duration(seconds: 10), defValue: BambooDeviceVerifyState.failToConnect);
}
Future<BambooDeviceVerifyState> _ensureDeviceReady(bool regitered) async {
if (!isKnownDevice) {
LogUtils.print('$shortId: no associated key found.');
return BambooDeviceVerifyState.unknown;
}
// connect
var state = await _establishConnection();
if (state != BambooDeviceVerifyState.bambooSlateVerified) {
LogUtils.print('$shortId: not a Bamboo Slate: $state');
_disconnect();
return state;
}
var sc = await _checkConnection();
if (!regitered && sc == BambooStatusSubcode.wrongDevice) {
LogUtils.print('$shortId: _checkConnection returns $sc');
_disconnect();
return BambooDeviceVerifyState.needReregister;
}
// OK, all the investigation process green.
LogUtils.print('$shortId: verified.');
return BambooDeviceVerifyState.readyToUse;
}
/// Remove the device.
Future<void> removeDevice() async {
LogUtils.print('$shortId: device removed.');
_connectKey = null;
_disconnect();
await manager._forgetDevice(this);
}
/// Register (pair) the new device.
/// [promptUserToPressButton] is called to prompt user to press the button on the device.
Future<bool> register(void promptUserToPressButton()) {
return _sync('register', () async {
// connect
var state = await _establishConnection();
if (state != BambooDeviceVerifyState.bambooSlateVerified)
return false;
return await _registrationConfirmationByButtonPush(promptUserToPressButton);
});
}
Future<BambooDeviceVerifyState> _establishConnection() async {
if (!await _connect()) {
LogUtils.print('$shortId: failed to connect.');
return BambooDeviceVerifyState.failToConnect;
}
try {
// detect device type
var type = await _getThingsReady();
if (type == _BambooDeviceType.bambooSlate) {
await _getDeviceStatus();
return BambooDeviceVerifyState.bambooSlateVerified; // ***succeeded!
}
// unknown or other device
if (type == _BambooDeviceType.unknown)
return BambooDeviceVerifyState.unknown;
return BambooDeviceVerifyState.otherTypeDevice;
} catch (e) {
LogUtils.print('$shortId: _establishConnection: $e');
return BambooDeviceVerifyState.failToConnect;
}
}
///
Future<_BambooDeviceType> _getThingsReady() {
if (_futGetThingsReady == null) {
_futGetThingsReady = _getThingsReadyInternal();
}
return _futGetThingsReady;
}
Future<_BambooDeviceType> _getThingsReadyInternal() async {
try {
LogUtils.print('$shortId: discovering services...');
final services = await _sync(
'discoverServices', () => device.discoverServices(),
timeout: Duration(seconds: 20), lock: manager._exclusiveLock);
if (services == null) {
LogUtils.print('$shortId: discoverServices: timeout or error.');
return _BambooDeviceType.unknown;
}
var uuid2chars = services
.expand((s) => s.characteristics)
.fold(
Map<Guid, BluetoothCharacteristic>(), (m, c) {
m[c.uuid] = c;
return m;
});
// check PnP ID
var pnpIdChar = uuid2chars[GattUUIDs.pnPID];
if (pnpIdChar == null) {
LogUtils.print('$shortId: No PnP ID available.');
return _BambooDeviceType.other;
}
var pnpId = await device.readCharacteristic(pnpIdChar);
const BambooSlatePnpId = '026a0529030100';
if (hex(pnpId) != BambooSlatePnpId) {
LogUtils.print('$shortId: Not Bamboo Slate ${hex(pnpId)}');
return _BambooDeviceType.other;
}
// State notification service
var mys = uuid2chars[BambooUUIDs.stateNotificationC1];
if (mys == null) {
LogUtils.print('$shortId: StateNotificationC1 not available.');
return _BambooDeviceType.other;
}
await device.setNotifyValue(mys, true);
_mysSub = device.onValueChanged(mys).listen((data) {
if (data.length >= 4) {
_batteryLevel = data[2];
_charging = data[3] == 1 ? BambooChargeStatus.charging : BambooChargeStatus.notCharging;
_subject.sink.add('bat=$_batteryLevel');
}
//LogUtils.print('$shortId: StateNotificationC1: ${hex(data)}');
});
var bat = uuid2chars[GattUUIDs.batteryLevel];
if (bat == null) {
LogUtils.print('$shortId: batteryLevel not available.');
return _BambooDeviceType.other;
}
await device.setNotifyValue(bat, true);
_batSub = device.onValueChanged(bat).listen((data) {
LogUtils.print('$shortId: Battery: ${hex(data)}');
});
// UART TX
var tx = uuid2chars[BambooUUIDs.uartTX];
if (tx == null) {
LogUtils.print('$shortId: UART TX not available.');
return _BambooDeviceType.other;
}
// UART RX
var rx = uuid2chars[BambooUUIDs.uartRX];
if (rx == null) {
LogUtils.print('$shortId: UART RX not available.');
return _BambooDeviceType.other;
}
_commandQueue = _BambooCommandQueueWithUART(shortId, device, tx, rx,
unexpected: (data) async {
_countUnexpected++;
if (_countUnexpected > 20) {
// there seems some other app running in background and we could not
// co-exist with that app :(
LogUtils.print('$shortId: CRITICAL: it seems that some other app is using the device concurrently. We cannot co-exist with it.');
_verifyState = BambooDeviceVerifyState.needReregister;
_subject.sink.add('disconnected');
_disconnect();
}
},
onRx: (data) {
_lastRxTimeStamp = DateTime.now();
_subject.sink.add('rxtime');
});
// Wacom Offline Service
_offlineData = uuid2chars[BambooUUIDs.offlinePenData];
if (_offlineData == null) {
LogUtils.print('$shortId: Offline service not available.');
return _BambooDeviceType.other;
}
const pollingIntervalSec = 5;
const reactivateIntervalSec = 5 * 60; // 5 minutes.
const reactivateIntervalCount = reactivateIntervalSec / pollingIntervalSec;
// Low frequency keep-alive timer
_keepAliveTimer = Timer.periodic(Duration(seconds: 5), (t) async {
try {
if (_syncingDrawings > 0)
return; // don't poll if downloading drawings
await _sync('keep-alive', () async {
await _getCountOfDrawingsAvailable();
}, timeout: Duration(seconds: 5));
_timerCount++;
if (_timerCount % reactivateIntervalCount == reactivateIntervalCount - 1) {
_commandQueue.reactivate();
await syncDrawings();
}
} catch (e) {
LogUtils.print('$shortId: keep-alive timer routine: $e');
_killKeepAliveTimer();
}
});
LogUtils.print('$shortId: Bamboo Slate connected!');
_subject.sink.add('inited');
return _BambooDeviceType.bambooSlate;
} catch (e) {
_subject.sink.add('init-error');
throw e;
}
}
void _killKeepAliveTimer() {
if (_keepAliveTimer != null && _keepAliveTimer.isActive) {
_keepAliveTimer.cancel();
_keepAliveTimer = null;
LogUtils.print('$shortId: keep-alive timer stopped.');
}
}
Future<T> _sync<T>(String label, FutureOr<T> action(), {Duration timeout, T defValue, Lock lock}) async {
//print('$shortId: _sync: $label: lock trying to acquire...');
_syncTimeoutWatchers.add(timeout == null ? Timer.periodic(Duration(seconds: 20), (t) {
LogUtils.print('$shortId: _sync: $label: still waiting for function return...');
}) : null);
try {
lock = lock ?? _lock;
return await lock.synchronized(action, timeout: timeout);
} on TimeoutException catch (_) {
LogUtils.print('$shortId: _sync: $label: timed out.');
return defValue;
} finally {
//LogUtils.print('$shortId: _sync: $label: stop watcher.');
_syncTimeoutWatchers.removeLast()?.cancel();
}
}
Future<bool> _connect({Duration timeout = const Duration(seconds: 10)}) {
if (_futConnect == null) {
_futConnect = _connectInternal(timeout: timeout);
}
return _futConnect;
}
// connect a new session.
Future<bool> _connectInternal({Duration timeout = const Duration(seconds: 4)}) async {
if ((await device.state) == BluetoothDeviceState.connected) {
LogUtils.print('$shortId: device is connected.');
return true;
}
var comp = Completer<bool>();
var prevState = BluetoothDeviceState.disconnected;
_sub = FlutterBlue.instance
.connect(device, timeout: timeout)
.listen((state) {
if (state != prevState) {
prevState = state;
LogUtils.print("$shortId: $state");
switch (state) {
case BluetoothDeviceState.connected:
_connectionTimeStamp = DateTime.now();
if (!comp.isCompleted)
comp.complete(true);
_subject.sink.add('connected');
break;
case BluetoothDeviceState.disconnected:
if (_verifyState != BambooDeviceVerifyState.otherTypeDevice && _verifyState != BambooDeviceVerifyState.unknown &&
_verifyState != BambooDeviceVerifyState.needReregister) {
_verifyState = BambooDeviceVerifyState.bambooSlateVerified;
}
_subject.sink.add('disconnected: lifetime=${DateTime.now().difference(_connectionTimeStamp)} from $_connectionTimeStamp');
_disconnect();
break;
default:
break;
}
}
},
onError: (e) {
LogUtils.print('$shortId: error on connect: $e');
comp.complete(false);
_subject.sink.add('connect-error');
});
// FIXME: timeout on FlutterBlue does not seem to work reliably :(
Future.delayed(timeout, () {
if (!comp.isCompleted) {
_sub?.cancel();
_sub = null;
comp.complete(false);
}
});
return await comp.future;
}
void dispose() {
_disconnect();
_subject.close();
}
/// disconnect the session.
void _disconnect() {
LogUtils.print('$shortId: _disconnect');
try {
if (_sub == null)
return;
_syncTimeoutWatchers.forEach((tw) => tw?.cancel());
_syncTimeoutWatchers.clear();
_killKeepAliveTimer();
_commandQueue?.dispose();
_commandQueue = null;
_mysSub?.cancel();
_mysSub = null;
_batSub?.cancel();
_batSub = null;
_offlineData = null;
_sub?.cancel();
_sub = null;
_futGetThingsReady = null;
_futConnect = null;
// these parameters should be 'initialized' for offline state.
_batteryLevel = -1;
_charging = BambooChargeStatus.unknown;
_countDrawingsAvailableOnDevice = 0;
_countUnexpected = 0;
_subject.sink.add('disconnected');
} catch (e) {
LogUtils.print('$shortId: error on disconnect: $e');
}
}
Future<bool> _registrationConfirmationByButtonPush(void promptUserToPressButton()) async {
var newConnectKey = generateRandom(6);
var result = await _registrationConfirmationByButtonPushWithConnectKey(newConnectKey, promptUserToPressButton);
if (result) {
// Yeah, the registration success! Keep the connect key.
_connectKey = newConnectKey;
await manager._memorizeDevice(this, newConnectKey);
await ensureConnected(regitered: true);
await _getDeviceStatus();
return true;
}
return false;
}
/// Internal: try to register the device with the specified [connectKey].
Future<bool> _registrationConfirmationByButtonPushWithConnectKey(List<int> connectKey, void promptUserToPressButton()) async {
bool stillWaiting = true;
var fDelay = () async {
await Future.delayed(Duration(milliseconds: 300));
if (stillWaiting && promptUserToPressButton != null)
promptUserToPressButton();
};
fDelay();
const BambooResponseCode = 0xe4;
//const IntuosProResponseCode = 0x53; // NOT USED
var result = await _sendCommand(0xe7, arguments: connectKey, expectedValues: [BambooResponseCode]);
stillWaiting = false;
if (result.succeeded) {
LogUtils.print('$shortId: Bamboo Slate button confirmation finished!');
return true;
}
// Possible values: 0xb3, 0x53 but not for Bamboo Slate
if (result.code == 0xb3 && result.data.length == 1) {
LogUtils.print('$shortId: not a pairing target: code=${h1(result.code)}, data=${_subcodeToStr(result.data[0])}');
} else {
LogUtils.print('$shortId: not a pairing target: code=${h1(result.code)}, data=${hex(result.data)}');
}
return false;
}
/// Obtain device status.
Future<void> _getDeviceStatus() async {
await _checkConnection();
await _syncTime();
var date = await _getTime();
var hw = await _getDimensions();
var isAvail = await _getCountOfDrawingsAvailable();
var firmVer = await _getFirmwareVersion();
var batLevel = await _getBatteyLevel();
_name = await _getName() ?? _name;
if (hw != null) {
_height = hw[0]; // 29700 μm (297 mm)
_width = hw[1]; // 21600 μm (216 mm)
}
if (batLevel != null) {
_batteryLevel = batLevel.batteryLevel;
_charging = batLevel.isCharging ? BambooChargeStatus.charging : BambooChargeStatus.notCharging;
} else {
_batteryLevel = -1;
_charging = BambooChargeStatus.unknown;
}
if (firmVer != null)
_firmwareVersion = firmVer;
LogUtils.print('$shortId: \"${device.name}\": \"$_name\", ${_width}x$_height, isAvail=$isAvail, date=$date, battery=$_batteryLevel%, isCharging=$_charging, firmVersion=$firmVer');
_subject.sink.add('status');
}
/// Get currently cached drawings.
Int32List get drawings => _drawings;
/// Clear cached drawings.
void clearDrawings() {
_drawings = null;
_subject.sink.add('drawings');
}
/// Sync device's offline drawings.
/// `w,h,x,y,x,y,x,y,-1,x,y,x,y,-1,...,-1`
Future<Int32List> syncDrawings() async {
if (_syncingDrawings > 0) {
LogUtils.print('$shortId: syncDrawings: already running.');
return _drawings;
}
return await _sync('syncDrawings', () async {
_syncingDrawings++;
_subject.sink.add('drawings');
try {
if (await ensureConnected() != BambooDeviceVerifyState.readyToUse)
return _drawings;
final newDrawings = await _downloadDrawings();
if (_drawings == null)
return _drawings = Int32List.fromList(newDrawings);
_drawings = Int32List.fromList(List<int>.from(_drawings)..addAll(newDrawings.skip(2)));
return _drawings;
} finally {
_syncingDrawings--;
_subject.sink.add('drawings');
}
}, timeout: Duration(minutes: 5) /* Timeout long enough; downloading drawings may take so long time :( */ );
}
/// Download device's offline drawings. (internal)
Future<List<int>> _downloadDrawings() async {
LogUtils.print('$shortId: downloading drawings...');
await _getDeviceStatus();
await _b1Command();
// enable receiving offline data
await device.setNotifyValue(_offlineData, true);
var retBuffer = <int>[];
retBuffer.add(_width);
retBuffer.add(_height);
final strokeDebugEnabled = (await NativeChannel.methodChannel.invokeMethod('strokeDebug')) == true;
final fullStrokeData = strokeDebugEnabled ? List<Uint8List>() : null;
while ((await _getCountOfDrawingsAvailable()) > 0) {
// estimated byte size, timestamp
var result = await _sendCommand(0xcc, expected: 0xcf);
if (!result.succeeded) {
LogUtils.print('$shortId: command 0xcc failed: ${hex(result.data)}');
break;
}
_bytesTotal = le32(result.data);
_bytesReceived = 0;
_subject.sink.add('stroke-data-receiving');
var strokeTimeStamp = _parseDateTime(result.data.skip(4));
LogUtils.print('$shortId: stroke data: estimated=$_bytesTotal bytes, time-stamp=$strokeTimeStamp');
// setup data listener
var strokeData = <int>[];
var pub = device.onValueChanged(_offlineData).listen((data) {
strokeData.addAll(data);
_bytesReceived += data.length;
_subject.sink.add('stroke-data-receiving');
});
// initiating data transfer
result = await _sendCommand(0xc3, expected: 0xc8);
if (!result.succeeded || result.data.length == 0 ||
result.data[0] != 0xbe /* begin */) {
LogUtils.print('$shortId: stroke: unexpected data: ${result.data[0]}');
pub.cancel();
break;
}
// Here the listener above is notified multiple times to build strokeData
// << waiting for end... >>
// wait for data end
result = await _commandQueue.waitForData([0xc8]);
pub.cancel();
if (!result.succeeded || result.data.length == 0 ||
result.data[0] != 0xed /* end */) {
LogUtils.print('#shortId: stroke: unexpected data: ${result.data[0]}');
break;
}
LogUtils.print('$shortId: stroke data: ${strokeData.length} bytes received.');
await _sendCommand(0xca, expected: 0xb3); // ack
_processStrokeData(retBuffer, strokeData, strokeTimeStamp);
if (fullStrokeData != null)
fullStrokeData.add(Uint8List.fromList(strokeData)); // remove header
}
await device.setNotifyValue(_offlineData, false);
if (fullStrokeData.length > 0) // to prevent data-loss due to regluar poll
_lastDrawingBytes = fullStrokeData;
_subject.sink.add('stroke-data-received');
LogUtils.print('$shortId: stroke data received.');
return retBuffer;
}
void _processStrokeData(List<int> retBuffer, List<int> data, DateTime date) {
final eq = const IterableEquality<int>();
// compare header
if (!eq.equals(data.sublist(0, 4), [0x62, 0x38, 0x62, 0x74] /* 'b8bt' */)) {
LogUtils.print('$shortId: stroke data header broken or unknown: ${hex(data.sublist(0, 4))}');
return;
}
// x,y,z
var coords = [0, 0, 0];
var deltas = [0, 0, 0];
for (var offset = 4; offset < data.length; ) {
//
// parse a packet
//
final flags = data[offset];
offset++;
// flags' least two bits have special meaning; they're defining opcode byte-width
final opcode = (((flags & 1) != 0) ? data[offset] : 0) | ((flags & 2) != 0 ? data[offset + 1] << 8 : 0);
var mask = flags;
// each bit indicates the existence of corrsponding data byte (1=exist, 0=not-exist)
var args = <int>[];
while (mask != 0) {
args.add((mask & 1) != 0 ? data[offset++] : 0);
mask >>= 1;
}
var isDraw = false;
if (opcode == 0xeeff) {
// draw-stroke operation
//var time = _le32(args.skip(4)) * 0.005;
//print('$shortId: stroke');
} else if (eq.equals(args, const [0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])) {
// end-of-stroke
//print('$shortId: end of stroke');
retBuffer.add(-1);
} else if (eq.equals(args, const [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])) {
// end-of-sequence
//print('$shortId: packet: end of sequence');
} else {
isDraw = true;
}
// x,y,z
for (var c = 0; c < 3; c++) {
final i = c * 2 + 2;
final chk = (flags >> i) & 3;
if (chk == 3) {
// absolute coordinate encoding
deltas[c] = 0;
coords[c] = args[i] + args[i + 1] * 256;
} else {
// relative coordinate encoding
if ((chk & 2) != 0)
deltas[c] += signed8(args[i + 1]);
coords[c] += deltas[c];
}
}
if (!isDraw)
continue;
if ((flags & 0x3c) == 0) // no X,Y changes
continue;
// unit: 10 μm
var y = coords[0];
var x = _width - coords[1];
// Remove z (pressure) for memory efficiency
//var z = coords[2];
//LogUtils.print('$shortId: $idx: ${coords[0]},${coords[1]},${coords[2]}');
retBuffer.addAll([x, y]);
}
}
/// Check connection
Future<BambooStatusSubcode> _checkConnection() async {
var result = await _sendCommand(0xe6, arguments: _connectKey, expected: 0xb3);
if (!result.succeeded)
return BambooStatusSubcode.unexpectedAnswer;
return BambooStatusSubcode.values[result.data[0]];
}
/// Synchronize slate's clock with host device one.
Future<bool> _syncTime() async {
var now = DateTime.now().toUtc();
var data = [now.year - 2000, now.month, now.day, now.hour, now.minute, now.second]
.map((v) => v ~/ 10 * 16 + v % 10);
var result = await _sendCommand(0xb6, arguments: data, expected: 0xb3);
return result.succeeded;
}
/// Get slate's clock.
Future<DateTime> _getTime() async {
var result = await _sendCommand(0xb6, expected: 0xbd);
if (!result.succeeded)
return null;
return _parseDateTime(result.data);
}
/// Parse packed date time in [YY,MM,DD,HH,MM,SS] format.
DateTime _parseDateTime(Iterable<int> dateBin) {
var data = dateBin.take(6).map((v) => (v >> 4) * 10 + (v & 15)).toList(growable: false);
return DateTime.utc(data[0] + 2000, data[1], data[2], data[3], data[4], data[5]);
}
/// Get battery level [0 100] and charging status.
Future<_BatteryInfo> _getBatteyLevel() async {
var result = await _sendCommand(0xb9, expected: 0xba);
if (result.succeeded && result.data.length >= 2)
return _BatteryInfo(result.data[0], result.data[1] == 1);
return null;
}
/// Get InkScape compatible firmware version string.
Future<String> _getFirmwareVersion() async {
var hi = await _sendCommand(0xb7, arguments: [0], expected: 0xb8);
if (!hi.succeeded) return null;
var lo = await _sendCommand(0xb7, arguments: [1], expected: 0xb8);
if (!lo.succeeded) return null;
return _decryptFirmVer(hi.data) + '-' + _decryptFirmVer(lo.data);
}
/// WHAT? Very cryptic rule to decode firmware version :(
/// Only lowest 4-bits of each byte is meaningful
static String _decryptFirmVer(List<int> data) => String.fromCharCodes(data.skip(1).map((b) => b2c(b & 15)));
/// Get device's human-readable (and modifiable) name.
/// It's not suitable to mechanically identify the device. Only for user's preference.
Future<String> _getName() async {
var result = await _sendCommand(0xbb, expected: 0xbc);
if (result.succeeded) {
final name = utf8.decode(result.data)?.trim() ?? null;
if (name != null)
manager._setDeviceName(this, name);
return name;
}
return null;
}
/// Set device's human-readable (and modifiable) name.
/// It's not suitable to mechanically identify the device. Only for user's preference.
Future<bool> setName(String name) {
return _sync('setName', () async {
if (name == null) name = '';
var result = await _sendCommand(0xbb, arguments: utf8.encode(name.trim() + '\n'), expected: 0xb3);
if (!result.succeeded)
return false;
name = await _getName();
if (name == null)
return false;
_name = name;
_subject.sink.add('name');
return true;
});
}
Future<bool> _ecCommand() async {
var result = await _sendCommand(0xec, arguments: [0x06, 0x00, 0x00, 0x00, 0x00, 0x00], expected: 0xb3);
return result.succeeded;
}
/// May be something like "keep-connection" not to disconnect during some important operation.
Future<bool> _b1Command() async {
var result = await _sendCommand(0xb1, arguments: [0x01], expected: 0xb3);
return result.succeeded;
}
Future<int> _getCountOfDrawingsAvailable() async {
final count = await _getCountOfDrawingsAvailableInternal();
if (_countDrawingsAvailableOnDevice != count) {
_countDrawingsAvailableOnDevice = count;
_subject.sink.add('drawings-count');
}
return count;
}
Future<int> _getCountOfDrawingsAvailableInternal() async {
var result = await _sendCommand(0xc1, expected: 0xc2);
if (!result.succeeded || result.data.length != 2)
return 0;
return result.data[0] + result.data[1] * 256;
}
Future<List<int>> _getDimensions() async {
var w = await _sendCommand(0xea, arguments: [3, 0], expected: 0xeb);
if (!w.succeeded) return null;
var h = await _sendCommand(0xea, arguments: [4, 0], expected: 0xeb);
if (!h.succeeded) return null;
return [w.data, h.data].map((b) => b[2] + b[3] * 0x100 + b[4] * 0x10000 + b[5] * 0x1000000).toList();
}
Future<_BambooCommandResult> _sendCommand(int command, {Iterable<int> arguments, int expected = -1, Iterable<int> expectedValues}) {
if (expectedValues != null && expected >= 0) {
throw('Either one of expectedValues or expected can be specified.');
}
if (_commandQueue == null)
return null;
if (expectedValues == null)
expectedValues = [expected];
return _commandQueue.sendCommand(command, arguments, expectedValues);
}
}
/// Battery information.
class _BatteryInfo {
/// Battery level in [0 100] or -1.
final int batteryLevel;
/// Whether battery is now charging or not.
final bool isCharging;
_BatteryInfo(this.batteryLevel,this.isCharging);
}
class _BambooCommandQueueWithUART extends _BambooCommandQueue {
final BluetoothDevice _device;
final void Function(List<int> data) _onRx;
final BluetoothCharacteristic _rx;
StreamSubscription _sub;
_BambooCommandQueueWithUART(String name, BluetoothDevice device, BluetoothCharacteristic tx, BluetoothCharacteristic rx, {BambooCommandDataFunc unexpected, void Function(List<int> data) onRx}) :
_device = device,
_onRx = onRx,
_rx = rx,
super(name, (data) async {
try {
//LogUtils.print('$name: writeCharacteristic: ${hex(data)}');
await device.writeCharacteristic(tx, data);
} catch (e) {
LogUtils.print('$name: connection closed?: $e');
}
}, unexpected: unexpected)
{
reactivate();
_sub = _device.onValueChanged(_rx).listen((data) {
feedData(data);
if (_onRx != null) _onRx(data);
});
}
Future<void> reactivate() async {
LogUtils.print('$name: reactivating notification on RX...');
await _device.setNotifyValue(_rx, true);
}
void dispose() {
LogUtils.print('$name: UART command queue disposing...');
try { _sub?.cancel(); } catch (e) {}
super.dispose();
}
}
typedef BambooCommandDataFunc = Future<void> Function(Iterable<int>);
/// Single-threaded queue that runs queued commands sequentially.
class _BambooCommandQueue {
final List<_BambooCommand> _queue = List<_BambooCommand>();
final BambooCommandDataFunc _write;
final BambooCommandDataFunc _unexpected;
final String name;
bool waitForResponse = false;
_BambooCommandQueue(this.name, this._write, { BambooCommandDataFunc unexpected }) : _unexpected = unexpected;
void dispose() {
reset();
}
void reset() {
_queue?.clear();
waitForResponse = false;
}
/// Queue a [command] to run.
Future<_BambooCommandResult> sendCommand(int command, Iterable<int> arguments, Iterable<int> expectedValues) {
final comp = Completer<_BambooCommandResult>();
_queue.add(_BambooCommand(command, _toList(arguments), _toList(expectedValues), comp));
_execNextCommand();
return comp.future;
}
/// Wait for data.
Future<_BambooCommandResult> waitForData(Iterable<int> expectedValues) => sendCommand(-1, null, expectedValues);
///
void feedData(Iterable<int> itr) {
final data = (itr as List<int>) ?? itr.toList();
if (_queue.length == 0) {
LogUtils.print('$name: RX: unexpected: data=${hex(data)} (No corresponding command)');
if (_unexpected != null)
_unexpected(data);
return;
}
var cmd = _queue.removeAt(0);
try {
if (data.length < 2) {
throw("$name: RX: unexpected data length: length=${data.length}");
}
var code = data[0];
if (cmd.expectedValues != null && !cmd.expectedValues.contains(code)) {
throw("$name: RX: unexpected status code: status=${h1(code)}, all=[${hex(data)}]");
}
/*
if (cmd.expectedValues.length == 1 && cmd.expectedValues[0] == 0xb3) {
LogUtils.print('$name: RX: code=${h1(code)}, error=${_subcodeToStr(data[2])}');
} else {
var len = data[1];
LogUtils.print('$name: RX: code=${h1(code)}, length=$len, data=${hex(data.skip(2))}');
}
*/
cmd.completer.complete(_BambooCommandResult(
data[0],
data.skip(2).toList(growable: false),
));
waitForResponse = false;
_execNextCommand();
} catch (e) {
//LogUtils.print('$name: feedData: $e');
cmd.completer.complete(_BambooCommandResult(
data.length >=1 ? data[0] : 0,
data.length >=2 ? data.skip(2).toList(growable: false) : <int>[],
errorIfAny: e));
waitForResponse = false;
_execNextCommand();
}
}
Future<void> _execNextCommand() async {
//LogUtils.print('$name: _execNextCommand: wait=$waitForResponse, queue=${_queue.length}');
while (!waitForResponse && _queue.length > 0) {
var cmd = _queue.first;
if (cmd.isRealCommand) {
var args = cmd.arguments ?? const [0];
var data = [cmd.command, args.length];
data.addAll(args);
await _write(data);
//LogUtils.print('$name: TX: command: ${h1(cmd.command)}, data=[${hex(args)}]');
} else {
//LogUtils.print('$name: TX: wait for incoming data...');
}
if (cmd.expectedValues != null) {
waitForResponse = true;
Future.delayed(Duration(seconds: 60), () {
if (_queue.length > 0 && identical(_queue.first, cmd)) {
_queue.removeAt(0);
cmd.completer.completeError(
_TimeoutError(cmd.command >= 0 ? '$name: response timeout: command: ${h1(cmd.command)}, data=[${hex(cmd.arguments)}]' : '$name: no data received.'));
}
});
break;
} else {
_queue.removeAt(0);
}
}
}
}
class _TimeoutError extends StateError {
_TimeoutError(msg) : super(msg);
}
// Status subcodes.
enum BambooStatusSubcode {
/// 0x00 (succeeded)
succeeded,
/// 0x01 (wrong device mode)
wrongDeviceMode,
/// 0x02 (unexpected answer)
unexpectedAnswer,
subcode_3,
subcode_4,
/// 0x05 (invalid opcode)
invalidOpcode,
subcode_6,
/// 0x07 (wrong device)
wrongDevice,
}
/// Convert [subcode] to corresponding string.
String _subcodeToStr(int subcode) {
switch (subcode) {
case 0x0:
return '0x00 (succeeded)';
case 0x1:
return '0x01 (wrong device mode)';
case 0x2:
return '0x02 (unexpected answer)';
case 0x5:
return '0x05 (invalid opcode)';
case 0x7:
return '0x07 (wrong device)';
default:
return '0x${h1(subcode)} (unknown subcode)';
}
}
/// Command definition for Bamboo Slate devices.
class _BambooCommand {
/// Command. If the value is negative, it is not a real command; see [isRealCommand].
final int command;
/// Command arguments.
final List<int> arguments;
/// Expected response status values. It can be [null] if no response is expected.
final List<int> expectedValues;
/// Future completed on response on UART RX.
final Completer<_BambooCommandResult> completer;
/// Whether to throw exception on error or not.
/// Whether it is a real (not-phantom) command or not.
bool get isRealCommand => command >= 0;
_BambooCommand(this.command, this.arguments, this.expectedValues, this.completer);
}
/// Internal; structure to return response from device.
class _BambooCommandResult {
/// Response status code. The most typical one is 0xb3.
final int code;
/// Response data. It could not be null.
final List<int> data;
/// Error if any. Normally it is Error.
final dynamic errorIfAny;
/// Whether the response indicates an error or not.
bool get succeeded => errorIfAny == null;
_BambooCommandResult(this.code, this.data, {this.errorIfAny});
}
List<int> _toList(Iterable<int> it) {
if (it == null) return null;
if (it is List<int>) return it;
return it.toList(growable: false);
}
@espresso3389
Copy link
Author

It's just extracted from our closed source code and I think it's useful to open at least the code only.
I will open the full source code in near future as a Flutter plugin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment