feat: recover bootloader OTA transfers
This commit is contained in:
@ -497,10 +497,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||||
final shifter = _shifterService;
|
|
||||||
if (shifter == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (_firmwareUpdateService != null) {
|
if (_firmwareUpdateService != null) {
|
||||||
return _firmwareUpdateService;
|
return _firmwareUpdateService;
|
||||||
}
|
}
|
||||||
@ -513,7 +509,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
final service = FirmwareUpdateService(
|
final service = FirmwareUpdateService(
|
||||||
transport: ShifterFirmwareUpdateTransport(
|
transport: ShifterFirmwareUpdateTransport(
|
||||||
shifterService: shifter,
|
shifterService: _shifterService,
|
||||||
bluetoothController: bluetooth,
|
bluetoothController: bluetooth,
|
||||||
buttonDeviceId: widget.deviceAddress,
|
buttonDeviceId: widget.deviceAddress,
|
||||||
),
|
),
|
||||||
@ -585,7 +581,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _startStatusStreamingIfNeeded();
|
|
||||||
final updater = await _ensureFirmwareUpdateService();
|
final updater = await _ensureFirmwareUpdateService();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@ -872,9 +867,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
currentConnectionStatus == ConnectionStatus.connected;
|
currentConnectionStatus == ConnectionStatus.connected;
|
||||||
final hasDeviceAccess =
|
final hasDeviceAccess =
|
||||||
isCurrentConnected && _shifterService != null && _latestStatus != null;
|
isCurrentConnected && _shifterService != null && _latestStatus != null;
|
||||||
|
final canUseFirmwareUpdate = isCurrentConnected;
|
||||||
final canSelectFirmware =
|
final canSelectFirmware =
|
||||||
hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||||
final canStartFirmware = hasDeviceAccess &&
|
final canStartFirmware = canUseFirmwareUpdate &&
|
||||||
!_isSelectingFirmware &&
|
!_isSelectingFirmware &&
|
||||||
!_isFirmwareUpdateBusy &&
|
!_isFirmwareUpdateBusy &&
|
||||||
_selectedFirmware != null;
|
_selectedFirmware != null;
|
||||||
@ -907,8 +903,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
status: _latestStatus,
|
status: _latestStatus,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (hasDeviceAccess) ...[
|
if (canUseFirmwareUpdate) ...[
|
||||||
|
_FirmwareUpdateCard(
|
||||||
|
selectedFirmware: _selectedFirmware,
|
||||||
|
progress: _dfuProgress,
|
||||||
|
isSelecting: _isSelectingFirmware,
|
||||||
|
isStarting: _isStartingFirmwareUpdate,
|
||||||
|
canSelect: canSelectFirmware,
|
||||||
|
canStart: canStartFirmware,
|
||||||
|
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||||
|
statusText: _firmwareUserMessage,
|
||||||
|
formattedProgressBytes:
|
||||||
|
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||||
|
onSelectFirmware: _selectFirmwareFile,
|
||||||
|
onStartUpdate: _startFirmwareUpdate,
|
||||||
|
expectedOffsetHex:
|
||||||
|
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (hasDeviceAccess) ...[
|
||||||
_StatusBanner(
|
_StatusBanner(
|
||||||
status: _latestStatus,
|
status: _latestStatus,
|
||||||
onTap: _showStatusHistory,
|
onTap: _showStatusHistory,
|
||||||
@ -1009,23 +1023,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FirmwareUpdateCard(
|
|
||||||
selectedFirmware: _selectedFirmware,
|
|
||||||
progress: _dfuProgress,
|
|
||||||
isSelecting: _isSelectingFirmware,
|
|
||||||
isStarting: _isStartingFirmwareUpdate,
|
|
||||||
canSelect: canSelectFirmware,
|
|
||||||
canStart: canStartFirmware,
|
|
||||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
|
||||||
statusText: _firmwareUserMessage,
|
|
||||||
formattedProgressBytes:
|
|
||||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
|
||||||
onSelectFirmware: _selectFirmwareFile,
|
|
||||||
onStartUpdate: _startFirmwareUpdate,
|
|
||||||
expectedOffsetHex:
|
|
||||||
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
|
||||||
),
|
|
||||||
] else if (isCurrentConnected) ...[
|
] else if (isCurrentConnected) ...[
|
||||||
_PairingRequiredCard(
|
_PairingRequiredCard(
|
||||||
isChecking: _isPairingCheckRunning,
|
isChecking: _isPairingCheckRunning,
|
||||||
|
|||||||
@ -12,11 +12,13 @@ class FirmwareUpdateService {
|
|||||||
FirmwareUpdateService({
|
FirmwareUpdateService({
|
||||||
required FirmwareUpdateTransport transport,
|
required FirmwareUpdateTransport transport,
|
||||||
this.defaultStatusTimeout = const Duration(seconds: 2),
|
this.defaultStatusTimeout = const Duration(seconds: 2),
|
||||||
this.defaultBootloaderConnectTimeout = const Duration(seconds: 20),
|
this.defaultBootloaderConnectTimeout = const Duration(seconds: 60),
|
||||||
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
||||||
this.defaultReconnectTimeout = const Duration(seconds: 12),
|
this.defaultReconnectTimeout = const Duration(seconds: 12),
|
||||||
this.defaultVerificationTimeout = const Duration(seconds: 5),
|
this.defaultVerificationTimeout = const Duration(seconds: 5),
|
||||||
|
this.queueFullBackoff = const Duration(milliseconds: 200),
|
||||||
this.maxNoProgressRetries = 5,
|
this.maxNoProgressRetries = 5,
|
||||||
|
this.maxReconnectResumeAttempts = 3,
|
||||||
}) : _transport = transport;
|
}) : _transport = transport;
|
||||||
|
|
||||||
final FirmwareUpdateTransport _transport;
|
final FirmwareUpdateTransport _transport;
|
||||||
@ -25,7 +27,9 @@ class FirmwareUpdateService {
|
|||||||
final Duration defaultPostFinishResetTimeout;
|
final Duration defaultPostFinishResetTimeout;
|
||||||
final Duration defaultReconnectTimeout;
|
final Duration defaultReconnectTimeout;
|
||||||
final Duration defaultVerificationTimeout;
|
final Duration defaultVerificationTimeout;
|
||||||
|
final Duration queueFullBackoff;
|
||||||
final int maxNoProgressRetries;
|
final int maxNoProgressRetries;
|
||||||
|
final int maxReconnectResumeAttempts;
|
||||||
|
|
||||||
final StreamController<DfuUpdateProgress> _progressController =
|
final StreamController<DfuUpdateProgress> _progressController =
|
||||||
StreamController<DfuUpdateProgress>.broadcast();
|
StreamController<DfuUpdateProgress>.broadcast();
|
||||||
@ -87,8 +91,17 @@ class FirmwareUpdateService {
|
|||||||
reconnectTimeout ?? defaultReconnectTimeout;
|
reconnectTimeout ?? defaultReconnectTimeout;
|
||||||
final effectiveVerificationTimeout =
|
final effectiveVerificationTimeout =
|
||||||
verificationTimeout ?? defaultVerificationTimeout;
|
verificationTimeout ?? defaultVerificationTimeout;
|
||||||
final normalizedSessionId = sessionId & 0xFF;
|
final rawSessionId = sessionId & 0xFF;
|
||||||
|
final normalizedSessionId = rawSessionId == 0 ? 1 : rawSessionId;
|
||||||
final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes);
|
final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes);
|
||||||
|
final startPayload = BootloaderDfuStartPayload(
|
||||||
|
totalLength: imageBytes.length,
|
||||||
|
imageCrc32: imageCrc32,
|
||||||
|
appStart: appStart,
|
||||||
|
imageVersion: imageVersion,
|
||||||
|
sessionId: normalizedSessionId,
|
||||||
|
flags: flags.rawValue,
|
||||||
|
);
|
||||||
|
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
_cancelRequested = false;
|
_cancelRequested = false;
|
||||||
@ -114,30 +127,25 @@ class FirmwareUpdateService {
|
|||||||
try {
|
try {
|
||||||
_throwIfCancelled();
|
_throwIfCancelled();
|
||||||
_emitProgress(state: DfuUpdateState.enteringBootloader);
|
_emitProgress(state: DfuUpdateState.enteringBootloader);
|
||||||
|
final alreadyInBootloader = await _isConnectedToBootloader();
|
||||||
|
if (!alreadyInBootloader) {
|
||||||
final enterResult = await _transport.enterBootloader();
|
final enterResult = await _transport.enterBootloader();
|
||||||
|
final appDisconnectResult = await _transport.waitForAppDisconnect(
|
||||||
|
timeout: effectiveBootloaderConnectTimeout,
|
||||||
|
);
|
||||||
|
if (appDisconnectResult.isErr()) {
|
||||||
if (enterResult.isErr()) {
|
if (enterResult.isErr()) {
|
||||||
throw _DfuFailure(
|
throw _DfuFailure(
|
||||||
'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}',
|
'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final appDisconnectResult = await _transport.waitForAppDisconnect(
|
|
||||||
timeout: effectiveBootloaderConnectTimeout,
|
|
||||||
);
|
|
||||||
if (appDisconnectResult.isErr()) {
|
|
||||||
throw _DfuFailure(
|
throw _DfuFailure(
|
||||||
'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}',
|
'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitProgress(state: DfuUpdateState.connectingBootloader);
|
_emitProgress(state: DfuUpdateState.connectingBootloader);
|
||||||
final bootloaderConnectResult = await _transport.connectToBootloader(
|
await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout);
|
||||||
timeout: effectiveBootloaderConnectTimeout,
|
|
||||||
);
|
|
||||||
if (bootloaderConnectResult.isErr()) {
|
|
||||||
throw _DfuFailure(
|
|
||||||
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final mtuResult =
|
final mtuResult =
|
||||||
@ -161,17 +169,8 @@ class FirmwareUpdateService {
|
|||||||
await _readInitialStatus();
|
await _readInitialStatus();
|
||||||
|
|
||||||
_emitProgress(state: DfuUpdateState.erasing);
|
_emitProgress(state: DfuUpdateState.erasing);
|
||||||
final startStatus = await _writeControlAndWaitForStatus(
|
final startStatus = await _sendStartAndWaitForStatus(
|
||||||
BootloaderDfuProtocol.encodeStartPayload(
|
startPayload,
|
||||||
BootloaderDfuStartPayload(
|
|
||||||
totalLength: imageBytes.length,
|
|
||||||
imageCrc32: imageCrc32,
|
|
||||||
appStart: appStart,
|
|
||||||
imageVersion: imageVersion,
|
|
||||||
sessionId: normalizedSessionId,
|
|
||||||
flags: flags.rawValue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeout: effectiveStatusTimeout,
|
timeout: effectiveStatusTimeout,
|
||||||
);
|
);
|
||||||
_requireOkStatus(
|
_requireOkStatus(
|
||||||
@ -187,7 +186,9 @@ class FirmwareUpdateService {
|
|||||||
imageBytes: imageBytes,
|
imageBytes: imageBytes,
|
||||||
sessionId: normalizedSessionId,
|
sessionId: normalizedSessionId,
|
||||||
payloadSize: payloadSize,
|
payloadSize: payloadSize,
|
||||||
|
startPayload: startPayload,
|
||||||
statusTimeout: effectiveStatusTimeout,
|
statusTimeout: effectiveStatusTimeout,
|
||||||
|
bootloaderConnectTimeout: effectiveBootloaderConnectTimeout,
|
||||||
);
|
);
|
||||||
|
|
||||||
_emitProgress(state: DfuUpdateState.finishing);
|
_emitProgress(state: DfuUpdateState.finishing);
|
||||||
@ -289,6 +290,7 @@ class FirmwareUpdateService {
|
|||||||
|
|
||||||
Future<void> _subscribeToStatus() async {
|
Future<void> _subscribeToStatus() async {
|
||||||
await _statusSubscription?.cancel();
|
await _statusSubscription?.cancel();
|
||||||
|
_statusStreamError = null;
|
||||||
_statusSubscription = _transport.subscribeToStatus().listen(
|
_statusSubscription = _transport.subscribeToStatus().listen(
|
||||||
_handleStatusPayload,
|
_handleStatusPayload,
|
||||||
onError: (Object error) {
|
onError: (Object error) {
|
||||||
@ -313,15 +315,19 @@ class FirmwareUpdateService {
|
|||||||
required List<int> imageBytes,
|
required List<int> imageBytes,
|
||||||
required int sessionId,
|
required int sessionId,
|
||||||
required int payloadSize,
|
required int payloadSize,
|
||||||
|
required BootloaderDfuStartPayload startPayload,
|
||||||
required Duration statusTimeout,
|
required Duration statusTimeout,
|
||||||
|
required Duration bootloaderConnectTimeout,
|
||||||
}) async {
|
}) async {
|
||||||
var expectedOffset = _currentProgress.expectedOffset;
|
var expectedOffset = _currentProgress.expectedOffset;
|
||||||
var retriesWithoutProgress = 0;
|
var retriesWithoutProgress = 0;
|
||||||
|
var reconnectResumeAttempts = 0;
|
||||||
|
|
||||||
while (expectedOffset < imageBytes.length) {
|
while (expectedOffset < imageBytes.length) {
|
||||||
_throwIfCancelled();
|
_throwIfCancelled();
|
||||||
_throwIfStatusStreamErrored();
|
_throwIfStatusStreamErrored(recoverable: true);
|
||||||
|
|
||||||
|
try {
|
||||||
final frame = BootloaderDfuProtocol.buildDataFrame(
|
final frame = BootloaderDfuProtocol.buildDataFrame(
|
||||||
imageBytes: imageBytes,
|
imageBytes: imageBytes,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
@ -335,15 +341,20 @@ class FirmwareUpdateService {
|
|||||||
|
|
||||||
if (status.code == DfuBootloaderStatusCode.queueFull ||
|
if (status.code == DfuBootloaderStatusCode.queueFull ||
|
||||||
status.code == DfuBootloaderStatusCode.stateError) {
|
status.code == DfuBootloaderStatusCode.stateError) {
|
||||||
|
if (status.code == DfuBootloaderStatusCode.queueFull) {
|
||||||
|
await _delayWithCancel(queueFullBackoff);
|
||||||
|
}
|
||||||
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
|
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
|
||||||
_requireOkStatus(
|
_requireOkStatus(
|
||||||
recoveredStatus,
|
recoveredStatus,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
operation: 'GET_STATUS after ${_statusLabel(status)}',
|
operation: 'GET_STATUS after ${_statusLabel(status)}',
|
||||||
);
|
);
|
||||||
expectedOffset =
|
expectedOffset = recoveredStatus.expectedOffset
|
||||||
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
|
.clamp(0, imageBytes.length)
|
||||||
|
.toInt();
|
||||||
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
||||||
|
reconnectResumeAttempts = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,29 +375,135 @@ class FirmwareUpdateService {
|
|||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
operation: 'GET_STATUS after no progress',
|
operation: 'GET_STATUS after no progress',
|
||||||
);
|
);
|
||||||
expectedOffset =
|
expectedOffset = recoveredStatus.expectedOffset
|
||||||
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
|
.clamp(0, imageBytes.length)
|
||||||
|
.toInt();
|
||||||
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
retriesWithoutProgress = 0;
|
retriesWithoutProgress = 0;
|
||||||
|
reconnectResumeAttempts = 0;
|
||||||
expectedOffset = nextOffset;
|
expectedOffset = nextOffset;
|
||||||
_emitTransferProgress(status, imageBytes.length);
|
_emitTransferProgress(status, imageBytes.length);
|
||||||
|
} on _DfuFailure catch (failure) {
|
||||||
|
if (!failure.recoverable ||
|
||||||
|
reconnectResumeAttempts >= maxReconnectResumeAttempts) {
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
reconnectResumeAttempts += 1;
|
||||||
|
final recoveredStatus = await _recoverTransferStatus(
|
||||||
|
timeout: statusTimeout,
|
||||||
|
bootloaderConnectTimeout: bootloaderConnectTimeout,
|
||||||
|
failure: failure,
|
||||||
|
);
|
||||||
|
if (recoveredStatus.isOk &&
|
||||||
|
recoveredStatus.sessionId == 0 &&
|
||||||
|
recoveredStatus.expectedOffset == 0) {
|
||||||
|
_emitProgress(
|
||||||
|
state: DfuUpdateState.erasing,
|
||||||
|
sentBytes: 0,
|
||||||
|
expectedOffset: 0,
|
||||||
|
);
|
||||||
|
final startStatus = await _sendStartAndWaitForStatus(
|
||||||
|
startPayload,
|
||||||
|
timeout: statusTimeout,
|
||||||
|
);
|
||||||
|
_requireOkStatus(
|
||||||
|
startStatus,
|
||||||
|
sessionId: sessionId,
|
||||||
|
expectedOffset: 0,
|
||||||
|
operation: 'START after reconnect',
|
||||||
|
);
|
||||||
|
expectedOffset = 0;
|
||||||
|
retriesWithoutProgress = 0;
|
||||||
|
_emitProgress(state: DfuUpdateState.transferring);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_requireOkStatus(
|
||||||
|
recoveredStatus,
|
||||||
|
sessionId: sessionId,
|
||||||
|
operation: 'GET_STATUS after reconnect',
|
||||||
|
);
|
||||||
|
expectedOffset =
|
||||||
|
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
|
||||||
|
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
||||||
|
_emitProgress(state: DfuUpdateState.transferring);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isConnectedToBootloader() async {
|
||||||
|
final result = await _transport.isConnectedToBootloader();
|
||||||
|
if (result.isErr()) {
|
||||||
|
throw _DfuFailure(
|
||||||
|
'Could not check bootloader DFU connection: ${result.unwrapErr()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectToBootloader({required Duration timeout}) async {
|
||||||
|
final bootloaderConnectResult = await _transport.connectToBootloader(
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
if (bootloaderConnectResult.isErr()) {
|
||||||
|
throw _DfuFailure(
|
||||||
|
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DfuBootloaderStatus> _sendStartAndWaitForStatus(
|
||||||
|
BootloaderDfuStartPayload payload, {
|
||||||
|
required Duration timeout,
|
||||||
|
}) {
|
||||||
|
return _writeControlAndWaitForStatus(
|
||||||
|
BootloaderDfuProtocol.encodeStartPayload(payload),
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DfuBootloaderStatus> _recoverTransferStatus({
|
||||||
|
required Duration timeout,
|
||||||
|
required Duration bootloaderConnectTimeout,
|
||||||
|
required _DfuFailure failure,
|
||||||
|
}) async {
|
||||||
|
await _statusSubscription?.cancel();
|
||||||
|
_statusSubscription = null;
|
||||||
|
_statusSignal = null;
|
||||||
|
_latestStatus = null;
|
||||||
|
_statusStreamError = null;
|
||||||
|
_statusEventCount = 0;
|
||||||
|
_emitProgress(
|
||||||
|
state: DfuUpdateState.connectingBootloader,
|
||||||
|
errorMessage: failure.message,
|
||||||
|
);
|
||||||
|
await _connectToBootloader(timeout: bootloaderConnectTimeout);
|
||||||
|
await _subscribeToStatus();
|
||||||
|
_emitProgress(state: DfuUpdateState.waitingForStatus);
|
||||||
|
return _requestStatus(timeout: timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DfuBootloaderStatus> _writeControlAndWaitForStatus(
|
Future<DfuBootloaderStatus> _writeControlAndWaitForStatus(
|
||||||
List<int> payload, {
|
List<int> payload, {
|
||||||
required Duration timeout,
|
required Duration timeout,
|
||||||
|
bool recoverable = false,
|
||||||
}) async {
|
}) async {
|
||||||
final eventCount = _statusEventCount;
|
final eventCount = _statusEventCount;
|
||||||
final result = await _transport.writeControl(payload);
|
final result = await _transport.writeControl(payload);
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw _DfuFailure(
|
throw _DfuFailure(
|
||||||
'Failed to write bootloader control command: ${result.unwrapErr()}');
|
'Failed to write bootloader control command: ${result.unwrapErr()}',
|
||||||
|
recoverable: recoverable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return _waitForStatus(afterEventCount: eventCount, timeout: timeout);
|
return _waitForStatus(
|
||||||
|
afterEventCount: eventCount,
|
||||||
|
timeout: timeout,
|
||||||
|
recoverable: recoverable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DfuBootloaderStatus> _writeDataFrameAndWaitForStatus(
|
Future<DfuBootloaderStatus> _writeDataFrameAndWaitForStatus(
|
||||||
@ -398,28 +515,35 @@ class FirmwareUpdateService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw _DfuFailure(
|
throw _DfuFailure(
|
||||||
'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}',
|
'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}',
|
||||||
|
recoverable: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _waitForStatus(afterEventCount: eventCount, timeout: timeout);
|
return _waitForStatus(
|
||||||
|
afterEventCount: eventCount,
|
||||||
|
timeout: timeout,
|
||||||
|
recoverable: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DfuBootloaderStatus> _requestStatus({required Duration timeout}) {
|
Future<DfuBootloaderStatus> _requestStatus({required Duration timeout}) {
|
||||||
return _writeControlAndWaitForStatus(
|
return _writeControlAndWaitForStatus(
|
||||||
BootloaderDfuProtocol.encodeGetStatusPayload(),
|
BootloaderDfuProtocol.encodeGetStatusPayload(),
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
|
recoverable: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DfuBootloaderStatus> _waitForStatus({
|
Future<DfuBootloaderStatus> _waitForStatus({
|
||||||
required int afterEventCount,
|
required int afterEventCount,
|
||||||
required Duration timeout,
|
required Duration timeout,
|
||||||
|
bool recoverable = false,
|
||||||
}) async {
|
}) async {
|
||||||
final deadline = DateTime.now().add(timeout);
|
final deadline = DateTime.now().add(timeout);
|
||||||
var observedEvents = afterEventCount;
|
var observedEvents = afterEventCount;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
_throwIfCancelled();
|
_throwIfCancelled();
|
||||||
_throwIfStatusStreamErrored();
|
_throwIfStatusStreamErrored(recoverable: recoverable);
|
||||||
|
|
||||||
if (_statusEventCount > observedEvents && _latestStatus != null) {
|
if (_statusEventCount > observedEvents && _latestStatus != null) {
|
||||||
return _latestStatus!;
|
return _latestStatus!;
|
||||||
@ -427,14 +551,16 @@ class FirmwareUpdateService {
|
|||||||
|
|
||||||
final remaining = deadline.difference(DateTime.now());
|
final remaining = deadline.difference(DateTime.now());
|
||||||
if (remaining <= Duration.zero) {
|
if (remaining <= Duration.zero) {
|
||||||
throw const _DfuFailure(
|
throw _DfuFailure(
|
||||||
'Timed out waiting for bootloader DFU status. Reconnect and retry the update.',
|
'Timed out waiting for bootloader DFU status. Reconnect and retry the update.',
|
||||||
|
recoverable: recoverable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final gotEvent = await _waitForNextStatusEvent(
|
final gotEvent = await _waitForNextStatusEvent(
|
||||||
afterEventCount: observedEvents,
|
afterEventCount: observedEvents,
|
||||||
timeout: remaining,
|
timeout: remaining,
|
||||||
|
recoverable: recoverable,
|
||||||
);
|
);
|
||||||
if (gotEvent) {
|
if (gotEvent) {
|
||||||
observedEvents = _statusEventCount - 1;
|
observedEvents = _statusEventCount - 1;
|
||||||
@ -445,6 +571,7 @@ class FirmwareUpdateService {
|
|||||||
Future<bool> _waitForNextStatusEvent({
|
Future<bool> _waitForNextStatusEvent({
|
||||||
required int afterEventCount,
|
required int afterEventCount,
|
||||||
required Duration timeout,
|
required Duration timeout,
|
||||||
|
required bool recoverable,
|
||||||
}) async {
|
}) async {
|
||||||
if (_statusEventCount > afterEventCount) {
|
if (_statusEventCount > afterEventCount) {
|
||||||
return true;
|
return true;
|
||||||
@ -467,7 +594,7 @@ class FirmwareUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_throwIfCancelled();
|
_throwIfCancelled();
|
||||||
_throwIfStatusStreamErrored();
|
_throwIfStatusStreamErrored(recoverable: recoverable);
|
||||||
return _statusEventCount > afterEventCount;
|
return _statusEventCount > afterEventCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -584,10 +711,21 @@ class FirmwareUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _throwIfStatusStreamErrored() {
|
Future<void> _delayWithCancel(Duration duration) async {
|
||||||
|
if (duration <= Duration.zero) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.any<void>([
|
||||||
|
Future<void>.delayed(duration),
|
||||||
|
_cancelSignal?.future ?? Future<void>.value(),
|
||||||
|
]);
|
||||||
|
_throwIfCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _throwIfStatusStreamErrored({bool recoverable = false}) {
|
||||||
final error = _statusStreamError;
|
final error = _statusStreamError;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
throw _DfuFailure(error);
|
throw _DfuFailure(error, recoverable: recoverable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -600,6 +738,8 @@ class FirmwareUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class FirmwareUpdateTransport {
|
abstract interface class FirmwareUpdateTransport {
|
||||||
|
Future<Result<bool>> isConnectedToBootloader();
|
||||||
|
|
||||||
Future<Result<void>> enterBootloader();
|
Future<Result<void>> enterBootloader();
|
||||||
|
|
||||||
Future<Result<void>> waitForAppDisconnect({required Duration timeout});
|
Future<Result<void>> waitForAppDisconnect({required Duration timeout});
|
||||||
@ -630,15 +770,39 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
required this.buttonDeviceId,
|
required this.buttonDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ShifterService shifterService;
|
final ShifterService? shifterService;
|
||||||
final BluetoothController bluetoothController;
|
final BluetoothController bluetoothController;
|
||||||
final String buttonDeviceId;
|
final String buttonDeviceId;
|
||||||
|
|
||||||
String? _bootloaderDeviceId;
|
String? _bootloaderDeviceId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<bool>> isConnectedToBootloader() async {
|
||||||
|
final currentState = bluetoothController.currentConnectionState;
|
||||||
|
if (currentState.$1 != ConnectionStatus.connected ||
|
||||||
|
currentState.$2 == null) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final statusResult = await bluetoothController.readCharacteristic(
|
||||||
|
currentState.$2!,
|
||||||
|
universalShifterControlServiceUuid,
|
||||||
|
universalShifterDfuStatusCharacteristicUuid,
|
||||||
|
);
|
||||||
|
if (statusResult.isErr()) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
_bootloaderDeviceId = currentState.$2;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<void>> enterBootloader() {
|
Future<Result<void>> enterBootloader() {
|
||||||
return shifterService.writeCommand(UniversalShifterCommand.enterDfu);
|
final shifter = shifterService;
|
||||||
|
if (shifter == null) {
|
||||||
|
return Future.value(bail('Normal app control service is not available.'));
|
||||||
|
}
|
||||||
|
return shifter.writeCommand(UniversalShifterCommand.enterDfu);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -774,10 +938,17 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
Future<Result<void>> verifyDeviceReachable(
|
Future<Result<void>> verifyDeviceReachable(
|
||||||
{required Duration timeout}) async {
|
{required Duration timeout}) async {
|
||||||
try {
|
try {
|
||||||
final statusResult = await shifterService.readStatus().timeout(timeout);
|
final statusResult = await bluetoothController
|
||||||
|
.readCharacteristic(
|
||||||
|
buttonDeviceId,
|
||||||
|
universalShifterControlServiceUuid,
|
||||||
|
universalShifterStatusCharacteristicUuid,
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
if (statusResult.isErr()) {
|
if (statusResult.isErr()) {
|
||||||
return Err(statusResult.unwrapErr());
|
return Err(statusResult.unwrapErr());
|
||||||
}
|
}
|
||||||
|
CentralStatus.fromBytes(statusResult.unwrap());
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
return bail(
|
return bail(
|
||||||
@ -874,9 +1045,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DfuFailure implements Exception {
|
class _DfuFailure implements Exception {
|
||||||
const _DfuFailure(this.message);
|
const _DfuFailure(this.message, {this.recoverable = false});
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
final bool recoverable;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => message;
|
String toString() => message;
|
||||||
|
|||||||
@ -24,6 +24,7 @@ void main() {
|
|||||||
|
|
||||||
expect(result.isOk(), isTrue);
|
expect(result.isOk(), isTrue);
|
||||||
expect(transport.steps, [
|
expect(transport.steps, [
|
||||||
|
'isConnectedToBootloader',
|
||||||
'enterBootloader',
|
'enterBootloader',
|
||||||
'waitForAppDisconnect',
|
'waitForAppDisconnect',
|
||||||
'connectToBootloader',
|
'connectToBootloader',
|
||||||
@ -48,6 +49,63 @@ void main() {
|
|||||||
await transport.dispose();
|
await transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('starts directly when already connected to bootloader', () async {
|
||||||
|
final image = _validImage(80);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
alreadyInBootloader: true,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(transport.steps, [
|
||||||
|
'isConnectedToBootloader',
|
||||||
|
'negotiateMtu',
|
||||||
|
'readStatus',
|
||||||
|
'waitForBootloaderDisconnect',
|
||||||
|
'reconnectForVerification',
|
||||||
|
'verifyDeviceReachable',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates enter bootloader write error when app disconnects',
|
||||||
|
() async {
|
||||||
|
final image = _validImage(80);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
failEnterBootloader: true,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 12,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(transport.steps, contains('waitForAppDisconnect'));
|
||||||
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('backs off on queue-full status and resumes from GET_STATUS',
|
test('backs off on queue-full status and resumes from GET_STATUS',
|
||||||
() async {
|
() async {
|
||||||
final image = _validImage(80);
|
final image = _validImage(80);
|
||||||
@ -80,6 +138,84 @@ void main() {
|
|||||||
await transport.dispose();
|
await transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reconnects and resumes from status after transient data failure',
|
||||||
|
() async {
|
||||||
|
final image = _validImage(130);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
failDataWriteAtOffsetOnce:
|
||||||
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 13,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(
|
||||||
|
transport.steps.where((step) => step == 'connectToBootloader').length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites
|
||||||
|
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
transport.dataWriteOffsets
|
||||||
|
.where(
|
||||||
|
(offset) =>
|
||||||
|
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restarts START when reconnect status has no active session',
|
||||||
|
() async {
|
||||||
|
final image = _validImage(80);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
failDataWriteAtOffsetOnce:
|
||||||
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
resetSessionOnRecoveryStatus: true,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 14,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites
|
||||||
|
.where((write) => write.first == universalShifterDfuOpcodeStart)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('fails with bootloader status error on rejected START', () async {
|
test('fails with bootloader status error on rejected START', () async {
|
||||||
final image = _validImage(40);
|
final image = _validImage(40);
|
||||||
final transport = _FakeFirmwareUpdateTransport(
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
@ -147,15 +283,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
_FakeFirmwareUpdateTransport({
|
_FakeFirmwareUpdateTransport({
|
||||||
required this.totalBytes,
|
required this.totalBytes,
|
||||||
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
||||||
|
this.alreadyInBootloader = false,
|
||||||
|
this.failEnterBootloader = false,
|
||||||
this.queueFullOnFirstData = false,
|
this.queueFullOnFirstData = false,
|
||||||
this.suppressFirstDataStatus = false,
|
this.suppressFirstDataStatus = false,
|
||||||
|
this.failDataWriteAtOffsetOnce,
|
||||||
|
this.resetSessionOnRecoveryStatus = false,
|
||||||
this.onDataWrite,
|
this.onDataWrite,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int totalBytes;
|
final int totalBytes;
|
||||||
final DfuBootloaderStatusCode startStatusCode;
|
final DfuBootloaderStatusCode startStatusCode;
|
||||||
|
final bool alreadyInBootloader;
|
||||||
|
final bool failEnterBootloader;
|
||||||
final bool queueFullOnFirstData;
|
final bool queueFullOnFirstData;
|
||||||
final bool suppressFirstDataStatus;
|
final bool suppressFirstDataStatus;
|
||||||
|
final int? failDataWriteAtOffsetOnce;
|
||||||
|
final bool resetSessionOnRecoveryStatus;
|
||||||
final void Function()? onDataWrite;
|
final void Function()? onDataWrite;
|
||||||
|
|
||||||
final StreamController<List<int>> _statusController =
|
final StreamController<List<int>> _statusController =
|
||||||
@ -167,12 +311,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
|
|
||||||
int _sessionId = 0;
|
int _sessionId = 0;
|
||||||
int _expectedOffset = 0;
|
int _expectedOffset = 0;
|
||||||
|
int _connectCount = 0;
|
||||||
|
bool _sentDataFailure = false;
|
||||||
bool _sentQueueFull = false;
|
bool _sentQueueFull = false;
|
||||||
bool _suppressedDataStatus = false;
|
bool _suppressedDataStatus = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<bool>> isConnectedToBootloader() async {
|
||||||
|
steps.add('isConnectedToBootloader');
|
||||||
|
return Ok(alreadyInBootloader);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<void>> enterBootloader() async {
|
Future<Result<void>> enterBootloader() async {
|
||||||
steps.add('enterBootloader');
|
steps.add('enterBootloader');
|
||||||
|
if (failEnterBootloader) {
|
||||||
|
return bail('app disconnected before write response');
|
||||||
|
}
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,6 +340,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
@override
|
@override
|
||||||
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
||||||
steps.add('connectToBootloader');
|
steps.add('connectToBootloader');
|
||||||
|
_connectCount += 1;
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,6 +368,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
_expectedOffset = 0;
|
_expectedOffset = 0;
|
||||||
_scheduleStatus(startStatusCode, _sessionId, 0);
|
_scheduleStatus(startStatusCode, _sessionId, 0);
|
||||||
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
||||||
|
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
|
||||||
|
_sessionId = 0;
|
||||||
|
_expectedOffset = 0;
|
||||||
|
}
|
||||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||||
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
||||||
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
|
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
|
||||||
@ -229,6 +389,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
final offset = _readLeU32(frame, 1);
|
final offset = _readLeU32(frame, 1);
|
||||||
dataWriteOffsets.add(offset);
|
dataWriteOffsets.add(offset);
|
||||||
|
|
||||||
|
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
|
||||||
|
_sentDataFailure = true;
|
||||||
|
return bail('simulated BLE write failure');
|
||||||
|
}
|
||||||
|
|
||||||
if (queueFullOnFirstData && !_sentQueueFull) {
|
if (queueFullOnFirstData && !_sentQueueFull) {
|
||||||
_sentQueueFull = true;
|
_sentQueueFull = true;
|
||||||
_scheduleStatus(
|
_scheduleStatus(
|
||||||
|
|||||||
Reference in New Issue
Block a user