Created
October 17, 2018 15:29
-
-
Save espresso3389/046aba8c5b88cc50c006d5556818f84c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.