diff --git a/CHANGELOG.md b/CHANGELOG.md index fa852aaf..f817cefe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### Added +- Added support for Pybricks Profile v1.3.0. + ### Changed - Better error message when download and run fails due to disconnected hub. diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index 282823f7..3b47aa9f 100644 --- a/src/ble-pybricks-service/actions.ts +++ b/src/ble-pybricks-service/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors +// Copyright (c) 2021-2023 The Pybricks Authors // // Actions for Bluetooth Low Energy Pybricks service @@ -109,6 +109,21 @@ export const sendWriteUserRamCommand = createAction( }), ); +/** + * Action that requests to write to stdin. + * @param id Unique identifier for this transaction. + * @param payload The bytes to write. + * + * @since Pybricks Profile v1.3.0. + */ +export const sendWriteStdinCommand = createAction( + (id: number, payload: ArrayBuffer) => ({ + type: 'blePybricksServiceCommand.action.sendWriteStdinCommand', + id, + payload, + }), +); + /** * Action that indicates that a command was successfully sent. * @param id Unique identifier for the transaction from the corresponding "send" command. @@ -140,6 +155,17 @@ export const didReceiveStatusReport = createAction((statusFlags: number) => ({ statusFlags, })); +/** + * Action that represents a status report event received from the hub. + * @param statusFlags The status flags. + * + * @since Pybricks Profile v1.3.0 + */ +export const didReceiveWriteStdout = createAction((payload: ArrayBuffer) => ({ + type: 'blePybricksServiceEvent.action.didReceiveWriteStdout', + payload, +})); + /** * Pseudo-event = actionCreator((not received from hub) indicating that there was a protocol error. * @param error The error that was caught. diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index 74309b6a..29bb9918 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2023 The Pybricks Authors // // Definitions related to the Pybricks Bluetooth low energy GATT service. @@ -52,6 +52,12 @@ export enum CommandType { * @since Pybricks Profile v1.2.0 */ ResetInUpdateMode = 5, + /** + * Request to write data to stdin. + * + * @since Pybricks Profile v1.3.0 + */ + WriteStdin = 6, } /** @@ -120,6 +126,20 @@ export function createWriteUserRamCommand( return msg; } +/** + * Creates a {@link CommandType.WriteStdin} message. + * @param payload The bytes to write. + * + * @since Pybricks Profile v1.3.0. + */ +export function createWriteStdinCommand(payload: ArrayBuffer): Uint8Array { + const msg = new Uint8Array(1 + payload.byteLength); + const view = new DataView(msg.buffer); + view.setUint8(0, CommandType.WriteStdin); + msg.set(new Uint8Array(payload), 1); + return msg; +} + /** Events are notifications received from the hub. */ export enum EventType { /** @@ -130,6 +150,12 @@ export enum EventType { * @since Pybricks Profile v1.0.0 */ StatusReport = 0, + /** + * Hub wrote to stdout event. + * + * @since Pybricks Profile v1.3.0 + */ + WriteStdout = 1, } /** Status indications received by Event.StatusReport */ @@ -206,6 +232,18 @@ export function parseStatusReport(msg: DataView): number { return msg.getUint32(1, true); } +/** + * Parses the payload of a write stdout. + * @param msg The raw message data. + * @returns The bytes that were written. + * + * @since Pybricks Profile v1.3.0 + */ +export function parseWriteStdout(msg: DataView): ArrayBuffer { + assert(msg.getUint8(0) === EventType.WriteStdout, 'expecting write stdout event'); + return msg.buffer.slice(1); +} + /** * Protocol error. Thrown e.g. when there is a malformed message. */ diff --git a/src/ble-pybricks-service/sagas.test.ts b/src/ble-pybricks-service/sagas.test.ts index ec44e30e..da58587e 100644 --- a/src/ble-pybricks-service/sagas.test.ts +++ b/src/ble-pybricks-service/sagas.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2023 The Pybricks Authors import { AsyncSaga } from '../../test'; import { @@ -7,10 +7,16 @@ import { didFailToWriteCommand, didNotifyEvent, didReceiveStatusReport, + didReceiveWriteStdout, didSendCommand, didWriteCommand, eventProtocolError, + sendStartReplCommand, + sendStartUserProgramCommand, sendStopUserProgramCommand, + sendWriteStdinCommand, + sendWriteUserProgramMetaCommand, + sendWriteUserRamCommand, writeCommand, } from './actions'; import { CommandType, ProtocolError } from './protocol'; @@ -25,6 +31,57 @@ describe('command encoder', () => { 0x00, // stop user program command ], ], + [ + 'start user program', + sendStartUserProgramCommand(0), + [ + 0x01, // start user program command + ], + ], + [ + 'start repl', + sendStartReplCommand(0), + [ + 0x02, // start repl command + ], + ], + [ + 'write user program meta', + sendWriteUserProgramMetaCommand(0, 100), + [ + 0x03, // write user program meta command + 0x64, // program size LSB + 0x00, + 0x00, + 0x00, // program size MSB + ], + ], + [ + 'write user ram', + sendWriteUserRamCommand(0, 100, new Uint8Array([1, 2, 3, 4]).buffer), + [ + 0x04, // write user ram command + 0x64, // offset size LSB + 0x00, + 0x00, + 0x00, // offset size MSB + 0x01, // payload start + 0x02, + 0x03, + 0x04, // payload end + ], + ], + [ + 'write stdin', + sendWriteStdinCommand(0, new Uint8Array([1, 2, 3, 4]).buffer), + [ + 0x06, // write stdin command + 0x01, // payload start + 0x02, + 0x03, + 0x04, // payload end + ], + ], ])('encode %s request', async (_n, request, expected) => { const saga = new AsyncSaga(blePybricksService); saga.put(request); @@ -103,6 +160,24 @@ describe('event decoder', () => { ], didReceiveStatusReport(0x00000001), ], + [ + 'write stdout', + [ + 0x01, // write stdout event + 't'.charCodeAt(0), //payload + 'e'.charCodeAt(0), + 't'.charCodeAt(0), + 't'.charCodeAt(0), + ], + didReceiveWriteStdout( + new Uint8Array([ + 't'.charCodeAt(0), + 'e'.charCodeAt(0), + 't'.charCodeAt(0), + 't'.charCodeAt(0), + ]).buffer, + ), + ], ])('decode %s event', async (_n, message, expected) => { const saga = new AsyncSaga(blePybricksService); const notification = new Uint8Array(message); diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index 15b964a4..f1477bb3 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors +// Copyright (c) 2021-2023 The Pybricks Authors // // Handles Pybricks protocol. @@ -18,12 +18,14 @@ import { didFailToWriteCommand, didNotifyEvent, didReceiveStatusReport, + didReceiveWriteStdout, didSendCommand, didWriteCommand, eventProtocolError, sendStartReplCommand, sendStartUserProgramCommand, sendStopUserProgramCommand, + sendWriteStdinCommand, sendWriteUserProgramMetaCommand, sendWriteUserRamCommand, writeCommand, @@ -34,10 +36,12 @@ import { createStartReplCommand, createStartUserProgramCommand, createStopUserProgramCommand, + createWriteStdinCommand, createWriteUserProgramMetaCommand, createWriteUserRamCommand, getEventType, parseStatusReport, + parseWriteStdout, } from './protocol'; /** @@ -45,7 +49,7 @@ import { * the bytecodes to to the device. */ function* encodeRequest(): Generator { - // Using a while loop to serialize sending data to avoid "busy" errors. + // Using a loop to serialize sending data to avoid "busy" errors. const chan = yield* actionChannel( (a: AnyAction) => @@ -53,7 +57,7 @@ function* encodeRequest(): Generator { a.type.startsWith('blePybricksServiceCommand.action.send'), ); - while (true) { + for (;;) { const action = yield* take(chan); /* istanbul ignore else: should not be possible to reach */ @@ -74,6 +78,10 @@ function* encodeRequest(): Generator { createWriteUserRamCommand(action.offset, action.payload), ), ); + } else if (sendWriteStdinCommand.matches(action)) { + yield* put( + writeCommand(action.id, createWriteStdinCommand(action.payload)), + ); } else { console.error(`Unknown Pybricks service command ${action.type}`); continue; @@ -103,6 +111,9 @@ function* decodeResponse(action: ReturnType): Generator { case EventType.StatusReport: yield* put(didReceiveStatusReport(parseStatusReport(action.value))); break; + case EventType.WriteStdout: + yield* put(didReceiveWriteStdout(parseWriteStdout(action.value))); + break; default: throw new ProtocolError( `unknown pybricks event type: ${hex(responseType, 2)}`, diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index a9ecc968..4102abf1 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -7,8 +7,7 @@ import { bleDidDisconnectPybricks, bleDisconnectPybricks, } from '../ble/actions'; -import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; -import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; +import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions'; import { PnpId } from '../ble-device-info-service/protocol'; import { HubType } from '../ble-lwp3-service/protocol'; import { @@ -48,6 +47,7 @@ test('initial state', () => { "preferredFileFormat": null, "runtime": "hub.runtime.disconnected", "useLegacyDownload": false, + "useLegacyStdio": false, } `); }); @@ -406,3 +406,22 @@ describe('useLegacyDownload', () => { }); }); +describe('useLegacyStdio', () => { + test('old', () => { + expect( + reducers( + { useLegacyStdio: false } as State, + bleDIServiceDidReceiveSoftwareRevision('1.2.0'), + ).useLegacyStdio, + ).toBeTruthy(); + }); + + test('new', () => { + expect( + reducers( + { useLegacyStdio: true } as State, + bleDIServiceDidReceiveSoftwareRevision('1.3.0'), + ).useLegacyStdio, + ).toBeFalsy(); + }); +}); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index bd9a4d50..5ddb7328 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -8,6 +8,7 @@ import { bleDidDisconnectPybricks, bleDisconnectPybricks, } from '../ble/actions'; +import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions'; import { HubType } from '../ble-lwp3-service/protocol'; import { blePybricksServiceDidNotReceiveHubCapabilities, @@ -250,6 +251,18 @@ const useLegacyDownload: Reducer = (state = false, action) => { return state; }; +/** + * When true, use NUS for stdio instead of Pybricks control characteristic. + */ +const useLegacyStdio: Reducer = (state = false, action) => { + if (bleDIServiceDidReceiveSoftwareRevision.matches(action)) { + // Behavior changed starting with Pybricks Profile v1.3.0. + return !semver.satisfies(action.version, '^1.3.0'); + } + + return state; +}; + export default combineReducers({ runtime, downloadProgress, @@ -258,4 +271,5 @@ export default combineReducers({ hasRepl, preferredFileFormat, useLegacyDownload, + useLegacyStdio, }); diff --git a/src/terminal/sagas.test.ts b/src/terminal/sagas.test.ts index 40fa7b94..f61b6321 100644 --- a/src/terminal/sagas.test.ts +++ b/src/terminal/sagas.test.ts @@ -9,6 +9,12 @@ import { didWrite, write, } from '../ble-nordic-uart-service/actions'; +import { + didFailToSendCommand, + didReceiveWriteStdout, + didSendCommand, + sendWriteStdinCommand, +} from '../ble-pybricks-service/actions'; import { checksum, hubDidStartRepl } from '../hub/actions'; import { HubRuntimeState } from '../hub/reducers'; import { createCountFunc } from '../utils/iter'; @@ -17,28 +23,68 @@ import terminal from './sagas'; const encoder = new TextEncoder(); -describe('Data receiver filters out hub status', () => { - test('normal message - no status', async () => { +describe('receiving stdout from hub', () => { + test('legacy UART message', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ hub: { useLegacyDownload: true, useLegacyStdio: true } }); // sending ASCII space character saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer))); - const action = await saga.take(); - expect(action).toEqual(sendData(' ')); + await expect(saga.take()).resolves.toEqual(sendData(' ')); await saga.end(); }); - test('checksum message', async () => { + test('legacy download checksum message', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Loading } }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Loading, + useLegacyDownload: true, + useLegacyStdio: true, + }, + }); saga.put(didNotify(new DataView(new Uint8Array([0xaa]).buffer))); - const action = await saga.take(); - expect(action).toEqual(checksum(0xaa)); + await expect(saga.take()).resolves.toEqual(checksum(0xaa)); + + await saga.end(); + }); + + test('Pybricks Profile v1.3.0 ignores UART service', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + + saga.updateState({ + hub: { + useLegacyDownload: false, + useLegacyStdio: false, + }, + }); + + saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer))); + + await delay(50); + + // no further actions should be pending + await saga.end(); + }); + + test('Pybricks Profile v1.3.0 write stdout command', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + + saga.updateState({ + hub: { + useLegacyDownload: false, + useLegacyStdio: false, + }, + }); + + saga.put(didReceiveWriteStdout(new Uint8Array([0x20]).buffer)); + + await expect(saga.take()).resolves.toEqual(sendData(' ')); await saga.end(); }); @@ -71,7 +117,161 @@ describe('Terminal data source responds to receive data actions', () => { test('basic function works', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Running } }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Running, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData('test1234')); + + await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected)); + + await saga.end(); + }); + + test('messages are queued until previous has completed', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Running, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData('test1234')); + await delay(50); // without delay, messages are combined + saga.put(receiveData('test1234')); + + // second message is queued until didSend or didFailToSend + expect(saga.numPending()).toBe(1); + + await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected)); + + // second message is queued until didSend or didFailToSend + expect(saga.numPending()).toBe(0); + + saga.put(didSendCommand(0)); + + await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(1, expected)); + + saga.put(didSendCommand(1)); + + await saga.end(); + }); + + test('messages are queued until previous has failed', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Running, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData('test1234')); + await delay(50); // without delay, messages are combined + saga.put(receiveData('test1234')); + + // second message is queued until didSend or didFailToSend + expect(saga.numPending()).toBe(1); + + await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected)); + + // second message is queued until didSend or didFailToSend + expect(saga.numPending()).toBe(0); + + saga.put(didFailToSendCommand(0, new Error('test error'))); + + await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(1, expected)); + + saga.put(didSendCommand(1)); + + await saga.end(); + }); + + test('small messages are combined', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Running, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData('test1234')); + saga.put(receiveData('test1234')); + + await expect(saga.take()).resolves.toEqual( + sendWriteStdinCommand(0, new Uint8Array([...expected, ...expected])), + ); + + await saga.end(); + }); + + test('long messages are split', async () => { + const testData = '012345678901234567890123456789'; + + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Running, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData(testData)); + + await expect(saga.take()).resolves.toEqual( + sendWriteStdinCommand(0, encoder.encode(testData.slice(0, 20))), + ); + + saga.put(didSendCommand(0)); + + await expect(saga.take()).resolves.toEqual( + sendWriteStdinCommand(1, encoder.encode(testData.slice(20, 40))), + ); + + saga.put(didSendCommand(1)); + + await saga.end(); + }); + + test('if user program is not running echo BEL', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { + runtime: HubRuntimeState.Disconnected, + useLegacyStdio: false, + maxBleWriteSize: 20, + }, + }); + + saga.put(receiveData('test1234')); + + await delay(50); + + // sends BEL character on error + await expect(saga.take()).resolves.toEqual(sendData('\x07')); + + await saga.end(); + }); +}); + +describe('Terminal data source responds to receive data actions (legacy)', () => { + const expected = encoder.encode('test1234'); + + test('basic function works', async () => { + const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true }, + }); saga.put(receiveData('test1234')); @@ -83,7 +283,9 @@ describe('Terminal data source responds to receive data actions', () => { test('messages are queued until previous has completed', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Running } }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true }, + }); saga.put(receiveData('test1234')); await delay(50); // without delay, messages are combined @@ -110,7 +312,9 @@ describe('Terminal data source responds to receive data actions', () => { test('messages are queued until previous has failed', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Running } }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true }, + }); saga.put(receiveData('test1234')); await delay(50); // without delay, messages are combined @@ -137,7 +341,9 @@ describe('Terminal data source responds to receive data actions', () => { test('small messages are combined', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Running } }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true }, + }); saga.put(receiveData('test1234')); saga.put(receiveData('test1234')); @@ -152,7 +358,9 @@ describe('Terminal data source responds to receive data actions', () => { const testData = '012345678901234567890123456789'; const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Running } }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true }, + }); saga.put(receiveData(testData)); @@ -169,13 +377,19 @@ describe('Terminal data source responds to receive data actions', () => { await saga.end(); }); - test('if user program is not running, do not dispatch write', async () => { + test('if user program is not running, echo BEL', async () => { const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); - saga.updateState({ hub: { runtime: HubRuntimeState.Disconnected } }); + saga.updateState({ + hub: { runtime: HubRuntimeState.Disconnected, useLegacyStdio: true }, + }); saga.put(receiveData('test1234')); - // line below will fail with unhandled pending dispatches if not working correctly + await delay(50); + + // sends BEL character on error + await expect(saga.take()).resolves.toEqual(sendData('\x07')); + await saga.end(); }); }); diff --git a/src/terminal/sagas.ts b/src/terminal/sagas.ts index cf6dcab0..0169ca35 100644 --- a/src/terminal/sagas.ts +++ b/src/terminal/sagas.ts @@ -20,10 +20,16 @@ import { write, } from '../ble-nordic-uart-service/actions'; import { nordicUartSafeTxCharLength } from '../ble-nordic-uart-service/protocol'; +import { + didFailToSendCommand, + didReceiveWriteStdout, + didSendCommand, + sendWriteStdinCommand, +} from '../ble-pybricks-service/actions'; import { checksum, hubDidStartRepl } from '../hub/actions'; import { HubRuntimeState } from '../hub/reducers'; import { RootState } from '../reducers'; -import { defined } from '../utils'; +import { assert, defined } from '../utils'; import { TerminalContextValue } from './TerminalContext'; import { receiveData, sendData } from './actions'; @@ -36,7 +42,13 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); function* receiveUartData(action: ReturnType): Generator { - const hubState = yield* select((s: RootState) => s.hub.runtime); + const { runtime: hubState, useLegacyStdio } = yield* select( + (s: RootState) => s.hub, + ); + + if (!useLegacyStdio) { + return; + } if (hubState === HubRuntimeState.Loading && action.value.buffer.byteLength === 1) { const view = new DataView(action.value.buffer); @@ -48,6 +60,13 @@ function* receiveUartData(action: ReturnType): Generator { yield* put(sendData(value)); } +function* handleReceiveWriteStdout( + action: ReturnType, +): Generator { + const value = decoder.decode(action.payload); + yield* put(sendData(value)); +} + function* receiveTerminalData(): Generator { const nextMessageId = yield* getContext<() => number>('nextMessageId'); const channel = yield* actionChannel(receiveData); @@ -87,18 +106,55 @@ function* receiveTerminalData(): Generator { // stdin gets piped to BLE connection const data = encoder.encode(value); - for (let i = 0; i < data.length; i += nordicUartSafeTxCharLength) { - const { id } = yield* put( - write(nextMessageId(), data.slice(i, i + nordicUartSafeTxCharLength)), - ); + const { useLegacyStdio, maxBleWriteSize } = yield* select( + (s: RootState) => s.hub, + ); - yield* take( - (a: AnyAction) => - (didWrite.matches(a) || didFailToWrite.matches(a)) && a.id === id, - ); + if (useLegacyStdio) { + for (let i = 0; i < data.length; i += nordicUartSafeTxCharLength) { + const { id } = yield* put( + write( + nextMessageId(), + data.slice(i, i + nordicUartSafeTxCharLength), + ), + ); - // wait for echo so tht we don't overrun the hub with messages - yield* race([take(didNotify), delay(100)]); + yield* take( + (a: AnyAction) => + (didWrite.matches(a) || didFailToWrite.matches(a)) && + a.id === id, + ); + + // wait for echo so tht we don't overrun the hub with messages + yield* race([take(didNotify), delay(100)]); + } + } else { + // maxBleWriteSize should always be set to a valid value when useLegacyStdio is false + assert(maxBleWriteSize >= 20, 'bad maxBleWriteSize'); + + for (let i = 0; i < data.length; i += maxBleWriteSize) { + const { id } = yield* put( + sendWriteStdinCommand( + nextMessageId(), + data.slice(i, i + maxBleWriteSize), + ), + ); + + const { didFail } = yield* race({ + didSucceed: take(didSendCommand.when((a) => a.id === id)), + didFail: take(didFailToSendCommand.when((a) => a.id === id)), + }); + + if (didFail) { + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + console.error(didFail.error); + } + + // REVISIT: should we provide UI feedback? + // could echo BEL character as above + } + } } } } @@ -115,6 +171,7 @@ function handleHubDidStartRepl(): void { export default function* (): Generator { yield* takeEvery(didNotify, receiveUartData); + yield* takeEvery(didReceiveWriteStdout, handleReceiveWriteStdout); yield* fork(receiveTerminalData); yield* takeEvery(sendData, sendTerminalData); yield* takeEvery(hubDidStartRepl, handleHubDidStartRepl);