From 430147487e9d598ea32645345e2da5c26057992d Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 9 Mar 2021 18:51:49 -0600 Subject: [PATCH] implement Pybricks service stop command --- src/actions.ts | 2 + src/ble-pybricks-service/actions.ts | 67 +++++++++++++++++++++++++++ src/ble-pybricks-service/protocol.ts | 15 ++++++ src/ble-pybricks-service/sagas.ts | 69 +++++++++++++++++++++++++++- src/hub/sagas.test.ts | 21 +++++++-- src/hub/sagas.ts | 30 ++++++++++-- 6 files changed, 194 insertions(+), 10 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 83257588..dbd59b18 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -5,6 +5,7 @@ import { Dispatch as ReduxDispatch } from 'redux'; import { AppAction } from './app/actions'; import { BlePybricksServiceAction, + BlePybricksServiceCommandAction, BlePybricksServiceEventAction, } from './ble-pybricks-service/actions'; import { BleUartAction } from './ble-uart/actions'; @@ -34,6 +35,7 @@ export type Action = | BLEAction | BLEConnectAction | BlePybricksServiceAction + | BlePybricksServiceCommandAction | BlePybricksServiceEventAction | BleUartAction | BootloaderConnectionAction diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index 6447b7b8..61cdb658 100644 --- a/src/ble-pybricks-service/actions.ts +++ b/src/ble-pybricks-service/actions.ts @@ -113,6 +113,73 @@ export type BlePybricksServiceAction = | BlePybricksServiceDidFailToWriteCommandAction | BlePybricksServiceDidNotifyEventAction; +/** Action types for commands sent via the Pybricks service control characteristic. */ +export enum BlePybricksServiceCommandActionType { + SendStopUserProgram = 'blePybricksServiceCommand.action.sendStopUserProgram', + DidSend = 'blePybricksServiceCommand.action.didSend', + DidFailToSend = 'blePybricksServiceCommand.action.didFailToSend', +} + +type TransactionId = { + /** Unique identifier for the transaction set in the "send" command. */ + id: number; +}; + +/** Action that requests a stop user program to be sent. */ +export type BlePybricksServiceCommandSendStopUserProgram = Action & + TransactionId; + +/** + * Action that requests a stop user program to be sent. + * @param id Unique identifier for this transaction. + */ +export function sendStopUserProgramCommand( + id: number, +): BlePybricksServiceCommandSendStopUserProgram { + return { type: BlePybricksServiceCommandActionType.SendStopUserProgram, id }; +} + +/** + * Action that indicates that a command was successfully sent. + */ +export type BlePybricksServiceCommandDidSendAction = Action & + TransactionId; + +/** + * Action that indicates that a command was successfully sent. + * @param id Unique identifier for the transaction from the corresponding "send" command. + */ +export function didSendCommand(id: number): BlePybricksServiceCommandDidSendAction { + return { type: BlePybricksServiceCommandActionType.DidSend, id }; +} + +/** + * Action that indicates that a command was not sent. + */ +export type BlePybricksServiceCommandDidFailToSendAction = Action & + TransactionId & { + /** The error that was raised. */ + err: Error; + }; + +/** + * Action that indicates that a command was not sent. + * @param id Unique identifier for the transaction from the corresponding "send" command. + * @param err The error that was raised. + */ +export function didFailToSendCommand( + id: number, + err: Error, +): BlePybricksServiceCommandDidFailToSendAction { + return { type: BlePybricksServiceCommandActionType.DidFailToSend, id, err }; +} + +/** Common type for Pybricks control characteristic send command actions. */ +export type BlePybricksServiceCommandAction = + | BlePybricksServiceCommandSendStopUserProgram + | BlePybricksServiceCommandDidSendAction + | BlePybricksServiceCommandDidFailToSendAction; + /** Action types for events received from the Pybricks service control characteristic. */ export enum BlePybricksServiceEventActionType { /** A status report event. */ diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index fbe5a617..48b73424 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -10,6 +10,21 @@ export const ServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; /** Pybricks control characteristic UUID. */ export const ControlCharacteristicUUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef'; +/** Commands are instructions sent to the hub. */ +export enum CommandType { + /** Request to stop the user program, if it is running. */ + StopUserProgram = 0, +} + +/** + * Creates a stop user program command message. + */ +export function createStopUserProgramCommand(): Uint8Array { + const msg = new Uint8Array(1); + msg[0] = CommandType.StopUserProgram; + return msg; +} + /** Events are notifications received from the hub. */ export enum EventType { /** Status report. Received when notifications are enabled and when status changes. */ diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index e3baf6e4..7f035155 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -3,15 +3,79 @@ // // Handles Pybricks protocol. -import { put, takeEvery } from 'typed-redux-saga/macro'; +import { + actionChannel, + fork, + put, + race, + take, + takeEvery, +} from 'typed-redux-saga/macro'; +import { Action } from '../actions'; import { hex } from '../utils'; import { BlePybricksServiceActionType, + BlePybricksServiceCommandAction, + BlePybricksServiceCommandActionType, + BlePybricksServiceDidFailToWriteCommandAction, BlePybricksServiceDidNotifyEventAction, + BlePybricksServiceDidWriteCommandAction, + didFailToSendCommand, + didSendCommand, eventProtocolError, statusReportEvent, + writeCommand, } from './actions'; -import { EventType, ProtocolError, getEventType, parseStatusReport } from './protocol'; +import { + EventType, + ProtocolError, + createStopUserProgramCommand, + getEventType, + parseStatusReport, +} from './protocol'; + +/** + * Converts a request action into bytecodes and creates a new action to send + * the bytecodes to to the device. + */ +function* encodeRequest(): Generator { + // Using a while loop to serialize sending data to avoid "busy" errors. + + const chan = yield* actionChannel((a: Action) => + Object.values(BlePybricksServiceCommandActionType).includes( + a.type as BlePybricksServiceCommandActionType, + ), + ); + + while (true) { + const action = yield* take(chan); + + switch (action.type) { + case BlePybricksServiceCommandActionType.SendStopUserProgram: + yield* put(writeCommand(action.id, createStopUserProgramCommand())); + break; + /* istanbul ignore next: should not be possible to reach */ + default: + console.error(`Unknown Pybricks service command ${action}`); + continue; + } + + const { failedToSend } = yield* race({ + sent: take( + BlePybricksServiceActionType.DidWriteCommand, + ), + failedToSend: take( + BlePybricksServiceActionType.DidFailToWriteCommand, + ), + }); + + if (failedToSend) { + yield* put(didFailToSendCommand(action.id, failedToSend.err)); + } else { + yield* put(didSendCommand(action.id)); + } + } +} /** * Converts an incoming connection message to a response action. @@ -36,5 +100,6 @@ function* decodeResponse(action: BlePybricksServiceDidNotifyEventAction): Genera } export default function* (): Generator { + yield* fork(encodeRequest); yield* takeEvery(BlePybricksServiceActionType.DidNotifyEvent, decodeResponse); } diff --git a/src/hub/sagas.test.ts b/src/hub/sagas.test.ts index 7e2094fc..5e770896 100644 --- a/src/hub/sagas.test.ts +++ b/src/hub/sagas.test.ts @@ -4,6 +4,11 @@ import { Ace } from 'ace-builds'; import { mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; +import { + BlePybricksServiceCommandActionType, + BlePybricksServiceCommandSendStopUserProgram, + didSendCommand, +} from '../ble-pybricks-service/actions'; import { BleUartActionType, BleUartWriteAction, didWrite } from '../ble-uart/actions'; import { MpyActionType, didCompile } from '../mpy/actions'; import { createCountFunc } from '../utils/iter'; @@ -82,8 +87,8 @@ test('repl', async () => { saga.put(repl()); - const compileAction = await saga.take(); - expect(compileAction.type).toBe(BleUartActionType.Write); + const action = await saga.take(); + expect(action.type).toBe(BleUartActionType.Write); await saga.end(); }); @@ -93,8 +98,16 @@ test('stop', async () => { saga.put(stop()); - const compileAction = await saga.take(); - expect(compileAction.type).toBe(BleUartActionType.Write); + const pybricksServiceAction = await saga.take(); + expect(pybricksServiceAction.type).toBe( + BlePybricksServiceCommandActionType.SendStopUserProgram, + ); + + saga.put( + didSendCommand( + (pybricksServiceAction as BlePybricksServiceCommandSendStopUserProgram).id, + ), + ); await saga.end(); }); diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index c290bf68..e7ac1b09 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -12,6 +12,12 @@ import { takeEvery, } from 'typed-redux-saga/macro'; import { Action } from '../actions'; +import { + BlePybricksServiceCommandActionType, + BlePybricksServiceCommandDidFailToSendAction, + BlePybricksServiceCommandDidSendAction, + sendStopUserProgramCommand, +} from '../ble-pybricks-service/actions'; import { BleUartActionType, BleUartDidFailToWriteAction, @@ -151,12 +157,28 @@ function* startRepl(_action: HubReplAction): Generator { yield* put(write(nextMessageId(), startReplCommand)); } -// CTRL+C, CTRL+C, CTRL+D -const stopCommand = new Uint8Array([0x03, 0x03, 0x04]); - function* stop(_action: HubStopAction): Generator { const nextMessageId = yield* getContext<() => number>('nextMessageId'); - yield* put(write(nextMessageId(), stopCommand)); + const id = nextMessageId(); + yield* put(sendStopUserProgramCommand(id)); + // REVISIT: may want to disable button while attempting to send command + // this would mean didSendStop() and didFailToSendStop() actions here + const { failedToSend } = yield* race({ + sent: take( + (a: Action) => + a.type === BlePybricksServiceCommandActionType.DidSend && a.id === id, + ), + failedToSend: take( + (a: Action) => + a.type === BlePybricksServiceCommandActionType.DidFailToSend && + a.id === id, + ), + }); + if (failedToSend) { + // TODO: probably want to check error. If hub disconnected, ignore error + // otherwise indicate error to user + console.error(failedToSend.err); + } } export default function* (): Generator {