diff --git a/src/actions.ts b/src/actions.ts index 5e3f6087..d88d7564 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,58 +1,39 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2022 The Pybricks Authors -import { AppAction } from './app/actions'; -import { BleDIServiceAction } from './ble-device-info-service/actions'; -import { BleUartAction } from './ble-nordic-uart-service/actions'; -import { - BlePybricksServiceAction, - BlePybricksServiceCommandAction, - BlePybricksServiceEventAction, -} from './ble-pybricks-service/actions'; -import { BLEAction, BLEConnectAction } from './ble/actions'; -import { EditorAction } from './editor/actions'; -import { FileStorageAction } from './fileStorage/actions'; -import { FlashFirmwareAction } from './firmware/actions'; -import { HubAction, HubMessageAction } from './hub/actions'; -import { LicenseAction } from './licenses/actions'; -import { - BootloaderConnectionAction, - BootloaderDidFailToRequestAction, - BootloaderDidRequestAction, - BootloaderRequestAction, - BootloaderResponseAction, -} from './lwp3-bootloader/actions'; -import { MpyAction } from './mpy/actions'; -import { NotificationAction } from './notifications/actions'; -import { ServiceWorkerAction } from './service-worker/actions'; -import { SettingsAction } from './settings/actions'; -import { TerminalDataAction } from './terminal/actions'; +import { AnyAction } from 'redux'; + +/** A function that creates action objects. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ActionCreationFunction = (...args: any[]) => A; + +/** A function that performs type discrimination on an action. */ +type MatchFunction = (action: AnyAction) => action is A; + +/** The extra members that are attached to a function by createAction(). */ +type MatchableExtensions, A extends AnyAction> = { + toString(): ReturnType['type']; + matches: MatchFunction>; +}; + +/** An action creation function that includes MatchableExtensions. */ +type Matchable, A extends AnyAction> = F & + MatchableExtensions; /** - * Common type for all actions. + * Adds additional members to an action creation function. + * + * @param actionCreator The action creation function. + * @returns actionCreator with type property and match method added. */ -export type Action = - | AppAction - | BLEAction - | BLEConnectAction - | BleDIServiceAction - | BlePybricksServiceAction - | BlePybricksServiceCommandAction - | BlePybricksServiceEventAction - | BleUartAction - | BootloaderConnectionAction - | BootloaderDidRequestAction - | BootloaderDidFailToRequestAction - | BootloaderRequestAction - | BootloaderResponseAction - | FileStorageAction - | EditorAction - | FlashFirmwareAction - | HubAction - | HubMessageAction - | LicenseAction - | MpyAction - | NotificationAction - | ServiceWorkerAction - | SettingsAction - | TerminalDataAction; +export function createAction, A extends AnyAction>( + actionCreator: T, +): Matchable { + // create a default action so we can get the type string. + const type = actionCreator().type; + + return Object.assign(actionCreator, >{ + toString: () => type, + matches: (action) => action.type === type, + }); +} diff --git a/src/app/actions.ts b/src/app/actions.ts index b5f2d32c..5863bd77 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,117 +1,56 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Actions for the app in general. -import { Action } from 'redux'; +import { createAction } from '../actions'; import { BeforeInstallPromptEvent } from '../utils/dom'; -/** App action types. */ -export enum AppActionType { - /** Reload the app. */ - Reload = 'app.action.reload', - /** Checks for an available update. */ - CheckForUpdate = 'app.action.checkForUpdate', - /** Indicates that checking for update finished. */ - DidCheckForUpdate = 'app.action.didCheckForUpdate', - /* Indicates the browser wants to prompt the use to install the app. */ - DidBeforeInstallPrompt = 'app.action.didBeforeInstallPrompt', - /* Requests to prompt the user to install the app. */ - InstallPrompt = 'app.action.installPrompt', - /* Indicates that the user responded to the install prompt. */ - DidInstallPrompt = 'app.action.didInstallPrompt', - /* Indicates that the app was installed. */ - DidInstall = 'app.action.didInstall', - /** The app has just ben started. */ - DidStart = 'app.action.didStart', -} - -/** Action that requests the app to reload. */ -export type AppReloadAction = Action & { - registration: ServiceWorkerRegistration; -}; - /** Creates an action that requests the app to reload. */ -export function reload(registration: ServiceWorkerRegistration): AppReloadAction { - return { type: AppActionType.Reload, registration }; -} +export const reload = createAction((registration: ServiceWorkerRegistration) => ({ + type: 'app.action.reload', + registration, +})); /** Action that requests to check for updates. */ -export type AppCheckForUpdatesAction = Action & { - registration: ServiceWorkerRegistration; -}; - -/** Action that requests to check for updates. */ -export function checkForUpdate( - registration: ServiceWorkerRegistration, -): AppCheckForUpdatesAction { - return { type: AppActionType.CheckForUpdate, registration }; -} +export const checkForUpdate = createAction( + (registration: ServiceWorkerRegistration) => ({ + type: 'app.action.checkForUpdate', + registration, + }), +); /** Action that indicates that checking for an update has completed. */ -export type AppDidCheckForUpdateAction = Action & { - updateFound: boolean; -}; - -/** Action that indicates that checking for an update has completed. */ -export function didCheckForUpdate(updateFound: boolean): AppDidCheckForUpdateAction { - return { type: AppActionType.DidCheckForUpdate, updateFound }; -} +export const didCheckForUpdate = createAction((updateFound: boolean) => ({ + type: 'app.action.didCheckForUpdate', + updateFound, +})); /* Action that indicates the browser wants to prompt the use to install the app. */ -export type AppDidBeforeInstallPromptAction = - Action & { - event: BeforeInstallPromptEvent; - }; - -/* Action that indicates the browser wants to prompt the use to install the app. */ -export function didBeforeInstallPrompt( - event: BeforeInstallPromptEvent, -): AppDidBeforeInstallPromptAction { - return { type: AppActionType.DidBeforeInstallPrompt, event }; -} +export const didBeforeInstallPrompt = createAction( + (event: BeforeInstallPromptEvent) => ({ + type: 'app.action.didBeforeInstallPrompt', + event, + }), +); /* Action that requests to prompt the user to install the app. */ -export type AppInstallPromptAction = Action & { - event: BeforeInstallPromptEvent; -}; - -/* Action that requests to prompt the user to install the app. */ -export function installPrompt(event: BeforeInstallPromptEvent): AppInstallPromptAction { - return { type: AppActionType.InstallPrompt, event }; -} +export const installPrompt = createAction((event: BeforeInstallPromptEvent) => ({ + type: 'app.action.installPrompt', + event, +})); /* Action that indicates that the user responded to the install prompt. */ -export type AppDidInstallPromptAction = Action; - -/* Action that indicates that the user responded to the install prompt. */ -export function didInstallPrompt(): AppDidInstallPromptAction { - return { type: AppActionType.DidInstallPrompt }; -} +export const didInstallPrompt = createAction(() => ({ + type: 'app.action.didInstallPrompt', +})); /* Action that indicates app was installed. */ -export type AppDidInstallAction = Action; - -/* Action that indicates app was installed. */ -export function didInstall(): AppDidInstallAction { - return { type: AppActionType.DidInstall }; -} - -/** Action that indicates the app has just started. */ -export type AppDidStartAction = Action; +export const didInstall = createAction(() => ({ + type: 'app.action.didInstall', +})); /** Creates an action that indicates the app has just started. */ -export function didStart(): AppDidStartAction { - return { type: AppActionType.DidStart }; -} - -/** common type for all app actions. */ -export type AppAction = - | AppReloadAction - | AppCheckForUpdatesAction - | AppDidCheckForUpdateAction - | AppDidBeforeInstallPromptAction - | AppInstallPromptAction - | AppDidInstallPromptAction - | AppDidInstallAction - | AppDidStartAction; +export const didStart = createAction(() => ({ + type: 'app.action.didStart', +})); diff --git a/src/app/reducers.test.ts b/src/app/reducers.test.ts index 95914627..eb80f8df 100644 --- a/src/app/reducers.test.ts +++ b/src/app/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { didSucceed, didUpdate } from '../service-worker/actions'; import { BeforeInstallPromptEvent } from '../utils/dom'; import { @@ -17,7 +17,7 @@ import reducers from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "beforeInstallPrompt": null, "checkingForUpdate": false, diff --git a/src/app/reducers.ts b/src/app/reducers.ts index 3237f046..a9824f31 100644 --- a/src/app/reducers.ts +++ b/src/app/reducers.ts @@ -1,84 +1,92 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Manages state the app in general. import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { ServiceWorkerActionType } from '../service-worker/actions'; +import { didSucceed, didUpdate } from '../service-worker/actions'; import { BeforeInstallPromptEvent } from '../utils/dom'; -import { AppActionType } from './actions'; +import { + checkForUpdate, + didBeforeInstallPrompt, + didCheckForUpdate, + didInstall, + didInstallPrompt, + installPrompt, +} from './actions'; -const serviceWorker: Reducer = ( +const serviceWorker: Reducer = ( state = null, action, ) => { - switch (action.type) { - case ServiceWorkerActionType.DidSucceed: - return action.registration; - default: - return state; + if (didSucceed.matches(action)) { + return action.registration; } + + return state; }; -const checkingForUpdate: Reducer = (state = false, action) => { - switch (action.type) { - case AppActionType.CheckForUpdate: - return true; - case AppActionType.DidCheckForUpdate: - if (!action.updateFound) { - return false; - } - // otherwise we wait for service worker to download everything - return state; - case ServiceWorkerActionType.DidUpdate: +const checkingForUpdate: Reducer = (state = false, action) => { + if (checkForUpdate.matches(action)) { + return true; + } + + if (didCheckForUpdate.matches(action)) { + if (!action.updateFound) { return false; - default: - return state; + } + // otherwise we wait for service worker to download everything + return state; } + + if (didUpdate.matches(action)) { + return false; + } + + return state; }; -const updateAvailable: Reducer = (state = false, action) => { - switch (action.type) { - case ServiceWorkerActionType.DidUpdate: - return true; - default: - return state; +const updateAvailable: Reducer = (state = false, action) => { + if (didUpdate.matches(action)) { + return true; } + + return state; }; -const beforeInstallPrompt: Reducer = ( +const beforeInstallPrompt: Reducer = ( state = null, action, ) => { - switch (action.type) { - case AppActionType.DidBeforeInstallPrompt: - return action.event; - case AppActionType.DidInstall: - return null; - default: - return state; + if (didBeforeInstallPrompt.matches(action)) { + return action.event; } + + if (didInstall.matches(action)) { + return null; + } + + return state; }; -const promptingInstall: Reducer = (state = false, action) => { - switch (action.type) { - case AppActionType.InstallPrompt: - return true; - case AppActionType.DidInstallPrompt: - return false; - default: - return state; +const promptingInstall: Reducer = (state = false, action) => { + if (installPrompt.matches(action)) { + return true; } + + if (didInstallPrompt.matches(action)) { + return false; + } + + return state; }; -const readyForOfflineUse: Reducer = (state = false, action) => { - switch (action.type) { - case ServiceWorkerActionType.DidSucceed: - return true; - default: - return state; +const readyForOfflineUse: Reducer = (state = false, action) => { + if (didSucceed.matches(action)) { + return true; } + + return state; }; export default combineReducers({ diff --git a/src/app/sagas.ts b/src/app/sagas.ts index 8319a687..8bc82484 100644 --- a/src/app/sagas.ts +++ b/src/app/sagas.ts @@ -1,18 +1,17 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { eventChannel } from 'redux-saga'; import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro'; import { BeforeInstallPromptEvent } from '../utils/dom'; import { - AppActionType, - AppCheckForUpdatesAction, - AppInstallPromptAction, - AppReloadAction, + checkForUpdate, didBeforeInstallPrompt, didCheckForUpdate, didInstall, didInstallPrompt, + installPrompt, + reload, } from './actions'; function* monitorAppInstalled(): Generator { @@ -50,18 +49,18 @@ function* monitorBeforeInstallPrompt(): Generator { } } -function* reload(action: AppReloadAction): Generator { +function* handleReload(action: ReturnType): Generator { yield* call(() => action.registration.unregister()); location.reload(); } -function* checkForUpdate(action: AppCheckForUpdatesAction): Generator { +function* handleCheckForUpdate(action: ReturnType): Generator { yield* call(() => action.registration.update()); const updateFound = action.registration.installing !== null; yield* put(didCheckForUpdate(updateFound)); } -function* installPrompt(action: AppInstallPromptAction): Generator { +function* handleInstallPrompt(action: ReturnType): Generator { yield* call(() => action.event.prompt()); yield* call(() => action.event.userChoice); yield* put(didInstallPrompt()); @@ -70,7 +69,7 @@ function* installPrompt(action: AppInstallPromptAction): Generator { export default function* app(): Generator { yield* fork(monitorAppInstalled); yield* fork(monitorBeforeInstallPrompt); - yield* takeEvery(AppActionType.Reload, reload); - yield* takeEvery(AppActionType.CheckForUpdate, checkForUpdate); - yield* takeEvery(AppActionType.InstallPrompt, installPrompt); + yield* takeEvery(reload, handleReload); + yield* takeEvery(checkForUpdate, handleCheckForUpdate); + yield* takeEvery(installPrompt, handleInstallPrompt); } diff --git a/src/ble-device-info-service/actions.ts b/src/ble-device-info-service/actions.ts index e7d7b404..15e9da24 100644 --- a/src/ble-device-info-service/actions.ts +++ b/src/ble-device-info-service/actions.ts @@ -1,56 +1,27 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { Action } from 'redux'; +import { createAction } from '../actions'; import { PnpId } from './protocol'; -export enum BleDIServiceActionType { - DidReceiveFirmwareRevision = 'action.bleDIService.didReceiveFirmwareRevision', - DidReceiveSoftwareRevision = 'action.bleDIService.didReceiveSoftwareRevision', - DidReceivePnPId = 'action.bleDIService.didReceivePnPId', -} - /** Action that indicates the firmware revision characteristic was read. */ -export type BleDIServiceDidReceiveFirmwareRevisionAction = - Action & { - version: string; - }; - -/** Action that indicates the firmware revision characteristic was read. */ -export function bleDIServiceDidReceiveFirmwareRevision( - version: string, -): BleDIServiceDidReceiveFirmwareRevisionAction { - return { type: BleDIServiceActionType.DidReceiveFirmwareRevision, version }; -} +export const bleDIServiceDidReceiveFirmwareRevision = createAction( + (version: string) => ({ + type: 'action.bleDIService.didReceiveFirmwareRevision', + version, + }), +); /** Action that indicates the software revision characteristic was read. */ -export type BleDIServiceDidReceiveSoftwareRevisionAction = - Action & { - version: string; - }; - -/** Action that indicates the software revision characteristic was read. */ -export function bleDIServiceDidReceiveSoftwareRevision( - version: string, -): BleDIServiceDidReceiveSoftwareRevisionAction { - return { type: BleDIServiceActionType.DidReceiveSoftwareRevision, version }; -} +export const bleDIServiceDidReceiveSoftwareRevision = createAction( + (version: string) => ({ + type: 'action.bleDIService.didReceiveSoftwareRevision', + version, + }), +); /** Action that indicates the PnP ID characteristic was read. */ -export type BleDIServiceDidReceivePnPIdAction = - Action & { - pnpId: PnpId; - }; - -/** Action that indicates the PnP ID characteristic was read. */ -export function bleDIServiceDidReceivePnPId( - pnpId: PnpId, -): BleDIServiceDidReceivePnPIdAction { - return { type: BleDIServiceActionType.DidReceivePnPId, pnpId }; -} - -/** Common type for all device info service actions. */ -export type BleDIServiceAction = - | BleDIServiceDidReceiveFirmwareRevisionAction - | BleDIServiceDidReceiveSoftwareRevisionAction - | BleDIServiceDidReceivePnPIdAction; +export const bleDIServiceDidReceivePnPId = createAction((pnpId: PnpId) => ({ + type: 'action.bleDIService.didReceivePnPId', + pnpId, +})); diff --git a/src/ble-nordic-uart-service/actions.ts b/src/ble-nordic-uart-service/actions.ts index 6756aa61..97dc1726 100644 --- a/src/ble-nordic-uart-service/actions.ts +++ b/src/ble-nordic-uart-service/actions.ts @@ -1,69 +1,27 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Actions for Bluetooth Low Energy Nordic UART service -import { Action } from 'redux'; +import { createAction } from '../actions'; -/** - * BLE nRF UART service actions types. - */ -export enum BleUartActionType { - /** - * Write data. - */ - Write = 'bleUart.action.write', - /** - * Writing completed successfully. - */ - DidWrite = 'bleUart.didWrite', - /** - * Writing failed. - */ - DidFailToWrite = 'bleUart.action.didFailToWrite', - /** - * Notify that data was received. - */ - DidNotify = 'bleUart.action.didNotify', -} +export const write = createAction((id: number, value: Uint8Array) => ({ + type: 'bleUart.action.write', + id, + value, +})); -export type BleUartWriteAction = Action & { - id: number; - value: Uint8Array; -}; +export const didWrite = createAction((id: number) => ({ + type: 'bleUart.action.didWrite', + id, +})); -export function write(id: number, value: Uint8Array): BleUartWriteAction { - return { type: BleUartActionType.Write, id, value }; -} - -export type BleUartDidWriteAction = Action & { - id: number; -}; - -export function didWrite(id: number): BleUartDidWriteAction { - return { type: BleUartActionType.DidWrite, id }; -} - -export type BleUartDidFailToWriteAction = Action & { - id: number; - err: Error; -}; - -export function didFailToWrite(id: number, err: Error): BleUartDidFailToWriteAction { - return { type: BleUartActionType.DidFailToWrite, id, err }; -} - -export type BleUartDidNotifyAction = Action & { - value: DataView; -}; - -export function didNotify(value: DataView): BleUartDidNotifyAction { - return { type: BleUartActionType.DidNotify, value }; -} - -/** Common type for low-level BLE data actions. */ -export type BleUartAction = - | BleUartWriteAction - | BleUartDidWriteAction - | BleUartDidFailToWriteAction - | BleUartDidNotifyAction; +export const didFailToWrite = createAction((id: number, err: Error) => ({ + type: 'bleUart.action.didFailToWrite', + id, + err, +})); +export const didNotify = createAction((value: DataView) => ({ + type: 'bleUart.action.didNotify', + value, +})); diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index 33d1b2e6..ecddb2b3 100644 --- a/src/ble-pybricks-service/actions.ts +++ b/src/ble-pybricks-service/actions.ts @@ -1,238 +1,93 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors -// actions/blePybricksService.ts: Actions for Bluetooth Low Energy Pybricks service +// Copyright (c) 2021-2022 The Pybricks Authors +// +// Actions for Bluetooth Low Energy Pybricks service -import { Action } from 'redux'; +import { createAction } from '../actions'; -/** - * BLE Pybricks service actions types. - */ -export enum BlePybricksServiceActionType { - /** - * Write command to control characteristic. - */ - WriteCommand = 'blePybricksService.action.writeCommand', - /** - * Writing command to control characteristic completed successfully. - */ - DidWriteCommand = 'blePybricksService.action.didWriteCommand', - /** - * Writing command to control characteristic failed. - */ - DidFailToWriteCommand = 'blePybricksService.action.didFailToWriteCommand', - /** - * Event notification was received from the control characteristic. - */ - DidNotifyEvent = 'blePybricksService.action.didNotifyEvent', -} +// Low-level connection actions. /** * Action that request to write a command to the Pybricks service control characteristic. */ -export type BlePybricksServiceWriteCommandAction = - Action & { - id: number; - value: Uint8Array; - }; - -/** - * Action that request to write a command to the Pybricks service control characteristic. - */ -export function writeCommand( - id: number, - value: Uint8Array, -): BlePybricksServiceWriteCommandAction { - return { - type: BlePybricksServiceActionType.WriteCommand, - id, - value, - }; -} +export const writeCommand = createAction((id: number, value: Uint8Array) => ({ + type: 'blePybricksService.action.writeCommand', + id, + value, +})); /** * Action that indicates sending a command to the Pybricks service control characteristic was successful. */ -export type BlePybricksServiceDidWriteCommandAction = - Action & { - id: number; - }; - -/** - * Action that indicates sending a command to the Pybricks service control characteristic was successful. - */ -export function didWriteCommand(id: number): BlePybricksServiceDidWriteCommandAction { - return { - type: BlePybricksServiceActionType.DidWriteCommand, - id, - }; -} +export const didWriteCommand = createAction((id: number) => ({ + type: 'blePybricksService.action.didWriteCommand', + id, +})); /** * Action that indicates sending a command to the Pybricks service control characteristic failed. */ -export type BlePybricksServiceDidFailToWriteCommandAction = - Action & { - id: number; - err: Error; - }; - -/** - * Action that indicates sending a command to the Pybricks service control characteristic failed. - */ -export function didFailToWriteCommand( - id: number, - err: Error, -): BlePybricksServiceDidFailToWriteCommandAction { - return { - type: BlePybricksServiceActionType.DidFailToWriteCommand, - id, - err, - }; -} +export const didFailToWriteCommand = createAction((id: number, err: Error) => ({ + type: 'blePybricksService.action.didFailToWriteCommand', + id, + err, +})); /** * Action that indicates an event notification was received on the Pybricks service control characteristic. */ -export type BlePybricksServiceDidNotifyEventAction = - Action & { - value: DataView; - }; - -/** - * Action that indicates an event notification was received on the Pybricks service control characteristic. - */ -export function didNotifyEvent( - value: DataView, -): BlePybricksServiceDidNotifyEventAction { - return { - type: BlePybricksServiceActionType.DidNotifyEvent, - value, - }; -} - -/** Common type for BLE Pybricks service actions. */ -export type BlePybricksServiceAction = - | BlePybricksServiceWriteCommandAction - | BlePybricksServiceDidWriteCommandAction - | BlePybricksServiceDidFailToWriteCommandAction - | BlePybricksServiceDidNotifyEventAction; +export const didNotifyEvent = createAction((value: DataView) => ({ + type: 'blePybricksService.action.didNotifyEvent', + value, +})); /** 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; +export const sendStopUserProgramCommand = createAction((id: number) => ({ + type: 'blePybricksServiceCommand.action.sendStopUserProgram', + id, +})); /** * 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; - }; +export const didSendCommand = createAction((id: number) => ({ + type: 'blePybricksServiceCommand.action.didSend', + id, +})); /** * 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; +export const didFailToSendCommand = createAction((id: number, err: Error) => ({ + type: 'blePybricksServiceCommand.action.didFailToSend', + id, + err, +})); /** Action types for events received from the Pybricks service control characteristic. */ -export enum BlePybricksServiceEventActionType { - /** A status report event was received. */ - DidReceiveStatusReport = 'blePybricksServiceEvent.action.didReceiveStatusReport', - /** A pseudo-event indicating there was a protocol error (not directly received from the hub). */ - ProtocolError = 'blePybricksServiceEvent.action.protocolError', -} - -/** - * Action that represents a status report event received from the hub. - */ -export type BlePybricksServiceEventStatusReportAction = - Action & { - statusFlags: number; - }; /** * Action that represents a status report event received from the hub. * @param statusFlags The status flags. */ -export function didReceiveStatusReport( - statusFlags: number, -): BlePybricksServiceEventStatusReportAction { - return { - type: BlePybricksServiceEventActionType.DidReceiveStatusReport, - statusFlags, - }; -} +export const didReceiveStatusReport = createAction((statusFlags: number) => ({ + type: 'blePybricksServiceEvent.action.didReceiveStatusReport', + statusFlags, +})); /** - * Pseudo-event (not received from hub) indicating that there was a protocol error. - */ -export type BlePybricksServiceEventProtocolErrorAction = - Action & { - err: Error; - }; - -/** - * Pseudo-event (not received from hub) indicating that there was a protocol error. + * Pseudo-event = actionCreator((not received from hub) indicating that there was a protocol error. * @param err The error that was caught. */ -export function eventProtocolError( - err: Error, -): BlePybricksServiceEventProtocolErrorAction { - return { type: BlePybricksServiceEventActionType.ProtocolError, err }; -} - -/** Common type for Pybricks control characteristic event actions. */ -export type BlePybricksServiceEventAction = - | BlePybricksServiceEventStatusReportAction - | BlePybricksServiceEventProtocolErrorAction; +export const eventProtocolError = createAction((err: Error) => ({ + type: 'blePybricksServiceEvent.action.protocolError', + err, +})); diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index 2f4b81cc..a8f50c37 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // // Handles Pybricks protocol. +import { AnyAction } from 'redux'; import { actionChannel, fork, @@ -11,19 +12,16 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; import { ensureError, hex } from '../utils'; import { - BlePybricksServiceActionType, - BlePybricksServiceCommandAction, - BlePybricksServiceCommandActionType, - BlePybricksServiceDidFailToWriteCommandAction, - BlePybricksServiceDidNotifyEventAction, - BlePybricksServiceDidWriteCommandAction, didFailToSendCommand, + didFailToWriteCommand, + didNotifyEvent, didReceiveStatusReport, didSendCommand, + didWriteCommand, eventProtocolError, + sendStopUserProgramCommand, writeCommand, } from './actions'; import { @@ -41,38 +39,27 @@ import { function* encodeRequest(): Generator { // Using a while loop to serialize sending data to avoid "busy" errors. - const sendCommands: readonly BlePybricksServiceCommandActionType[] = Object.values( - BlePybricksServiceCommandActionType, - ).filter( - (x) => - x !== BlePybricksServiceCommandActionType.DidSend && - x != BlePybricksServiceCommandActionType.DidFailToSend, - ); - - const chan = yield* actionChannel((a: Action) => - sendCommands.includes(a.type as BlePybricksServiceCommandActionType), + const chan = yield* actionChannel( + (a: AnyAction) => + typeof a.type === 'string' && + a.type.startsWith('blePybricksServiceCommand.action.send'), ); 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.type}`); - continue; + /* istanbul ignore else: should not be possible to reach */ + if (sendStopUserProgramCommand.matches(action)) { + yield* put(writeCommand(action.id, createStopUserProgramCommand())); + } else { + console.error(`Unknown Pybricks service command ${action.type}`); + continue; } const { failedToSend } = yield* race({ - sent: take( - BlePybricksServiceActionType.DidWriteCommand, - ), - failedToSend: take( - BlePybricksServiceActionType.DidFailToWriteCommand, - ), + sent: take>(didWriteCommand), + failedToSend: + take>(didFailToWriteCommand), }); if (failedToSend) { @@ -87,7 +74,7 @@ function* encodeRequest(): Generator { * Converts an incoming connection message to a response action. * @param action The received response action. */ -function* decodeResponse(action: BlePybricksServiceDidNotifyEventAction): Generator { +function* decodeResponse(action: ReturnType): Generator { try { const responseType = getEventType(action.value); switch (responseType) { @@ -107,5 +94,5 @@ function* decodeResponse(action: BlePybricksServiceDidNotifyEventAction): Genera export default function* (): Generator { yield* fork(encodeRequest); - yield* takeEvery(BlePybricksServiceActionType.DidNotifyEvent, decodeResponse); + yield* takeEvery(didNotifyEvent, decodeResponse); } diff --git a/src/ble/actions.ts b/src/ble/actions.ts index 19f39181..0c85d711 100644 --- a/src/ble/actions.ts +++ b/src/ble/actions.ts @@ -1,62 +1,24 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Actions for managing Bluetooth Low Energy connections. -import { Action } from 'redux'; - -/** - * Bluetooth low energy device action types. - */ -export enum BleDeviceActionType { - /** - * Connecting to a device has been requested. - */ - Connect = 'ble.device.action.connect', - /** - * The connection completed successfully. - */ - DidConnect = 'ble.device.action.didConnect', - /** - * The connection did not complete successfully. - */ - DidFailToConnect = 'ble.device.action.didFailToConnect', - /** - * Disconnecting from a device has been requested. - */ - Disconnect = 'ble.device.action.disconnect', - /** - * The device was disconnected. - */ - DidDisconnect = 'ble.device.action.didDisconnect', - /** - * The device fail to disconnect. - */ - DidFailToDisconnect = 'ble.device.action.didFailToDisconnect', -} - -export type BleDeviceConnectAction = Action; - +import { createAction } from '../actions'; /** * Creates an action that indicates connecting has been requested. */ -export function connect(): BleDeviceConnectAction { - return { type: BleDeviceActionType.Connect }; -} - -export type BleDeviceDidConnectAction = Action & { - /** A unique identifier for the connected hub. */ - id: string; - /** A user-displayable name for the connected hub. */ - name: string; -}; +export const connect = createAction(() => ({ + type: 'ble.device.action.connect', +})); /** * Creates an action that indicates a device was connected. */ -export function didConnect(id: string, name: string): BleDeviceDidConnectAction { - return { type: BleDeviceActionType.DidConnect, id, name }; -} +export const didConnect = createAction((id: string, name: string) => ({ + type: 'ble.device.action.didConnect', + id, + name, +})); export enum BleDeviceFailToConnectReasonType { NoWebBluetooth = 'ble.device.didFailToConnect.noWebBluetooth', @@ -104,69 +66,41 @@ export type BleDeviceDidFailToConnectReason = | BleDeviceFailToConnectNoPybricksServiceReason | BleDeviceFailToConnectUnknownReason; -export type BleDeviceDidFailToConnectAction = - Action & BleDeviceDidFailToConnectReason; - /** * Creates an action that indicates a device failed to connect. */ -export function didFailToConnect( - reason: BleDeviceDidFailToConnectReason, -): BleDeviceDidFailToConnectAction { - return { type: BleDeviceActionType.DidFailToConnect, ...reason }; -} - -export type BleDeviceDisconnectAction = Action; +export const didFailToConnect = createAction( + (reason: BleDeviceDidFailToConnectReason) => ({ + type: 'ble.device.action.didFailToConnect', + ...reason, + }), +); /** * Creates an action that indicates disconnecting was requested. */ -export function disconnect(): BleDeviceDisconnectAction { - return { type: BleDeviceActionType.Disconnect }; -} - -export type BleDeviceDidDisconnectAction = Action; +export const disconnect = createAction(() => ({ + type: 'ble.device.action.disconnect', +})); /** * Creates an action that indicates a device was disconnected. */ -export function didDisconnect(): BleDeviceDidDisconnectAction { - return { type: BleDeviceActionType.DidDisconnect }; -} - -export type BleDeviceDidFailToDisconnectAction = - Action; +export const didDisconnect = createAction(() => ({ + type: 'ble.device.action.didDisconnect', +})); /** * Creates an action that indicates a device failed to disconnect. */ -export function didFailToDisconnect(): BleDeviceDidFailToDisconnectAction { - return { type: BleDeviceActionType.DidFailToDisconnect }; -} - -/** - * Common type for all BLE connection actions. - */ -export type BLEConnectAction = - | BleDeviceConnectAction - | BleDeviceDidConnectAction - | BleDeviceDidFailToConnectAction - | BleDeviceDisconnectAction - | BleDeviceDidDisconnectAction - | BleDeviceDidFailToDisconnectAction; +export const didFailToDisconnect = createAction(() => ({ + type: 'ble.device.action.didFailToDisconnect', +})); /** * High-level BLE actions. */ -export enum BLEActionType { - Toggle = 'ble.action.toggle', -} -export type BLEToggleAction = Action; - -export function toggleBluetooth(): BLEToggleAction { - return { type: BLEActionType.Toggle }; -} - -/** Common type for high-level BLE actions */ -export type BLEAction = BLEToggleAction; +export const toggleBluetooth = createAction(() => ({ + type: 'ble.action.toggle', +})); diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 06a0662a..742c5ed3 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -24,7 +24,7 @@ import reducers, { BleConnectionState } from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "connection": "ble.connection.state.disconnected", "deviceBatteryCharging": false, diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index c14e089b..f69d9eaf 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -1,16 +1,25 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Manages state for the Bluetooth Low Energy connection. // This assumes that there is only one global connection to a single device. import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { BleDIServiceActionType } from '../ble-device-info-service/actions'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, +} from '../ble-device-info-service/actions'; import { getHubTypeName } from '../ble-device-info-service/protocol'; -import { BlePybricksServiceEventActionType } from '../ble-pybricks-service/actions'; +import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { BleDeviceActionType } from './actions'; +import { + connect, + didConnect, + didDisconnect, + didFailToConnect, + didFailToDisconnect, + disconnect, +} from './actions'; /** * Describes the state of the BLE connection. @@ -34,80 +43,87 @@ export enum BleConnectionState { Disconnecting = 'ble.connection.state.disconnecting', } -const connection: Reducer = ( +const connection: Reducer = ( state = BleConnectionState.Disconnected, action, ) => { - switch (action.type) { - case BleDeviceActionType.Connect: - return BleConnectionState.Connecting; - case BleDeviceActionType.DidConnect: - case BleDeviceActionType.DidFailToDisconnect: - return BleConnectionState.Connected; - case BleDeviceActionType.Disconnect: - return BleConnectionState.Disconnecting; - case BleDeviceActionType.DidFailToConnect: - case BleDeviceActionType.DidDisconnect: - return BleConnectionState.Disconnected; - default: - return state; + if (connect.matches(action)) { + return BleConnectionState.Connecting; } + + if (didConnect.matches(action) || didFailToDisconnect.matches(action)) { + return BleConnectionState.Connected; + } + + if (disconnect.matches(action)) { + return BleConnectionState.Disconnecting; + } + + if (didFailToConnect.matches(action) || didDisconnect.matches(action)) { + return BleConnectionState.Disconnected; + } + + return state; }; -const deviceName: Reducer = (state = '', action) => { - switch (action.type) { - case BleDeviceActionType.DidDisconnect: - return ''; - case BleDeviceActionType.DidConnect: - return action.name; - default: - return state; +const deviceName: Reducer = (state = '', action) => { + if (didDisconnect.matches(action)) { + return ''; } + + if (didConnect.matches(action)) { + return action.name; + } + + return state; }; -const deviceType: Reducer = (state = '', action) => { - switch (action.type) { - case BleDeviceActionType.DidDisconnect: - return ''; - case BleDIServiceActionType.DidReceivePnPId: - return getHubTypeName(action.pnpId); - default: - return state; +const deviceType: Reducer = (state = '', action) => { + if (didDisconnect.matches(action)) { + return ''; } + + if (bleDIServiceDidReceivePnPId.matches(action)) { + return getHubTypeName(action.pnpId); + } + + return state; }; -const deviceFirmwareVersion: Reducer = (state = '', action) => { - switch (action.type) { - case BleDeviceActionType.DidDisconnect: - return ''; - case BleDIServiceActionType.DidReceiveFirmwareRevision: - return action.version; - default: - return state; +const deviceFirmwareVersion: Reducer = (state = '', action) => { + if (didDisconnect.matches(action)) { + return ''; } + + if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) { + return action.version; + } + + return state; }; -const deviceLowBatteryWarning: Reducer = (state = false, action) => { - switch (action.type) { - case BleDeviceActionType.DidDisconnect: - return false; - case BlePybricksServiceEventActionType.DidReceiveStatusReport: - return Boolean( - action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning), - ); - default: - return state; +const deviceLowBatteryWarning: Reducer = (state = false, action) => { + if (didDisconnect.matches(action)) { + return false; } + + if (didReceiveStatusReport.matches(action)) { + return Boolean( + action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning), + ); + } + + return state; }; -const deviceBatteryCharging: Reducer = (state = false, action) => { - switch (action.type) { - case BleDeviceActionType.DidDisconnect: - return false; - // TODO: hub does not currently have a status flag for this - default: - return state; +const deviceBatteryCharging: Reducer = (state = false, action) => { + if (didDisconnect.matches(action)) { + return false; } + + // TODO: hub does not currently have a status flag for this + + return state; }; export default combineReducers({ diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index d591a798..b2edee5a 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Manages connection to a Bluetooth Low Energy device running Pybricks firmware. @@ -28,11 +28,10 @@ import { softwareRevisionStringUUID, } from '../ble-device-info-service/protocol'; import { - BleUartActionType, - BleUartWriteAction, didFailToWrite as didFailToWriteUart, didNotify as didNotifyUart, didWrite as didWriteUart, + write as writeUart, } from '../ble-nordic-uart-service/actions'; import { RxCharUUID as uartRxCharUUID, @@ -40,10 +39,10 @@ import { TxCharUUID as uartTxCharUUID, } from '../ble-nordic-uart-service/protocol'; import { - BlePybricksServiceActionType, didFailToWriteCommand, didNotifyEvent, didWriteCommand, + writeCommand, } from '../ble-pybricks-service/actions'; import { ControlCharacteristicUUID as pybricksCommandCharacteristicUUID, @@ -52,25 +51,21 @@ import { import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { - BLEActionType, - BleDeviceActionType as BLEDeviceActionType, - BLEToggleAction, - BleDeviceConnectAction, - BleDeviceDisconnectAction, BleDeviceFailToConnectReasonType as Reason, - connect as connectAction, + connect, didConnect, didDisconnect, didFailToConnect, - disconnect as disconnectAction, + disconnect, + toggleBluetooth, } from './actions'; import { BleConnectionState } from './reducers'; const decoder = new TextDecoder(); -function disconnect( +function handleDisconnect( server: BluetoothRemoteGATTServer, - _action: BleDeviceDisconnectAction, + _action: ReturnType, ): void { server.disconnect(); } @@ -79,9 +74,9 @@ function* handlePybricksControlValueChanged(data: DataView): Generator { yield* put(didNotifyEvent(data)); } -function* writePybricksCommand( +function* handleWriteCommand( char: BluetoothRemoteGATTCharacteristic, - action: BleUartWriteAction, + action: ReturnType, ): Generator { try { yield* call(() => char.writeValueWithoutResponse(action.value.buffer)); @@ -95,9 +90,9 @@ function* handleUartValueChanged(data: DataView): Generator { yield* put(didNotifyUart(data)); } -function* writeUart( +function* handleWriteUart( char: BluetoothRemoteGATTCharacteristic, - action: BleUartWriteAction, + action: ReturnType, ): Generator { try { yield* call(() => char.writeValueWithoutResponse(action.value.buffer)); @@ -107,7 +102,7 @@ function* writeUart( } } -function* connect(_action: BleDeviceConnectAction): Generator { +function* handleConnect(): Generator { if (navigator.bluetooth === undefined) { yield* put(didFailToConnect({ reason: Reason.NoWebBluetooth })); return; @@ -164,7 +159,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { return; } - yield* takeEvery(BLEDeviceActionType.Disconnect, disconnect, server); + yield* takeEvery(disconnect, handleDisconnect, server); let deviceInfoService: BluetoothRemoteGATTService; try { @@ -325,13 +320,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { return; } - tasks.push( - yield* takeEvery( - BlePybricksServiceActionType.WriteCommand, - writePybricksCommand, - pybricksControlChar, - ), - ); + tasks.push(yield* takeEvery(writeCommand, handleWriteCommand, pybricksControlChar)); let uartService: BluetoothRemoteGATTService; try { @@ -407,7 +396,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { return; } - tasks.push(yield* takeEvery(BleUartActionType.Write, writeUart, uartRxChar)); + tasks.push(yield* takeEvery(writeUart, handleWriteUart, uartRxChar)); yield* put(didConnect(device.id, device.name || '')); @@ -421,22 +410,22 @@ function* connect(_action: BleDeviceConnectAction): Generator { yield* put(didDisconnect()); } -function* toggle(_action: BLEToggleAction): Generator { +function* handleToggleBluetooth(): Generator { const connectionState = (yield select( (s: RootState) => s.ble.connection, )) as BleConnectionState; switch (connectionState) { case BleConnectionState.Connected: - yield* put(disconnectAction()); + yield* put(disconnect()); break; case BleConnectionState.Disconnected: - yield* put(connectAction()); + yield* put(connect()); break; } } export default function* (): Generator { - yield* takeEvery(BLEDeviceActionType.Connect, connect); - yield* takeEvery(BLEActionType.Toggle, toggle); + yield* takeEvery(connect, handleConnect); + yield* takeEvery(toggleBluetooth, handleToggleBluetooth); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index ce25fade..867d55ce 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -1,88 +1,43 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { monaco } from 'react-monaco-editor'; -import { Action } from 'redux'; - -export enum EditorActionType { - /** The current (active) editor changed. */ - Current = 'editor.action.current', - /** Save the current file to disk. */ - SaveAs = 'editor.action.saveAs', - /** Saving the file succeeded. */ - DidSaveAs = 'editor.action.didSaveAs', - /** Saving the file failed. */ - DidFailToSaveAs = 'editor.action.didFailToSaveAs', - /** Open a file. */ - Open = 'editor.action.open', -} - -export type CurrentEditorAction = Action & { - editSession: monaco.editor.ICodeEditor | undefined; -}; +import { createAction } from '../actions'; /** * Sets the current (active) edit session. * @param editSession The new edit session. */ -export function setEditSession( - editSession: monaco.editor.ICodeEditor | undefined, -): CurrentEditorAction { - return { type: EditorActionType.Current, editSession }; -} - -/** - * Action that saves the current file. - */ -export type EditorSaveAsAction = Action; +export const setEditSession = createAction( + (editSession: monaco.editor.ICodeEditor | undefined) => ({ + type: 'editor.action.setEditSession', + editSession, + }), +); /** * Creates an action to save the current file */ -export function saveAs(): EditorSaveAsAction { - return { type: EditorActionType.SaveAs }; -} +export const saveAs = createAction(() => ({ + type: 'editor.action.saveAs', +})); /** Action that indicates saving a file succeeded. */ -export type EditorDidSaveAsAction = Action; - -/** Action that indicates saving a file succeeded. */ -export function didSaveAs(): EditorDidSaveAsAction { - return { type: EditorActionType.DidSaveAs }; -} +export const didSaveAs = createAction(() => ({ + type: 'editor.action.didSaveAs', +})); /** Action that indicates saving a file failed. */ -export type EditorDidFailToSaveAsAction = Action & { - err: Error; -}; - -/** Action that indicates saving a file failed. */ -export function didFailToSaveAs(err: Error): EditorDidFailToSaveAsAction { - return { type: EditorActionType.DidFailToSaveAs, err }; -} - -/** - * Action that opens a file. - */ -export type EditorOpenAction = Action & { - /** The data to save */ - data: ArrayBuffer; -}; +export const didFailToSaveAs = createAction((err: Error) => ({ + type: 'editor.action.didFailToSaveAs', + err, +})); /** * Creates an action to save a file * @param data The file data */ -export function open(data: ArrayBuffer): EditorOpenAction { - return { type: EditorActionType.Open, data }; -} - -/** - * Common type for all editor actions. - */ -export type EditorAction = - | CurrentEditorAction - | EditorOpenAction - | EditorSaveAsAction - | EditorDidSaveAsAction - | EditorDidFailToSaveAsAction; +export const open = createAction((data: ArrayBuffer) => ({ + type: 'editor.action.open', + data, +})); diff --git a/src/editor/reducers.test.ts b/src/editor/reducers.test.ts index 142962b3..48d88bf7 100644 --- a/src/editor/reducers.test.ts +++ b/src/editor/reducers.test.ts @@ -2,14 +2,14 @@ // Copyright (c) 2021 The Pybricks Authors import { monaco } from 'react-monaco-editor'; -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { setEditSession } from './actions'; import reducers from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "current": null, } diff --git a/src/editor/reducers.ts b/src/editor/reducers.ts index a2e5fb82..e6b8fe65 100644 --- a/src/editor/reducers.ts +++ b/src/editor/reducers.ts @@ -1,21 +1,16 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { monaco } from 'react-monaco-editor'; import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { EditorActionType } from './actions'; +import { setEditSession } from './actions'; -const current: Reducer = ( - state = null, - action, -) => { - switch (action.type) { - case EditorActionType.Current: - return action.editSession || null; - default: - return state; +const current: Reducer = (state = null, action) => { + if (setEditSession.matches(action)) { + return action.editSession || null; } + + return state; }; export default combineReducers({ current }); diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 696f99eb..5a6afd00 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -2,6 +2,7 @@ // Copyright (c) 2020-2022 The Pybricks Authors import FileSaver from 'file-saver'; +import { AnyAction } from 'redux'; import { call, put, @@ -11,27 +12,19 @@ import { takeEvery, takeLatest, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; import { - FileStorageActionType, - FileStorageDidFailToReadFileAction, - FileStorageDidReadFileAction, + fileStorageDidFailToReadFile, + fileStorageDidInitialize, + fileStorageDidReadFile, fileStorageReadFile, } from '../fileStorage/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; -import { - CurrentEditorAction, - EditorActionType, - EditorOpenAction, - EditorSaveAsAction, - didFailToSaveAs, - didSaveAs, -} from './actions'; +import { didFailToSaveAs, didSaveAs, open, saveAs, setEditSession } from './actions'; const decoder = new TextDecoder(); -function* open(action: EditorOpenAction): Generator { +function* handleOpen(action: ReturnType): Generator { const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor @@ -44,7 +37,7 @@ function* open(action: EditorOpenAction): Generator { editor.setValue(text); } -function* saveAs(_action: EditorSaveAsAction): Generator { +function* handleSaveAs(): Generator { const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor @@ -93,7 +86,7 @@ function* saveAs(_action: EditorSaveAsAction): Generator { yield* put(didSaveAs()); } -function* handleEditSession(action: CurrentEditorAction): Generator { +function* handleSetEditSession(action: ReturnType): Generator { if (action.editSession === null) { // there is not current edit session, nothing to do return; @@ -106,7 +99,7 @@ function* handleEditSession(action: CurrentEditorAction): Generator { ); if (!isStorageInitialized) { - yield* take(FileStorageActionType.DidInitialize); + yield* take(fileStorageDidInitialize); } // TODO: get current file from state @@ -116,14 +109,13 @@ function* handleEditSession(action: CurrentEditorAction): Generator { yield* put(fileStorageReadFile(currentFileName)); const { result } = yield* race({ - result: take( - (a: Action) => - a.type === FileStorageActionType.DidReadFile && - a.fileName === currentFileName, + result: take>( + (a: AnyAction) => + fileStorageDidReadFile.matches(a) && a.fileName === currentFileName, ), - error: take( - (a: Action) => - a.type === FileStorageActionType.DidFailToReadFile && + error: take>( + (a: AnyAction) => + fileStorageDidFailToReadFile.matches(a) && a.fileName === currentFileName, ), }); @@ -134,7 +126,7 @@ function* handleEditSession(action: CurrentEditorAction): Generator { } export default function* (): Generator { - yield* takeEvery(EditorActionType.Open, open); - yield* takeEvery(EditorActionType.SaveAs, saveAs); - yield* takeLatest(EditorActionType.Current, handleEditSession); + yield* takeEvery(open, handleOpen); + yield* takeEvery(saveAs, handleSaveAs); + yield* takeLatest(setEditSession, handleSetEditSession); } diff --git a/src/error-log/sagas.ts b/src/error-log/sagas.ts index b918f7db..c9792a6f 100644 --- a/src/error-log/sagas.ts +++ b/src/error-log/sagas.ts @@ -1,74 +1,63 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { takeEvery } from 'typed-redux-saga/macro'; +import { didFailToWrite as bleUartDidFailToWrite } from '../ble-nordic-uart-service/actions'; +import { eventProtocolError as pybricksEventProtocolError } from '../ble-pybricks-service/actions'; import { - BleUartActionType, - BleUartDidFailToWriteAction, -} from '../ble-nordic-uart-service/actions'; -import { - BlePybricksServiceEventActionType, - BlePybricksServiceEventProtocolErrorAction, -} from '../ble-pybricks-service/actions'; -import { - BleDeviceActionType, - BleDeviceDidFailToConnectAction, BleDeviceFailToConnectReasonType, + didFailToConnect as bleDeviceDidFailToConnect, } from '../ble/actions'; +import { didFailToFetchList } from '../licenses/actions'; import { - LicenseActionType, - LicenseDidFailToFetchListAction, -} from '../licenses/actions'; -import { - BootloaderConnectionActionType, - BootloaderConnectionDidErrorAction, - BootloaderConnectionDidFailToConnectAction, BootloaderConnectionFailureReason, + didError as bootloaderDidError, + didFailToConnect as bootloaderDidFailToConnect, } from '../lwp3-bootloader/actions'; -function bleDeviceDidFailToConnect(action: BleDeviceDidFailToConnectAction): void { +function handleBleDeviceDidFailToConnect( + action: ReturnType, +): void { if (action.reason === BleDeviceFailToConnectReasonType.Unknown) { console.error(action.err); } } -function pybricksProtocolError( - action: BlePybricksServiceEventProtocolErrorAction, +function handlePybricksEventProtocolError( + action: ReturnType, ): void { console.error(action.err); } -function bleDataDidFailToWrite(action: BleUartDidFailToWriteAction): void { +function handleBleUartDidFailToWrite( + action: ReturnType, +): void { console.error(action.err); } -function bootloaderDidFailToConnect( - action: BootloaderConnectionDidFailToConnectAction, +function handleBootloaderDidFailToConnect( + action: ReturnType, ): void { if (action.reason === BootloaderConnectionFailureReason.Unknown) { console.error(action.err); } } -function bootloaderDidError(action: BootloaderConnectionDidErrorAction): void { +function handleBootloaderDidError(action: ReturnType): void { console.error(action.err); } -function licenseDidFailToFetch(action: LicenseDidFailToFetchListAction): void { +function handleLicenseDidFailToFetch( + action: ReturnType, +): void { console.error(`Failed to fetch licenses: ${action.reason.statusText}`); } export default function* (): Generator { - yield* takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect); - yield* takeEvery( - BlePybricksServiceEventActionType.ProtocolError, - pybricksProtocolError, - ); - yield* takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite); - yield* takeEvery( - BootloaderConnectionActionType.DidFailToConnect, - bootloaderDidFailToConnect, - ); - yield* takeEvery(BootloaderConnectionActionType.DidError, bootloaderDidError); - yield* takeEvery(LicenseActionType.DidFailToFetchList, licenseDidFailToFetch); + yield* takeEvery(bleDeviceDidFailToConnect, handleBleDeviceDidFailToConnect); + yield* takeEvery(pybricksEventProtocolError, handlePybricksEventProtocolError); + yield* takeEvery(bleUartDidFailToWrite, handleBleUartDidFailToWrite); + yield* takeEvery(bootloaderDidFailToConnect, handleBootloaderDidFailToConnect); + yield* takeEvery(bootloaderDidError, handleBootloaderDidError); + yield* takeEvery(didFailToFetchList, handleLicenseDidFailToFetch); } diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index a3b5f392..63e00b62 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -1,169 +1,75 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { Action } from 'redux'; - -export enum FileStorageActionType { - /** Action that indicates that the storage backend is ready to use. */ - DidInitialize = 'fileStorage.action.didInitialize', - /** Action that indicates that the storage backend failed to initialize. */ - DidFailToInitialize = 'fileStorage.action.didFailToInitialize', - /** Action that indicates that an item in the storage was created or changed by us or in another tab. */ - DidChangeItem = 'fileStorage.action.didChangeItem', - /** Action that indicates that an item in the storage was removed by us or in another tab. */ - DidRemoveItem = 'fileStorage.action.didRemoveItem', - /** Requests to read a file from storage. */ - ReadFile = 'fileStorage.action.readFile', - /** Response to read file request indicating success. */ - DidReadFile = 'fileStorage.action.didReadFile', - /** Response to read file request indicating failure. */ - DidFailToReadFile = 'fileStorage.action.didFailToReadFile', - /** Requests to write a file to storage. */ - WriteFile = 'fileStorage.action.writeFile', - /** Response to write file request indicating success. */ - DidWriteFile = 'fileStorage.action.didWriteFile', - /** Response to write file request indicating failure. */ - DidFailToWriteFile = 'fileStorage.action.didFailToWriteFile', -} +import { createAction } from '../actions'; /** Action that indicates that the storage backend is ready to use. */ -export type FileStorageDidInitializeAction = - Action; - -/** Action that indicates that the storage backend is ready to use. */ -export function fileStorageDidInitialize(): FileStorageDidInitializeAction { - return { type: FileStorageActionType.DidInitialize }; -} +export const fileStorageDidInitialize = createAction(() => ({ + type: 'fileStorage.action.didInitialize', +})); /** Action that indicates that the storage backend failed to initialize. */ -export type FileStorageDidFailToInitializeAction = - Action & { error: Error }; - -/** Action that indicates that the storage backend failed to initialize. */ -export function fileStorageDidFailToInitialize( - err: Error, -): FileStorageDidFailToInitializeAction { - return { type: FileStorageActionType.DidFailToInitialize, error: err }; -} +export const fileStorageDidFailToInitialize = createAction((error: Error) => ({ + type: 'fileStorage.action.didFailToInitialize', + error, +})); /** Action that indicates that an item in the storage was created or changed by us or in another tab. */ -export type FileStorageDidChangeItemAction = - Action & { - fileName: string; - }; - -/** Action that indicates that an item in the storage was created or changed by us or in another tab. */ -export function fileStorageDidChangeItem( - fileName: string, -): FileStorageDidChangeItemAction { - return { type: FileStorageActionType.DidChangeItem, fileName }; -} +export const fileStorageDidChangeItem = createAction((fileName: string) => ({ + type: 'fileStorage.action.didChangeItem', + fileName, +})); /** Action that indicates that an item in the storage was removed by us or in another tab. */ -export type FileStorageDidRemoveItemAction = - Action & { - fileName: string; - }; - -/** Action that indicates that an item in the storage was removed by us or in another tab. */ -export function fileStorageDidRemoveItem( - fileName: string, -): FileStorageDidRemoveItemAction { - return { type: FileStorageActionType.DidRemoveItem, fileName }; -} +export const fileStorageDidRemoveItem = createAction((fileName: string) => ({ + type: 'fileStorage.action.didRemoveItem', + fileName, +})); /** Requests to read a file from storage. */ -export type FileStorageReadFileAction = Action & { - fileName: string; -}; - -/** Requests to read a file from storage. */ -export function fileStorageReadFile(fileName: string): FileStorageReadFileAction { - return { type: FileStorageActionType.ReadFile, fileName }; -} +export const fileStorageReadFile = createAction((fileName: string) => ({ + type: 'fileStorage.action.readFile', + fileName, +})); /** Response to read file request indicating success. */ -export type FileStorageDidReadFileAction = Action & { - fileName: string; - fileContents: string; -}; - -/** Response to read file request indicating success. */ -export function fileStorageDidReadFile( - fileName: string, - fileContents: string, -): FileStorageDidReadFileAction { - return { type: FileStorageActionType.DidReadFile, fileName, fileContents }; -} +export const fileStorageDidReadFile = createAction( + (fileName: string, fileContents: string) => ({ + type: 'fileStorage.action.didReadFile', + fileName, + fileContents, + }), +); /** Response to read file request indicating failure. */ -export type FileStorageDidFailToReadFileAction = - Action & { - fileName: string; - error: Error; - }; - -/** Response to read file request indicating failure. */ -export function fileStorageDidFailToReadFile( - fileName: string, - error: Error, -): FileStorageDidFailToReadFileAction { - return { type: FileStorageActionType.DidFailToReadFile, fileName, error }; -} +export const fileStorageDidFailToReadFile = createAction( + (fileName: string, error: Error) => ({ + type: 'fileStorage.action.didFailToReadFile', + fileName, + error, + }), +); /** Requests to write a file to storage. */ -export type FileStorageWriteFileAction = Action & { - fileName: string; - fileContents: string; -}; - -/** Requests to write a file to storage. */ -export function fileStorageWriteFile( - fileName: string, - fileContents: string, -): FileStorageWriteFileAction { - return { type: FileStorageActionType.WriteFile, fileName, fileContents }; -} +export const fileStorageWriteFile = createAction( + (fileName: string, fileContents: string) => ({ + type: 'fileStorage.action.writeFile', + fileName, + fileContents, + }), +); /** Response to write file request indicating success. */ -export type FileStorageDidWriteFileAction = - Action & { - fileName: string; - }; - -/** Response to write file request indicating success. */ -export function fileStorageDidWriteFile( - fileName: string, -): FileStorageDidWriteFileAction { - return { type: FileStorageActionType.DidWriteFile, fileName }; -} +export const fileStorageDidWriteFile = createAction((fileName: string) => ({ + type: 'fileStorage.action.didWriteFile', + fileName, +})); /** Response to write file request indicating failure. */ -export type FileStorageDidFailToWriteFileAction = - Action & { - fileName: string; - error: Error; - }; - -/** Response to write file request indicating failure. */ -export function fileStorageDidFailToWriteFile( - fileName: string, - error: Error, -): FileStorageDidFailToWriteFileAction { - return { type: FileStorageActionType.DidFailToWriteFile, fileName, error }; -} - -/** - * Common type for all file storage actions. - */ -export type FileStorageAction = - | FileStorageDidInitializeAction - | FileStorageDidFailToInitializeAction - | FileStorageDidChangeItemAction - | FileStorageDidRemoveItemAction - | FileStorageReadFileAction - | FileStorageDidReadFileAction - | FileStorageDidFailToReadFileAction - | FileStorageWriteFileAction - | FileStorageDidWriteFileAction - | FileStorageDidFailToWriteFileAction; +export const fileStorageDidFailToWriteFile = createAction( + (fileName: string, error: Error) => ({ + type: 'fileStorage.action.didFailToWriteFile', + fileName, + error, + }), +); diff --git a/src/fileStorage/reducers.test.ts b/src/fileStorage/reducers.test.ts index 2587bb96..cd01389a 100644 --- a/src/fileStorage/reducers.test.ts +++ b/src/fileStorage/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { fileStorageDidChangeItem, fileStorageDidInitialize, @@ -12,7 +12,7 @@ import reducers from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "fileNames": Set {}, "isInitialized": false, diff --git a/src/fileStorage/reducers.ts b/src/fileStorage/reducers.ts index 8fcdd3ee..fd624628 100644 --- a/src/fileStorage/reducers.ts +++ b/src/fileStorage/reducers.ts @@ -2,27 +2,30 @@ // Copyright (c) 2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { FileStorageActionType } from './actions'; +import { + fileStorageDidChangeItem, + fileStorageDidInitialize, + fileStorageDidRemoveItem, +} from './actions'; -const isInitialized: Reducer = (state = false, action) => { - switch (action.type) { - case FileStorageActionType.DidInitialize: - return true; - default: - return state; +const isInitialized: Reducer = (state = false, action) => { + if (fileStorageDidInitialize.matches(action)) { + return true; } + + return state; }; -const fileNames: Reducer, Action> = (state = new Set(), action) => { - switch (action.type) { - case FileStorageActionType.DidChangeItem: - return new Set([...state, action.fileName]); - case FileStorageActionType.DidRemoveItem: - return new Set([...state].filter((value) => value !== action.fileName)); - default: - return state; +const fileNames: Reducer> = (state = new Set(), action) => { + if (fileStorageDidChangeItem.matches(action)) { + return new Set([...state, action.fileName]); } + + if (fileStorageDidRemoveItem.matches(action)) { + return new Set([...state].filter((value) => value !== action.fileName)); + } + + return state; }; export default combineReducers({ isInitialized, fileNames }); diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index af10aaa1..29ee3bdc 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -1,7 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + import { AsyncSaga } from '../../test'; import { - FileStorageActionType, fileStorageDidChangeItem, + fileStorageDidFailToReadFile, fileStorageDidInitialize, fileStorageDidReadFile, fileStorageDidWriteFile, @@ -79,7 +82,7 @@ it('should dispatch fail action if file does not exist', async () => { saga.put(fileStorageReadFile(testFileName)); action = await saga.take(); - expect(action).toHaveProperty('type', FileStorageActionType.DidFailToReadFile); + expect(action).toHaveProperty('type', fileStorageDidFailToReadFile.toString()); await saga.end(); }); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 5bc59715..3158c266 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -8,9 +8,6 @@ import { call, fork, put, takeEvery } from 'typed-redux-saga/macro'; import Observable from 'zen-observable'; import { ensureError } from '../utils'; import { - FileStorageActionType, - FileStorageReadFileAction, - FileStorageWriteFileAction, fileStorageDidChangeItem, fileStorageDidFailToInitialize, fileStorageDidFailToReadFile, @@ -19,6 +16,8 @@ import { fileStorageDidReadFile, fileStorageDidRemoveItem, fileStorageDidWriteFile, + fileStorageReadFile, + fileStorageWriteFile, } from './actions'; /** @@ -47,7 +46,7 @@ function* handleFileStorageDidChange(change: LocalForageObservableChange): Gener */ function* handleReadFile( files: LocalForage, - action: FileStorageReadFileAction, + action: ReturnType, ): Generator { try { const value = yield* call(() => files.getItem(action.fileName)); @@ -67,7 +66,10 @@ function* handleReadFile( * @param files The localForage instance. * @param action The action that triggered this saga. */ -function* handleWriteFile(files: LocalForage, action: FileStorageWriteFileAction) { +function* handleWriteFile( + files: LocalForage, + action: ReturnType, +) { try { yield* call(() => files.setItem(action.fileName, action.fileContents)); yield* put(fileStorageDidWriteFile(action.fileName)); @@ -116,8 +118,8 @@ function* initialize(): Generator { // subscribe to events yield* takeEvery(localForageChannel, handleFileStorageDidChange); - yield* takeEvery(FileStorageActionType.ReadFile, handleReadFile, files); - yield* takeEvery(FileStorageActionType.WriteFile, handleWriteFile, files); + yield* takeEvery(fileStorageReadFile, handleReadFile, files); + yield* takeEvery(fileStorageWriteFile, handleWriteFile, files); // migrate from old storage diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index 8839f575..eaebea53 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -1,25 +1,8 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { FirmwareMetadata, FirmwareReaderError } from '@pybricks/firmware'; -import { Action } from 'redux'; -import { assert } from '../utils'; - -/** - * High-level bootloader actions. - */ -export enum FlashFirmwareActionType { - /** Request to flash new firmware to the device. */ - FlashFirmware = 'flashFirmware.action.flashFirmware', - /** Actual modification of the flash memory on the device started. */ - DidStart = 'flashFirmware.action.didStart', - /** Firmware flash progress. */ - DidProgress = 'flashFirmware.action.didProgress', - /** Flashing finished successfully. */ - DidFinish = 'flashFirmware.action.didFinish', - /** Flashing firmware failed. */ - DidFailToFinish = 'flashFirmware.action.didFailToFinish', -} +import { createAction } from '../actions'; export enum MetadataProblem { Missing = 'metadata.missing', @@ -132,122 +115,149 @@ export type FailToFinishReason = | FailToFinishReasonFailedToCompile | FailToFinishReasonUnknown; -/** - * Action that flashes firmware to a hub. - */ -export type FlashFirmwareFlashAction = Action & { - /** The firmware zip file data or `null` to get firmware later. */ - data: ArrayBuffer | null; -}; +// High-level bootloader actions. /** * Creates a new action to flash firmware to a hub. * @param data The firmware zip file data or `null` to get firmware later. */ -export function flashFirmware(data: ArrayBuffer | null): FlashFirmwareFlashAction { - return { type: FlashFirmwareActionType.FlashFirmware, data }; -} - -/** Action that indicates flashing firmware started. */ -export type FlashFirmwareDidStartAction = Action; +export const flashFirmware = createAction((data: ArrayBuffer | null) => ({ + type: 'flashFirmware.action.flashFirmware', + data, +})); /** * Action that indicates flashing firmware started. * @param total The total number of bytes to be flashed. */ -export function didStart(): FlashFirmwareDidStartAction { - return { type: FlashFirmwareActionType.DidStart }; -} - -/** Action that indicates current firmware flashing progress. */ -export type FlashFirmwareDidProgressAction = - Action & { - /** The current progress (0 to 1). */ - value: number; - }; +export const didStart = createAction(() => ({ + type: 'flashFirmware.action.didStart', +})); /** * Action that indicates current firmware flashing progress. * @param value The current progress (0 to 1). */ -export function didProgress(value: number): FlashFirmwareDidProgressAction { - assert(value >= 0 && value <= 1, 'value out of range'); - return { type: FlashFirmwareActionType.DidProgress, value }; -} +export const didProgress = createAction((value: number) => { + // assert(value >= 0 && value <= 1, 'value out of range'); + return { type: 'flashFirmware.action.didProgress', value }; +}); /** Action that indicates that flashing firmware completed successfully. */ -export type FlashFirmwareDidFinishAction = Action; +export const didFinish = createAction(() => ({ + type: 'flashFirmware.action.didFinish', +})); -/** Action that indicates that flashing firmware completed successfully. */ -export function didFinish(): FlashFirmwareDidFinishAction { - return { type: FlashFirmwareActionType.DidFinish }; -} +const didFailToFinishType = 'flashFirmware.action.didFailToFinish'; -/** Action that indicates that flashing failed. */ -export type FlashFirmwareDidFailToFinishAction = - Action & { - reason: FailToFinishReason; - }; +function didFailToFinishCreator(reason: FailToFinishReasonType.FailedToConnect): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonFailedToConnect; +}; -export function didFailToFinish( +function didFailToFinishCreator(reason: FailToFinishReasonType.TimedOut): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonTimedOut; +}; + +function didFailToFinishCreator( reason: FailToFinishReasonType.BleError, err: Error, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonBleError; +}; -export function didFailToFinish( +function didFailToFinishCreator(reason: FailToFinishReasonType.Disconnected): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonDisconnected; +}; + +function didFailToFinishCreator( reason: FailToFinishReasonType.HubError, hubError: HubError, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonHubError; +}; -export function didFailToFinish( +function didFailToFinishCreator(reason: FailToFinishReasonType.NoFirmware): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonNoFirmware; +}; + +function didFailToFinishCreator(reason: FailToFinishReasonType.DeviceMismatch): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonDeviceMismatch; +}; + +function didFailToFinishCreator( reason: FailToFinishReasonType.FailedToFetch, response: Response, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonFailedToFetch; +}; -export function didFailToFinish( +function didFailToFinishCreator( reason: FailToFinishReasonType.ZipError, err: FirmwareReaderError, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonZipError; +}; -export function didFailToFinish( +function didFailToFinishCreator( reason: FailToFinishReasonType.BadMetadata, property: keyof FirmwareMetadata, problem: MetadataProblem, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonBadMetadata; +}; -export function didFailToFinish( +function didFailToFinishCreator(reason: FailToFinishReasonType.FirmwareSize): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonFirmwareSize; +}; + +function didFailToFinishCreator(reason: FailToFinishReasonType.FailedToCompile): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonFailedToCompile; +}; + +function didFailToFinishCreator( reason: FailToFinishReasonType.Unknown, err: Error, -): FlashFirmwareDidFailToFinishAction; +): { + type: typeof didFailToFinishType; + reason: FailToFinishReasonUnknown; +}; -export function didFailToFinish( - reason: Exclude< - FailToFinishReasonType, - | FailToFinishReasonType.BleError - | FailToFinishReasonType.HubError - | FailToFinishReasonType.FailedToFetch - | FailToFinishReasonType.ZipError - | FailToFinishReasonType.BadMetadata - | FailToFinishReasonType.Unknown - >, -): FlashFirmwareDidFailToFinishAction; +function didFailToFinishCreator( + reason: T, + arg1?: string | HubError | Error | Response, + arg2?: MetadataProblem, +): { + type: typeof didFailToFinishType; + reason: FailToFinishReason; +}; -/** - * Action that indicates flashing did not start because of an error. - * @param total The total number of bytes to be flashed. - */ -export function didFailToFinish( +function didFailToFinishCreator( reason: FailToFinishReasonType, arg1?: string | HubError | Error | Response, arg2?: MetadataProblem, -): FlashFirmwareDidFailToFinishAction { +): { + type: typeof didFailToFinishType; + reason: FailToFinishReason; +} { if (reason === FailToFinishReasonType.BleError) { // istanbul ignore if: programmer error give wrong arg if (!(arg1 instanceof Error)) { throw new Error('missing or invalid err'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, err: arg1 }, }; } @@ -258,7 +268,7 @@ export function didFailToFinish( throw new Error('missing or invalid hubError'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, hubError: arg1 }, }; } @@ -269,7 +279,7 @@ export function didFailToFinish( throw new Error('missing or invalid response'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, response: arg1 }, }; } @@ -280,7 +290,7 @@ export function didFailToFinish( throw new Error('missing or invalid err'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, err: arg1 }, }; } @@ -304,7 +314,7 @@ export function didFailToFinish( throw new Error('missing or invalid problem'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, property: arg1, problem: arg2 }, }; } @@ -315,20 +325,16 @@ export function didFailToFinish( throw new Error('missing or invalid err'); } return { - type: FlashFirmwareActionType.DidFailToFinish, + type: didFailToFinishType, reason: { reason, err: arg1 }, }; } - return { type: FlashFirmwareActionType.DidFailToFinish, reason: { reason } }; + return { type: didFailToFinishType, reason: { reason } }; } /** - * Common type for all high-level bootloader actions. + * Action that indicates flashing did not start because of an error. + * @param total The total number of bytes to be flashed. */ -export type FlashFirmwareAction = - | FlashFirmwareFlashAction - | FlashFirmwareDidStartAction - | FlashFirmwareDidProgressAction - | FlashFirmwareDidFinishAction - | FlashFirmwareDidFailToFinishAction; +export const didFailToFinish = createAction(didFailToFinishCreator); diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts index c47b0f04..a910f8e0 100644 --- a/src/firmware/reducers.test.ts +++ b/src/firmware/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { FailToFinishReasonType, didFailToFinish, @@ -14,7 +14,7 @@ import reducers from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "flashing": false, "progress": null, diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts index 831b1e19..97bd6c12 100644 --- a/src/firmware/reducers.ts +++ b/src/firmware/reducers.ts @@ -1,31 +1,31 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { FlashFirmwareActionType } from './actions'; +import { didFailToFinish, didFinish, didProgress, didStart } from './actions'; -const flashing: Reducer = (state = false, action) => { - switch (action.type) { - case FlashFirmwareActionType.DidStart: - return true; - case FlashFirmwareActionType.DidFinish: - case FlashFirmwareActionType.DidFailToFinish: - return false; - default: - return state; +const flashing: Reducer = (state = false, action) => { + if (didStart.matches(action)) { + return true; } + + if (didFinish.matches(action) || didFailToFinish.matches(action)) { + return false; + } + + return state; }; -const progress: Reducer = (state = null, action) => { - switch (action.type) { - case FlashFirmwareActionType.DidStart: - return null; - case FlashFirmwareActionType.DidProgress: - return action.value; - default: - return state; +const progress: Reducer = (state = null, action) => { + if (didStart.matches(action)) { + return null; } + + if (didProgress.matches(action)) { + return action.value; + } + + return state; }; export default combineReducers({ flashing, progress }); diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index c96d8e3d..22923d1f 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { FirmwareReader, @@ -10,6 +10,7 @@ import { import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { AnyAction } from 'redux'; import { SagaGenerator, all, @@ -23,54 +24,43 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; import { - BootloaderChecksumResponseAction, - BootloaderConnectionAction, - BootloaderConnectionActionType, - BootloaderDidFailToRequestAction, - BootloaderDidFailToRequestType, - BootloaderDidRequestAction, - BootloaderDidRequestType, - BootloaderEraseResponseAction, - BootloaderErrorResponseAction, - BootloaderInfoResponseAction, - BootloaderInitResponseAction, - BootloaderProgramResponseAction, - BootloaderResponseAction, - BootloaderResponseActionType, checksumRequest, + checksumResponse, connect, + didConnect, + didDisconnect, + didFailToConnect, + didFailToRequest, + didRequest, disconnect, eraseRequest, eraseResponse, + errorResponse, infoRequest, + infoResponse, initRequest, + initResponse, programRequest, + programResponse, rebootRequest, } from '../lwp3-bootloader/actions'; import { MaxProgramFlashSize, Result } from '../lwp3-bootloader/protocol'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; -import { - MpyActionType, - MpyDidCompileAction, - MpyDidFailToCompileAction, - compile, -} from '../mpy/actions'; +import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; import { defined, ensureError, hex, maybe } from '../utils'; import { fmod, sumComplement32 } from '../utils/math'; import { isAndroid } from '../utils/os'; import { FailToFinishReasonType, - FlashFirmwareActionType, - FlashFirmwareFlashAction, HubError, MetadataProblem, didFailToFinish, didFinish, didProgress, didStart, + flashFirmware, } from './actions'; const firmwareZipMap = new Map([ @@ -93,13 +83,13 @@ function* disconnectAndCancel(): SagaGenerator { yield* cancel(); } -function* waitForDidRequest(id: number): SagaGenerator { +function* waitForDidRequest(id: number): SagaGenerator> { const { requested, failedToRequest } = yield* race({ - requested: take( - (a: Action) => a.type === BootloaderDidRequestType && a.id === id, + requested: take>( + (a: AnyAction) => didRequest.matches(a) && a.id === id, ), - failedToRequest: take( - (a: Action) => a.type === BootloaderDidFailToRequestType && a.id === id, + failedToRequest: take>( + (a: AnyAction) => didFailToRequest.matches(a) && a.id === id, ), }); @@ -121,26 +111,26 @@ function* waitForDidRequest(id: number): SagaGenerator( - type: BootloaderResponseActionType, +function* waitForResponse( + type: string, timeout = 500, ): SagaGenerator { const { response, error, disconnected, timedOut } = yield* race({ response: take(type), - error: take(BootloaderResponseActionType.Error), - disconnected: take(BootloaderConnectionActionType.DidDisconnect), + error: take>(errorResponse), + disconnected: take(didDisconnect), timedOut: delay(timeout), }); if (timedOut) { // istanbul ignore if: this hacks around a hardware/OS issue - if (type === BootloaderResponseActionType.Erase) { + if (type === errorResponse.toString()) { // It has been observed that sometimes this response is not received // or gets stuck in the Bluetooth stack until another request is sent. // So, we ignore the timeout and continue. If there really was a // problem, then the next request should fail anyway. console.warn('Timeout waiting for erase response, continuing anyway.'); - return eraseResponse(Result.OK) as T; + return eraseResponse(Result.OK) as unknown as T; } yield* put(didFailToFinish(FailToFinishReasonType.TimedOut)); yield* disconnectAndCancel(); @@ -219,8 +209,8 @@ function* loadFirmware( yield* put(compile(program, metadata['mpy-cross-options'])); const { mpy, mpyFail } = yield* race({ - mpy: take(MpyActionType.DidCompile), - mpyFail: take(MpyActionType.DidFailToCompile), + mpy: take>(didCompile), + mpyFail: take>(didFailToCompile), }); if (mpyFail) { @@ -280,7 +270,7 @@ function* loadFirmware( * Flashes firmware to a Powered Up device. * @param action The action that triggered this saga. */ -function* flashFirmware(action: FlashFirmwareFlashAction): Generator { +function* handleFlashFirmware(action: ReturnType): Generator { try { let firmware: Uint8Array | undefined = undefined; let deviceId: HubType | undefined = undefined; @@ -308,12 +298,9 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { } yield* put(connect()); - const connectResult = yield* take([ - BootloaderConnectionActionType.DidConnect, - BootloaderConnectionActionType.DidFailToConnect, - ]); + const connectResult = yield* take([didConnect, didFailToConnect]); - if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) { + if (didFailToConnect.matches(connectResult)) { yield* put(didFailToFinish(FailToFinishReasonType.FailedToConnect)); return; } @@ -323,8 +310,8 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { const infoAction = yield* put(infoRequest(nextMessageId())); const { info } = yield* all({ sent: waitForDidRequest(infoAction.id), - info: waitForResponse( - BootloaderResponseActionType.Info, + info: waitForResponse>( + infoResponse.toString(), ), }); @@ -366,8 +353,8 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { ); const { erase } = yield* all({ sent: waitForDidRequest(eraseAction.id), - erase: waitForResponse( - BootloaderResponseActionType.Erase, + erase: waitForResponse>( + eraseResponse.toString(), 5000, ), }); @@ -381,8 +368,8 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { const initAction = yield* put(initRequest(nextMessageId(), firmware.length)); const { init } = yield* all({ sent: waitForDidRequest(initAction.id), - init: waitForResponse( - BootloaderResponseActionType.Init, + init: waitForResponse>( + initResponse.toString(), ), }); if (init.result) { @@ -433,8 +420,8 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { const { response } = yield* all({ sent: waitForDidRequest(checksumAction.id), - response: waitForResponse( - BootloaderResponseActionType.Checksum, + response: waitForResponse>( + checksumResponse.toString(), 5000, ), }); @@ -460,8 +447,8 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { } } - const flash = yield* waitForResponse( - BootloaderResponseActionType.Program, + const flash = yield* waitForResponse>( + programResponse.toString(), 5000, ); @@ -508,5 +495,5 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { } export default function* (): Generator { - yield* takeEvery(FlashFirmwareActionType.FlashFirmware, flashFirmware); + yield* takeEvery(flashFirmware, handleFlashFirmware); } diff --git a/src/hub/actions.ts b/src/hub/actions.ts index 6480cb72..a6ac04ef 100644 --- a/src/hub/actions.ts +++ b/src/hub/actions.ts @@ -1,97 +1,43 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors -import { Action } from 'redux'; - -export enum HubMessageActionType { - /** - * The hub has sent a checksum. - */ - Checksum = 'hub.message.action.runtime.checksum', -} - -export type HubChecksumMessageAction = Action & { - readonly checksum: number; -}; - -export function checksum(checksum: number): HubChecksumMessageAction { - return { - type: HubMessageActionType.Checksum, - checksum, - }; -} +import { createAction } from '../actions'; /** - * Common type for low-level hub message actions. + * Action that indicates the hub has sent a checksum. */ -export type HubMessageAction = HubChecksumMessageAction; +export const checksum = createAction((checksum: number) => ({ + type: 'hub.message.action.runtime.checksum', + checksum, +})); -/** - * High-level hub actions. - */ -export enum HubActionType { - DownloadAndRun = 'hub.action.downloadAndRun', - DidStartDownload = 'hub.action.didStartDownload', - DidProgressDownload = 'hub.action.didProgressDownload', - DidFinishDownload = 'hub.action.didFinishDownload', - DidFailToFinishDownload = 'hub.action.didFailToFinishDownload', - Stop = 'hub.action.stop', - Repl = 'hub.action.repl', -} +// High-level hub actions. -export type HubDownloadAndRunAction = Action; +export const downloadAndRun = createAction(() => ({ + type: 'hub.action.downloadAndRun', +})); -export function downloadAndRun(): HubDownloadAndRunAction { - return { type: HubActionType.DownloadAndRun }; -} +export const didStartDownload = createAction(() => ({ + type: 'hub.action.didStartDownload', +})); -export type HubDidStartDownloadAction = Action; +export const didProgressDownload = createAction((progress: number) => ({ + type: 'hub.action.didProgressDownload', + progress, +})); -export function didStartDownload(): HubDidStartDownloadAction { - return { type: HubActionType.DidStartDownload }; -} +export const didFinishDownload = createAction(() => ({ + type: 'hub.action.didFinishDownload', +})); -export type HubDidProgressDownloadAction = Action & { - progress: number; -}; +export const didFailToFinishDownload = createAction(() => ({ + type: 'hub.action.didFailToFinishDownload', +})); -export function didProgressDownload(progress: number): HubDidProgressDownloadAction { - return { type: HubActionType.DidProgressDownload, progress }; -} +export const stop = createAction(() => ({ + type: 'hub.action.stop', +})); -export type HubDidFinishDownloadAction = Action; - -export function didFinishDownload(): HubDidFinishDownloadAction { - return { type: HubActionType.DidFinishDownload }; -} - -export type HubDidFailToFinishDownloadAction = - Action; - -export function didFailToFinishDownload(): HubDidFailToFinishDownloadAction { - return { type: HubActionType.DidFailToFinishDownload }; -} - -export type HubStopAction = Action; - -export function stop(): HubStopAction { - return { type: HubActionType.Stop }; -} - -export type HubReplAction = Action; - -export function repl(): HubReplAction { - return { type: HubActionType.Repl }; -} - -/** - * Common type for all high-level hub actions. - */ -export type HubAction = - | HubDownloadAndRunAction - | HubDidStartDownloadAction - | HubDidProgressDownloadAction - | HubDidFinishDownloadAction - | HubDidFailToFinishDownloadAction - | HubStopAction - | HubReplAction; +export const repl = createAction(() => ({ + type: 'hub.action.repl', +})); diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 8820ee49..15dc7293 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { didConnect, didDisconnect } from '../ble/actions'; @@ -16,7 +16,7 @@ import reducers, { HubRuntimeState } from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "downloadProgress": null, "runtime": "hub.runtime.disconnected", diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index d0e88036..c4a70ae2 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -1,12 +1,16 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { BlePybricksServiceEventActionType } from '../ble-pybricks-service/actions'; +import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { BleDeviceActionType } from '../ble/actions'; -import { HubActionType } from './actions'; +import { didConnect, didDisconnect } from '../ble/actions'; +import { + didFailToFinishDownload, + didFinishDownload, + didProgressDownload, + didStartDownload, +} from './actions'; /** * Describes the state of the MicroPython runtime on the hub. @@ -38,66 +42,80 @@ export enum HubRuntimeState { Running = 'hub.runtime.running', } -const runtime: Reducer = ( +const runtime: Reducer = ( state = HubRuntimeState.Disconnected, action, ) => { - switch (action.type) { - case BleDeviceActionType.DidConnect: - return HubRuntimeState.Unknown; - case BleDeviceActionType.DidDisconnect: - return HubRuntimeState.Disconnected; - case HubActionType.DidStartDownload: - // disconnected overrides download - if (state === HubRuntimeState.Disconnected) { - return state; - } - return HubRuntimeState.Loading; - case HubActionType.DidFinishDownload: - // disconnected overrides download - if (state === HubRuntimeState.Disconnected) { - return state; - } - return HubRuntimeState.Loaded; - case HubActionType.DidFailToFinishDownload: - // disconnected overrides download - if (state === HubRuntimeState.Disconnected) { - return state; - } - return HubRuntimeState.Idle; - case BlePybricksServiceEventActionType.DidReceiveStatusReport: - // The loading state is determined solely by the IDE, so we can't - // let the hub status interfere with it. - if ( - state === HubRuntimeState.Disconnected || - state === HubRuntimeState.Loading - ) { - return state; - } - - if (action.statusFlags & statusToFlag(Status.UserProgramRunning)) { - return HubRuntimeState.Running; - } - - return HubRuntimeState.Idle; - default: - return state; + if (didConnect.matches(action)) { + return HubRuntimeState.Unknown; } + + if (didDisconnect.matches(action)) { + return HubRuntimeState.Disconnected; + } + + if (didStartDownload.matches(action)) { + // disconnected overrides download + if (state === HubRuntimeState.Disconnected) { + return state; + } + return HubRuntimeState.Loading; + } + + if (didFinishDownload.matches(action)) { + // disconnected overrides download + if (state === HubRuntimeState.Disconnected) { + return state; + } + return HubRuntimeState.Loaded; + } + + if (didFailToFinishDownload.matches(action)) { + // disconnected overrides download + if (state === HubRuntimeState.Disconnected) { + return state; + } + return HubRuntimeState.Idle; + } + + if (didReceiveStatusReport.matches(action)) { + // The loading state is determined solely by the IDE, so we can't + // let the hub status interfere with it. + if ( + state === HubRuntimeState.Disconnected || + state === HubRuntimeState.Loading + ) { + return state; + } + + if (action.statusFlags & statusToFlag(Status.UserProgramRunning)) { + return HubRuntimeState.Running; + } + + return HubRuntimeState.Idle; + } + + return state; }; -const downloadProgress: Reducer = (state = null, action) => { - switch (action.type) { - case HubActionType.DidStartDownload: - return 0; - case HubActionType.DidProgressDownload: - return action.progress; - case HubActionType.DidFinishDownload: - return 1; - case HubActionType.DidFailToFinishDownload: - return null; - default: - return state; +const downloadProgress: Reducer = (state = null, action) => { + if (didStartDownload.matches(action)) { + return 0; } + + if (didProgressDownload.matches(action)) { + return action.progress; + } + + if (didFinishDownload.matches(action)) { + return 1; + } + + if (didFailToFinishDownload.matches(action)) { + return null; + } + + return state; }; export default combineReducers({ runtime, downloadProgress }); diff --git a/src/hub/sagas.test.ts b/src/hub/sagas.test.ts index 1e2b5ff9..9dd738d5 100644 --- a/src/hub/sagas.test.ts +++ b/src/hub/sagas.test.ts @@ -1,25 +1,21 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { mock } from 'jest-mock-extended'; import { monaco } from 'react-monaco-editor'; import { AsyncSaga } from '../../test'; +import { didWrite, write } from '../ble-nordic-uart-service/actions'; import { - BleUartActionType, - BleUartWriteAction, - didWrite, -} from '../ble-nordic-uart-service/actions'; -import { - BlePybricksServiceCommandActionType, - BlePybricksServiceCommandSendStopUserProgram, didSendCommand, + sendStopUserProgramCommand, } from '../ble-pybricks-service/actions'; -import { MpyActionType, didCompile } from '../mpy/actions'; +import { compile, didCompile } from '../mpy/actions'; import { createCountFunc } from '../utils/iter'; import { - HubActionType, - HubDidProgressDownloadAction, checksum, + didFinishDownload, + didProgressDownload, + didStartDownload, downloadAndRun, repl, stop, @@ -41,49 +37,51 @@ describe('downloadAndRun', () => { // first, it tries to compile the program in the current editor const compileAction = await saga.take(); - expect(compileAction.type).toBe(MpyActionType.Compile); + expect(compileAction.type).toBe(compile.toString()); saga.put(didCompile(new Uint8Array(30))); // then it notifies that loading has begun const loadingStatusAction = await saga.take(); - expect(loadingStatusAction.type).toBe(HubActionType.DidStartDownload); + expect(loadingStatusAction.type).toBe(didStartDownload.toString()); // first message is the length const writeAction = await saga.take(); - expect(writeAction.type).toBe(BleUartActionType.Write); - expect((writeAction as BleUartWriteAction).value.length).toBe(4); - saga.put(didWrite((writeAction as BleUartWriteAction).id)); + expect(writeAction.type).toBe(write.toString()); + expect((writeAction as ReturnType).value.length).toBe(4); + saga.put(didWrite((writeAction as ReturnType).id)); saga.put(checksum(30)); // then progress is updated const progressAction = await saga.take(); - expect(progressAction.type).toBe(HubActionType.DidProgressDownload); - expect((progressAction as HubDidProgressDownloadAction).progress).toBe(0); + expect(progressAction.type).toBe(didProgressDownload.toString()); + expect( + (progressAction as ReturnType).progress, + ).toBe(0); // then the first chunk of 20 bytes const writeAction2 = await saga.take(); - expect(writeAction2.type).toBe(BleUartActionType.Write); - expect((writeAction2 as BleUartWriteAction).value.length).toBe(20); - saga.put(didWrite((writeAction2 as BleUartWriteAction).id)); + expect(writeAction2.type).toBe(write.toString()); + expect((writeAction2 as ReturnType).value.length).toBe(20); + saga.put(didWrite((writeAction2 as ReturnType).id)); saga.put(checksum(0)); // then progress is updated const progress2Action = await saga.take(); - expect(progress2Action.type).toBe(HubActionType.DidProgressDownload); - expect((progress2Action as HubDidProgressDownloadAction).progress).toBe( - 20 / 30, - ); + expect(progress2Action.type).toBe(didProgressDownload.toString()); + expect( + (progress2Action as ReturnType).progress, + ).toBe(20 / 30); // then last chunk const writeAction3 = await saga.take(); - expect(writeAction3.type).toBe(BleUartActionType.Write); - expect((writeAction3 as BleUartWriteAction).value.length).toBe(10); - saga.put(didWrite((writeAction3 as BleUartWriteAction).id)); + expect(writeAction3.type).toBe(write.toString()); + expect((writeAction3 as ReturnType).value.length).toBe(10); + saga.put(didWrite((writeAction3 as ReturnType).id)); saga.put(checksum(0)); // Then a status message saying that we are done const loadedStatusAction = await saga.take(); - expect(loadedStatusAction.type).toBe(HubActionType.DidFinishDownload); + expect(loadedStatusAction.type).toBe(didFinishDownload.toString()); await saga.end(); }); @@ -97,7 +95,7 @@ test('repl', async () => { saga.put(repl()); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); + expect(action.type).toBe(write.toString()); await saga.end(); }); @@ -108,13 +106,11 @@ test('stop', async () => { saga.put(stop()); const pybricksServiceAction = await saga.take(); - expect(pybricksServiceAction.type).toBe( - BlePybricksServiceCommandActionType.SendStopUserProgram, - ); + expect(pybricksServiceAction.type).toBe(sendStopUserProgramCommand.toString()); saga.put( didSendCommand( - (pybricksServiceAction as BlePybricksServiceCommandSendStopUserProgram).id, + (pybricksServiceAction as ReturnType).id, ), ); diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index ac855a52..77ce0511 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors +import { AnyAction } from 'redux'; import { SagaGenerator, actionChannel, @@ -12,60 +13,46 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; -import { - BleUartActionType, - BleUartDidFailToWriteAction, - BleUartDidWriteAction, - write, -} from '../ble-nordic-uart-service/actions'; +import { didFailToWrite, didWrite, write } from '../ble-nordic-uart-service/actions'; import { SafeTxCharLength } from '../ble-nordic-uart-service/protocol'; import { - BlePybricksServiceCommandActionType, - BlePybricksServiceCommandDidFailToSendAction, - BlePybricksServiceCommandDidSendAction, + didFailToSendCommand, + didSendCommand, sendStopUserProgramCommand, } from '../ble-pybricks-service/actions'; -import { BleDeviceActionType } from '../ble/actions'; -import { - MpyActionType, - MpyDidCompileAction, - MpyDidFailToCompileAction, - compile, -} from '../mpy/actions'; +import { didConnect } from '../ble/actions'; +import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; import { defined } from '../utils'; import { xor8 } from '../utils/math'; import { - HubActionType, - HubChecksumMessageAction, - HubDownloadAndRunAction, - HubMessageActionType, - HubReplAction, - HubStopAction, - didFailToFinishDownload as didFailToFinishDownload, + checksum, + didFailToFinishDownload, didFinishDownload, didProgressDownload, didStartDownload, + downloadAndRun, + repl, + stop, } from './actions'; const downloadChunkSize = 100; function* waitForWrite(id: number): SagaGenerator<{ - didWrite: BleUartDidWriteAction | undefined; - didFailToWrite: BleUartDidFailToWriteAction | undefined; + didWrite: ReturnType | undefined; + didFailToWrite: ReturnType | undefined; }> { return yield* race({ - didWrite: take( - (a: Action) => a.type === BleUartActionType.DidWrite && a.id === id, + didWrite: take>( + (a: AnyAction) => didWrite.matches(a) && a.id === id, ), - didFailToWrite: take( - (a: Action) => a.type === BleUartActionType.DidFailToWrite && a.id === id, + didFailToWrite: take>( + (a: AnyAction) => didFailToWrite.matches(a) && a.id === id, ), }); } -function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { +function* handleDownloadAndRun(): Generator { const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor @@ -77,8 +64,8 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { const script = editor.getValue(); yield* put(compile(script, ['-mno-unicode'])); const { mpy, mpyFail } = yield* race({ - mpy: take(MpyActionType.DidCompile), - mpyFail: take(MpyActionType.DidFailToCompile), + mpy: take>(didCompile), + mpyFail: take>(didFailToCompile), }); if (mpyFail) { @@ -95,9 +82,7 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { console.log(`Downloading ${mpy.data.byteLength} bytes`); } - const checksumChannel = yield* actionChannel( - HubMessageActionType.Checksum, - ); + const checksumChannel = yield* actionChannel>(checksum); const nextMessageId = yield* getContext<() => number>('nextMessageId'); @@ -183,26 +168,23 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { // SPACE, SPACE, SPACE, SPACE const startReplCommand = new Uint8Array([0x20, 0x20, 0x20, 0x20]); -function* startRepl(_action: HubReplAction): Generator { +function* handleRepl(): Generator { const nextMessageId = yield* getContext<() => number>('nextMessageId'); yield* put(write(nextMessageId(), startReplCommand)); } -function* stop(_action: HubStopAction): Generator { +function* handleStop(): Generator { const nextMessageId = yield* getContext<() => number>('nextMessageId'); 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, + sent: take>( + (a: AnyAction) => didSendCommand.matches(a) && a.id === id, ), - failedToSend: take( - (a: Action) => - a.type === BlePybricksServiceCommandActionType.DidFailToSend && - a.id === id, + failedToSend: take>( + (a: AnyAction) => didFailToSendCommand.matches(a) && a.id === id, ), }); if (failedToSend) { @@ -213,9 +195,9 @@ function* stop(_action: HubStopAction): Generator { } export default function* (): Generator { - yield* takeEvery(HubActionType.DownloadAndRun, downloadAndRun); - yield* takeEvery(HubActionType.Repl, startRepl); - yield* takeEvery(HubActionType.Stop, stop); + yield* takeEvery(downloadAndRun, handleDownloadAndRun); + yield* takeEvery(repl, handleRepl); + yield* takeEvery(stop, handleStop); // calling stop right after connecting should get the hub into a known state - yield* takeEvery(BleDeviceActionType.DidConnect, stop); + yield* takeEvery(didConnect, handleStop); } diff --git a/src/licenses/actions.ts b/src/licenses/actions.ts index 58b99521..5674e586 100644 --- a/src/licenses/actions.ts +++ b/src/licenses/actions.ts @@ -1,49 +1,24 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { Action } from 'redux'; +import { createAction } from '../actions'; import { LicenseInfo, LicenseList } from './reducers'; -export enum LicenseActionType { - FetchList = 'license.action.fetchList', - DidFetchList = 'license.action.didFetchList', - DidFailToFetchList = 'license.action.didFailToFetchList', - Select = 'license.action.select', -} +export const fetchList = createAction(() => ({ + type: 'license.action.fetchList', +})); -export type LicenseFetchListAction = Action; +export const didFetchList = createAction((list: LicenseList) => ({ + type: 'license.action.didFetchList', + list, +})); -export function fetchList(): LicenseFetchListAction { - return { type: LicenseActionType.FetchList }; -} +export const didFailToFetchList = createAction((reason: Response) => ({ + type: 'license.action.didFailToFetchList', + reason, +})); -export type LicenseDidFetchListAction = Action & { - list: LicenseList; -}; - -export function didFetchList(list: LicenseList): LicenseDidFetchListAction { - return { type: LicenseActionType.DidFetchList, list }; -} - -export type LicenseDidFailToFetchListAction = - Action & { - reason: Response; - }; - -export function didFailToFetchList(reason: Response): LicenseDidFailToFetchListAction { - return { type: LicenseActionType.DidFailToFetchList, reason }; -} - -export type LicenseSelectAction = Action & { - info: LicenseInfo; -}; - -export function select(info: LicenseInfo): LicenseSelectAction { - return { type: LicenseActionType.Select, info }; -} - -export type LicenseAction = - | LicenseFetchListAction - | LicenseDidFetchListAction - | LicenseDidFailToFetchListAction - | LicenseSelectAction; +export const select = createAction((info: LicenseInfo) => ({ + type: 'license.action.select', + info, +})); diff --git a/src/licenses/reducers.test.ts b/src/licenses/reducers.test.ts index 1fbdfdae..dbbb9768 100644 --- a/src/licenses/reducers.test.ts +++ b/src/licenses/reducers.test.ts @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { didFetchList, select } from './actions'; import reducers, { LicenseInfo, LicenseList } from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "list": null, "selected": null, diff --git a/src/licenses/reducers.ts b/src/licenses/reducers.ts index 46501a8d..72489641 100644 --- a/src/licenses/reducers.ts +++ b/src/licenses/reducers.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { LicenseActionType } from './actions'; +import { didFetchList, select } from './actions'; export interface LicenseInfo { readonly name: string; @@ -15,22 +14,20 @@ export interface LicenseInfo { export type LicenseList = LicenseInfo[]; -const list: Reducer = (state = null, action) => { - switch (action.type) { - case LicenseActionType.DidFetchList: - return action.list; - default: - return state; +const list: Reducer = (state = null, action) => { + if (didFetchList.matches(action)) { + return action.list; } + + return state; }; -const selected: Reducer = (state = null, action) => { - switch (action.type) { - case LicenseActionType.Select: - return action.info; - default: - return state; +const selected: Reducer = (state = null, action) => { + if (select.matches(action)) { + return action.info; } + + return state; }; export default combineReducers({ list, selected }); diff --git a/src/licenses/sagas.ts b/src/licenses/sagas.ts index aaef64aa..7dff3cbc 100644 --- a/src/licenses/sagas.ts +++ b/src/licenses/sagas.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { call, put, select, takeEvery } from 'typed-redux-saga/macro'; import { RootState } from '../reducers'; -import { LicenseActionType, didFailToFetchList, didFetchList } from './actions'; +import { didFailToFetchList, didFetchList, fetchList } from './actions'; function* fetchLicenses(): Generator { const licenses = yield* select((s: RootState) => s.licenses.list); @@ -24,5 +24,5 @@ function* fetchLicenses(): Generator { } export default function* (): Generator { - yield* takeEvery(LicenseActionType.FetchList, fetchLicenses); + yield* takeEvery(fetchList, fetchLicenses); } diff --git a/src/lwp3-bootloader/actions.ts b/src/lwp3-bootloader/actions.ts index 9e1bbb01..b99d787f 100644 --- a/src/lwp3-bootloader/actions.ts +++ b/src/lwp3-bootloader/actions.ts @@ -1,79 +1,23 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { Action } from 'redux'; +import { createAction } from '../actions'; import { Command, HubType, ProtectionLevel, Result } from './protocol'; /** - * Bootloader BLE connection actions. + * Initiate a connection. */ -export enum BootloaderConnectionActionType { - /** - * Initiate a connection. - */ - Connect = 'bootloader.action.connection.connect', - /** - * The connection has been made. - */ - DidConnect = 'bootloader.action.connection.did.connect', - /** - * The connection was not successful. - */ - DidFailToConnect = 'bootloader.action.connection.did.connect.fail', - /** - * There was a connection error. - */ - DidError = 'bootloader.action.connection.did.error', - /** - * Send a message using the connection. - */ - Send = 'bootloader.action.connection.send', - /** - * Finished sending a message. - */ - DidSend = 'bootloader.action.connection.did.send', - /** - * Sending a message failed with error. - */ - DidFailToSend = 'bootloader.action.connection.did.failToSend', - /** - * The connection received a message. - */ - DidReceive = 'bootloader.action.connection.did.receive', - /** - * Initiate disconnection/ - */ - Disconnect = 'bootloader.action.connection.disconnect', - /** - * The connection has been closed. - */ - DidDisconnect = 'bootloader.action.connection.did.disconnect', - /** - * Disconnecting failed. - */ - DidFailToDisconnect = 'bootloader.action.connection.did.failToDisconnect', -} +export const connect = createAction(() => ({ + type: 'bootloader.action.connection.connect', +})); -export type BootloaderConnectionConnectAction = - Action; - -export function connect(): BootloaderConnectionConnectAction { - return { type: BootloaderConnectionActionType.Connect }; -} - -export type BootloaderConnectionDidConnectAction = - Action; - -export function didConnect(): BootloaderConnectionDidConnectAction { - return { type: BootloaderConnectionActionType.DidConnect }; -} - -export type BootloaderConnectionDisconnectAction = - Action; - -export function disconnect(): BootloaderConnectionDisconnectAction { - return { type: BootloaderConnectionActionType.Disconnect }; -} +/** + * The connection has been made. + */ +export const didConnect = createAction(() => ({ + type: 'bootloader.action.connection.didConnect', +})); /** * Possible reasons a device could fail to connect. @@ -91,476 +35,267 @@ export enum BootloaderConnectionFailureReason { Unknown = 'unknown', } -type Reason = { - reason: T; +const didFailToConnectType = 'bootloader.action.connection.didFailToConnect'; + +function didFailToConnectCreator( + reason: BootloaderConnectionFailureReason.NoWebBluetooth, +): Action & { + reason: BootloaderConnectionFailureReason.NoWebBluetooth; }; -export type BootloaderConnectionFailToConnectNoWebBluetoothReason = - Reason; +function didFailToConnectCreator( + reason: BootloaderConnectionFailureReason.NoBluetooth, +): Action & { + reason: BootloaderConnectionFailureReason.NoBluetooth; +}; -export type BootloaderConnectionFailToConnectNoBluetoothReason = - Reason; +function didFailToConnectCreator( + reason: BootloaderConnectionFailureReason.GattServiceNotFound, +): Action & { + reason: BootloaderConnectionFailureReason.GattServiceNotFound; +}; -export type BootloaderConnectionFailToConnectGattServiceNotFoundReason = - Reason; +function didFailToConnectCreator( + reason: BootloaderConnectionFailureReason.Canceled, +): Action & { + reason: BootloaderConnectionFailureReason.Canceled; +}; -export type BootloaderConnectionFailToConnectCanceledReason = - Reason; - -export type BootloaderConnectionFailToConnectUnknownReason = - Reason & { - err: Error; - }; - -export type BootloaderConnectionDidFailToConnectReason = - | BootloaderConnectionFailToConnectNoWebBluetoothReason - | BootloaderConnectionFailToConnectNoBluetoothReason - | BootloaderConnectionFailToConnectGattServiceNotFoundReason - | BootloaderConnectionFailToConnectCanceledReason - | BootloaderConnectionFailToConnectUnknownReason; - -export type BootloaderConnectionDidFailToConnectAction = - Action & - BootloaderConnectionDidFailToConnectReason; - -export function didFailToConnect( - reason: Exclude< - BootloaderConnectionFailureReason, - BootloaderConnectionFailureReason.Unknown - >, -): BootloaderConnectionDidFailToConnectAction; - -export function didFailToConnect( +function didFailToConnectCreator( reason: BootloaderConnectionFailureReason.Unknown, err: Error, -): BootloaderConnectionDidFailToConnectAction; +): Action & { + reason: BootloaderConnectionFailureReason.Unknown; + err: Error; +}; -export function didFailToConnect( +function didFailToConnectCreator( + reason: T, + arg1?: Error, +): Action & { + reason: T; + err: T extends BootloaderConnectionFailureReason.Unknown ? Error : never; +}; + +function didFailToConnectCreator( reason: BootloaderConnectionFailureReason, arg1?: Error, -): BootloaderConnectionDidFailToConnectAction { +): Action & { + reason: BootloaderConnectionFailureReason; + err?: Error; +} { if (reason === BootloaderConnectionFailureReason.Unknown) { - return { - type: BootloaderConnectionActionType.DidFailToConnect, + return { + type: didFailToConnectType, reason, err: arg1, }; } - return { type: BootloaderConnectionActionType.DidFailToConnect, reason }; -} -export type BootloaderConnectionDidErrorAction = - Action & { - err: Error; - }; - -export function didError(err: Error): BootloaderConnectionDidErrorAction { - return { type: BootloaderConnectionActionType.DidError, err }; -} - -export type BootloaderConnectionSendAction = - Action & { - readonly data: ArrayBuffer; - readonly withResponse: boolean; - }; - -export function send( - data: ArrayBuffer, - withResponse = true, -): BootloaderConnectionSendAction { - return { type: BootloaderConnectionActionType.Send, data, withResponse }; -} - -export type BootloaderConnectionDidSendAction = - Action; - -export function didSend(): BootloaderConnectionDidSendAction { - return { type: BootloaderConnectionActionType.DidSend }; -} - -export type BootloaderConnectionDidFailToSendAction = - Action & { - err: Error; - }; - -export function didFailToSend(err: Error): BootloaderConnectionDidFailToSendAction { - return { type: BootloaderConnectionActionType.DidFailToSend, err }; -} - -export type BootloaderConnectionDidReceiveAction = - Action & { - data: DataView; - }; - -export function didReceive(data: DataView): BootloaderConnectionDidReceiveAction { - return { type: BootloaderConnectionActionType.DidReceive, data }; -} - -export type BootloaderConnectionDidDisconnectAction = - Action; - -export function didDisconnect(): BootloaderConnectionDidDisconnectAction { - return { type: BootloaderConnectionActionType.DidDisconnect }; -} - -export type BootloaderConnectionDidFailToDisconnectAction = - Action; - -export function didFailToDisconnect(): BootloaderConnectionDidFailToDisconnectAction { - return { type: BootloaderConnectionActionType.DidFailToDisconnect }; + return { type: didFailToConnectType, reason }; } /** - * Common type for all bootloader connection actions. + * The connection was not successful. */ -export type BootloaderConnectionAction = - | BootloaderConnectionConnectAction - | BootloaderConnectionDidConnectAction - | BootloaderConnectionDidFailToConnectAction - | BootloaderConnectionDidErrorAction - | BootloaderConnectionSendAction - | BootloaderConnectionDidSendAction - | BootloaderConnectionDidFailToSendAction - | BootloaderConnectionDidReceiveAction - | BootloaderConnectionDisconnectAction - | BootloaderConnectionDidDisconnectAction - | BootloaderConnectionDidFailToDisconnectAction; +export const didFailToConnect = createAction(didFailToConnectCreator); /** - * Bootloader request actions for sending commands over the connection. + * There was a connection error. */ -export enum BootloaderRequestActionType { - Erase = 'bootloader.action.request.erase', - Program = 'bootloader.action.request.program', - Reboot = 'bootloader.action.request.reboot', - Init = 'bootloader.action.request.init', - Info = 'bootloader.action.request.info', - Checksum = 'bootloader.action.request.checksum', - State = 'bootloader.action.request.state', - Disconnect = 'bootloader.action.request.disconnect', -} - -type BaseBootloaderRequestAction = Action & { - /** - * Unique identifier for this action. - */ - id: number; -}; +export const didError = createAction((err: Error) => ({ + type: 'bootloader.action.connection.didError', + err, +})); /** - * Action that requests to erase the flash memory. + * Send a message using the connection. */ -export type BootloaderEraseRequestAction = - BaseBootloaderRequestAction & { - /* City hub requires special handling due to buggy bootloader */ - isCityHub: boolean; - }; +export const send = createAction((data: ArrayBuffer, withResponse = true) => ({ + type: 'bootloader.action.connection.send', + data, + withResponse, +})); + +/** + * Finished sending a message. + */ +export const didSend = createAction(() => ({ + type: 'bootloader.action.connection.didSend', +})); + +/** + * Sending a message failed with error. + */ +export const didFailToSend = createAction((err: Error) => ({ + type: 'bootloader.action.connection.didFailToSend', + err, +})); + +/** + * The connection received a message. + */ +export const didReceive = createAction((data: DataView) => ({ + type: 'bootloader.action.connection.didReceive', + data, +})); + +/** + * Initiate disconnection. + */ +export const disconnect = createAction(() => ({ + type: 'bootloader.action.connection.disconnect', +})); + +/** + * The connection has been closed. + */ +export const didDisconnect = createAction(() => ({ + type: 'bootloader.action.connection.didDisconnect', +})); + +/** + * Disconnecting failed. + */ +export const didFailToDisconnect = createAction(() => ({ + type: 'bootloader.action.connection.didFailToDisconnect', +})); + +// LWP3 bootloader request message actions /** * Creates a request to erase the flash memory. */ -export function eraseRequest( - id: number, - isCityHub: boolean, -): BootloaderEraseRequestAction { - return { type: BootloaderRequestActionType.Erase, id, isCityHub }; -} - -/** - * Action that requests to program the flash memory. - */ -export type BootloaderProgramRequestAction = - BaseBootloaderRequestAction & { - address: number; - payload: ArrayBuffer; - }; +export const eraseRequest = createAction((id: number, isCityHub: boolean) => ({ + type: 'bootloader.action.request.eraseRequest', + id, + isCityHub, +})); /** * Creates a request to program the flash memory. * @param address The starting address in the flash memory. * @param payload The bytes to write (max 14 bytes!) */ -export function programRequest( - id: number, - address: number, - payload: ArrayBuffer, -): BootloaderProgramRequestAction { - return { - type: BootloaderRequestActionType.Program, +export const programRequest = createAction( + (id: number, address: number, payload: ArrayBuffer) => ({ + type: 'bootloader.action.request.programRequest', id, address, payload, - }; -} - -/** - * Action that requests to reboot the hub. - */ -export type BootloaderRebootRequestAction = - BaseBootloaderRequestAction; + }), +); /** * Creates a request to reboot the hub. */ -export function rebootRequest(id: number): BootloaderRebootRequestAction { - return { type: BootloaderRequestActionType.Reboot, id }; -} - -/** - * Action that requests to initialize the firmware flashing process. - */ -export type BootloaderInitRequestAction = - BaseBootloaderRequestAction & { - firmwareSize: number; - }; +export const rebootRequest = createAction((id: number) => ({ + type: 'bootloader.action.request.rebootRequest', + id, +})); /** * Creates a request to initialize the firmware flashing process. * @param firmwareSize The size of the firmware to written to flash memory. */ -export function initRequest( - id: number, - firmwareSize: number, -): BootloaderInitRequestAction { - return { - type: BootloaderRequestActionType.Init, - id, - firmwareSize, - }; -} - -/** - * Action that requests information about the hub. - */ -export type BootloaderInfoRequestAction = - BaseBootloaderRequestAction; +export const initRequest = createAction((id: number, firmwareSize: number) => ({ + type: 'bootloader.action.request.initRequest', + id, + firmwareSize, +})); /** * Creates a request to get information about the hub. */ -export function infoRequest(id: number): BootloaderInfoRequestAction { - return { type: BootloaderRequestActionType.Info, id }; -} - -/** - * Action to get the checksum of the bytes that have been written to flash - * so far. - */ -export type BootloaderChecksumRequestAction = - BaseBootloaderRequestAction; +export const infoRequest = createAction((id: number) => ({ + type: 'bootloader.action.request.infoRequest', + id, +})); /** * Creates a request to get the checksum of the bytes that have been written * to flash so far. */ -export function checksumRequest(id: number): BootloaderChecksumRequestAction { - return { type: BootloaderRequestActionType.Checksum, id }; -} - -/** - * Action that requests the bootloader flash memory protection state. - */ -export type BootloaderStateRequestAction = - BaseBootloaderRequestAction; +export const checksumRequest = createAction((id: number) => ({ + type: 'bootloader.action.request.checksumRequest', + id, +})); /** * Creates a request to get the bootloader flash memory protection state. */ -export function stateRequest(id: number): BootloaderStateRequestAction { - return { type: BootloaderRequestActionType.State, id }; -} - -/** - * Action that requests to disconnect the hub. - */ -export type BootloaderDisconnectRequestAction = - BaseBootloaderRequestAction; +export const stateRequest = createAction((id: number) => ({ + type: 'bootloader.action.request.stateRequest', + id, +})); /** * Creates a request to disconnect the hub. */ -export function disconnectRequest(id: number): BootloaderDisconnectRequestAction { - return { type: BootloaderRequestActionType.Disconnect, id }; -} - -/** - * Common type for all bootloader requests. - */ -export type BootloaderRequestAction = - | BootloaderEraseRequestAction - | BootloaderProgramRequestAction - | BootloaderRebootRequestAction - | BootloaderInitRequestAction - | BootloaderInfoRequestAction - | BootloaderChecksumRequestAction - | BootloaderStateRequestAction - | BootloaderDisconnectRequestAction; - -/** - * Action type for bootloader did request action. - */ -export type BootloaderDidRequestType = 'bootloader.action.did.request'; - -/** - * Action type for bootloader did request action. - */ -export const BootloaderDidRequestType = 'bootloader.action.did.request'; - -/** - * Action that indicates a request was sent. - */ -export type BootloaderDidRequestAction = Action & { - /** - * The unique identifier of the action. - */ - id: number; -}; +export const disconnectRequest = createAction((id: number) => ({ + type: 'bootloader.action.request.disconnectRequest', + id, +})); /** * Creates an action that indicates a request was sent. * @param id The unique identifier of the action. */ -export function didRequest(id: number): BootloaderDidRequestAction { - return { type: BootloaderDidRequestType, id }; -} - -/** - * Action type for bootloader did fail to request action. - */ -export type BootloaderDidFailToRequestType = 'bootloader.action.did.failToRequest'; - -/** - * Action type for bootloader did fail to request action. - */ -export const BootloaderDidFailToRequestType = 'bootloader.action.did.failToRequest'; - -/** - * Action that indicates a request failed to send. - */ -export type BootloaderDidFailToRequestAction = - Action & { - /** - * The unique identifier of the action. - */ - id: number; - /** - * The error. - */ - err: Error; - }; +export const didRequest = createAction((id: number) => ({ + type: 'bootloader.action.didRequest', + id, +})); /** * Creates an action that indicates a request failed to send. * @param id The unique identifier of the action. * @param err The error message. */ -export function didFailToRequest( - id: number, - err: Error, -): BootloaderDidFailToRequestAction { - return { type: BootloaderDidFailToRequestType, id, err }; -} +export const didFailToRequest = createAction((id: number, err: Error) => ({ + type: 'bootloader.action.didFailToRequest', + id, + err, +})); -/** - * Bootloader response actions for receiving responses from the connection. - */ -export enum BootloaderResponseActionType { - Erase = 'bootloader.action.response.erase', - Program = 'bootloader.action.response.program', - Init = 'bootloader.action.response.init', - Info = 'bootloader.action.response.info', - Checksum = 'bootloader.action.response.checksum', - State = 'bootloader.action.response.state', - Error = 'bootloader.action.response.error', -} +// Bootloader response actions for receiving responses from the connection. -export type BootloaderEraseResponseAction = - Action & { - result: Result; - }; +export const eraseResponse = createAction((result: Result) => ({ + type: 'bootloader.action.response.eraseResponse', + result, +})); -export function eraseResponse(result: Result): BootloaderEraseResponseAction { - return { type: BootloaderResponseActionType.Erase, result }; -} +export const programResponse = createAction((checksum: number, count: number) => ({ + type: 'bootloader.action.response.programResponse', + checksum, + count, +})); -export type BootloaderProgramResponseAction = - Action & { - checksum: number; - count: number; - }; +export const initResponse = createAction((result: Result) => ({ + type: 'bootloader.action.response.initResponse', + result, +})); -export function programResponse( - checksum: number, - count: number, -): BootloaderProgramResponseAction { - return { type: BootloaderResponseActionType.Program, checksum, count }; -} - -export type BootloaderInitResponseAction = Action & { - result: Result; -}; - -export function initResponse(result: Result): BootloaderInitResponseAction { - return { type: BootloaderResponseActionType.Init, result }; -} - -export type BootloaderInfoResponseAction = Action & { - version: number; - startAddress: number; - endAddress: number; - hubType: HubType; -}; - -export function infoResponse( - version: number, - startAddress: number, - endAddress: number, - hubType: HubType, -): BootloaderInfoResponseAction { - return { - type: BootloaderResponseActionType.Info, +export const infoResponse = createAction( + (version: number, startAddress: number, endAddress: number, hubType: HubType) => ({ + type: 'bootloader.action.response.infoResponse', version, startAddress, endAddress, hubType, - }; -} + }), +); -export type BootloaderChecksumResponseAction = - Action & { - checksum: number; - }; +export const checksumResponse = createAction((checksum: number) => ({ + type: 'bootloader.action.response.checksumResponse', + checksum, +})); -export function checksumResponse(checksum: number): BootloaderChecksumResponseAction { - return { type: BootloaderResponseActionType.Checksum, checksum }; -} +export const stateResponse = createAction((level: ProtectionLevel) => ({ + type: 'bootloader.action.response.stateResponse', + level, +})); -export type BootloaderStateResponseAction = - Action & { - level: ProtectionLevel; - }; - -export function stateResponse(level: ProtectionLevel): BootloaderStateResponseAction { - return { type: BootloaderResponseActionType.State, level }; -} - -export type BootloaderErrorResponseAction = - Action & { - command: Command; - }; - -export function errorResponse(command: Command): BootloaderErrorResponseAction { - return { type: BootloaderResponseActionType.Error, command }; -} - -/** - * Common type for all bootloader response actions. - */ -export type BootloaderResponseAction = - | BootloaderEraseResponseAction - | BootloaderProgramResponseAction - | BootloaderInitResponseAction - | BootloaderInfoResponseAction - | BootloaderChecksumResponseAction - | BootloaderStateResponseAction - | BootloaderErrorResponseAction; +export const errorResponse = createAction((command: Command) => ({ + type: 'bootloader.action.response.errorResponse', + command, +})); diff --git a/src/lwp3-bootloader/reducers.test.ts b/src/lwp3-bootloader/reducers.test.ts index 55801959..a04dcde0 100644 --- a/src/lwp3-bootloader/reducers.test.ts +++ b/src/lwp3-bootloader/reducers.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { BootloaderConnectionFailureReason, connect, @@ -18,7 +18,7 @@ import reducers, { BootloaderConnectionState } from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "connection": "bootloader.connection.disconnected", } diff --git a/src/lwp3-bootloader/reducers.ts b/src/lwp3-bootloader/reducers.ts index a7aee859..770be010 100644 --- a/src/lwp3-bootloader/reducers.ts +++ b/src/lwp3-bootloader/reducers.ts @@ -1,9 +1,17 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { BootloaderConnectionActionType, BootloaderRequestActionType } from './actions'; +import { + connect, + didConnect, + didDisconnect, + didFailToConnect, + didFailToDisconnect, + disconnect, + disconnectRequest, + rebootRequest, +} from './actions'; /** * Describes the state of the bootloader connection. @@ -27,26 +35,31 @@ export enum BootloaderConnectionState { Disconnecting = 'bootloader.connection.disconnecting', } -const connection: Reducer = ( +const connection: Reducer = ( state = BootloaderConnectionState.Disconnected, action, ) => { - switch (action.type) { - case BootloaderConnectionActionType.Connect: - return BootloaderConnectionState.Connecting; - case BootloaderConnectionActionType.DidConnect: - case BootloaderConnectionActionType.DidFailToDisconnect: - return BootloaderConnectionState.Connected; - case BootloaderConnectionActionType.Disconnect: - case BootloaderRequestActionType.Reboot: - case BootloaderRequestActionType.Disconnect: - return BootloaderConnectionState.Disconnecting; - case BootloaderConnectionActionType.DidDisconnect: - case BootloaderConnectionActionType.DidFailToConnect: - return BootloaderConnectionState.Disconnected; - default: - return state; + if (connect.matches(action)) { + return BootloaderConnectionState.Connecting; } + + if (didConnect.matches(action) || didFailToDisconnect.matches(action)) { + return BootloaderConnectionState.Connected; + } + + if ( + disconnect.matches(action) || + rebootRequest.matches(action) || + disconnectRequest.matches(action) + ) { + return BootloaderConnectionState.Disconnecting; + } + + if (didDisconnect.matches(action) || didFailToConnect.matches(action)) { + return BootloaderConnectionState.Disconnected; + } + + return state; }; export default combineReducers({ connection }); diff --git a/src/lwp3-bootloader/sagas-ble.ts b/src/lwp3-bootloader/sagas-ble.ts index 9b278f93..8b384736 100644 --- a/src/lwp3-bootloader/sagas-ble.ts +++ b/src/lwp3-bootloader/sagas-ble.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service. @@ -7,16 +7,16 @@ import { END, eventChannel } from 'redux-saga'; import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'typed-redux-saga/macro'; import { ensureError } from '../utils'; import { - BootloaderConnectionAction, - BootloaderConnectionActionType, - BootloaderConnectionSendAction, BootloaderConnectionFailureReason as Reason, + connect, didConnect, didDisconnect, didFailToConnect, didFailToSend, didReceive, didSend, + disconnect, + send, } from './actions'; import { CharacteristicUUID, ServiceUUID } from './protocol'; @@ -26,7 +26,7 @@ function* handleNotify(data: DataView): Generator { function* write( characteristic: BluetoothRemoteGATTCharacteristic, - action: BootloaderConnectionSendAction, + action: ReturnType, ): Generator { try { if (action.withResponse) { @@ -40,7 +40,7 @@ function* write( } } -function* connect(_action: BootloaderConnectionAction): Generator { +function* handleConnect(): Generator { if (navigator.bluetooth === undefined) { yield* put(didFailToConnect(Reason.NoWebBluetooth)); return; @@ -155,16 +155,13 @@ function* connect(_action: BootloaderConnectionAction): Generator { // Spawning write so that it can't be canceled. This is important because // other sagas always expect it to complete with success action or error // action. - function* spawnWrite(action: BootloaderConnectionSendAction): Generator { + function* spawnWrite(action: ReturnType): Generator { yield* spawn(write, characteristic, action); } yield* takeEvery(notificationChannel, handleNotify); - yield* takeEvery(BootloaderConnectionActionType.Send, spawnWrite); - yield* takeEvery( - BootloaderConnectionActionType.Disconnect, - server.disconnect.bind(server), - ); + yield* takeEvery(send, spawnWrite); + yield* takeEvery(disconnect, server.disconnect.bind(server)); yield* put(didConnect()); @@ -178,5 +175,5 @@ function* connect(_action: BootloaderConnectionAction): Generator { } export default function* (): Generator { - yield* takeEvery(BootloaderConnectionActionType.Connect, connect); + yield* takeEvery(connect, handleConnect); } diff --git a/src/lwp3-bootloader/sagas.test.ts b/src/lwp3-bootloader/sagas.test.ts index 4468888d..ef04807e 100644 --- a/src/lwp3-bootloader/sagas.test.ts +++ b/src/lwp3-bootloader/sagas.test.ts @@ -1,10 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { AsyncSaga } from '../../test'; import { createCountFunc } from '../utils/iter'; import { - BootloaderRequestActionType, checksumRequest, checksumResponse, didError, @@ -116,10 +115,10 @@ describe('message encoder', () => { ], ])('encode %s request', async (_n, request, expected) => { const messageTypesThatShouldBeCalledWithoutResponse = [ - BootloaderRequestActionType.Erase, - BootloaderRequestActionType.Program, - BootloaderRequestActionType.Reboot, - BootloaderRequestActionType.Disconnect, + eraseRequest.toString(), + programRequest.toString(), + rebootRequest.toString(), + disconnectRequest.toString(), ]; const saga = new AsyncSaga(bootloader); saga.put(request); diff --git a/src/lwp3-bootloader/sagas.ts b/src/lwp3-bootloader/sagas.ts index 4e7267de..161b33c3 100644 --- a/src/lwp3-bootloader/sagas.ts +++ b/src/lwp3-bootloader/sagas.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors // // Handles LEGO Wireless Protocol v3 Bootloader protocol. +import { AnyAction } from 'redux'; import { actionChannel, fork, @@ -11,26 +12,30 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; import { ensureError, hex } from '../utils'; import { isWindows } from '../utils/os'; import { - BootloaderConnectionActionType, - BootloaderConnectionDidFailToSendAction, - BootloaderConnectionDidReceiveAction, - BootloaderConnectionDidSendAction, - BootloaderRequestAction, - BootloaderRequestActionType, + checksumRequest, checksumResponse, didError, didFailToRequest, + didFailToSend, + didReceive, didRequest, + didSend, + disconnectRequest, + eraseRequest, eraseResponse, errorResponse, + infoRequest, infoResponse, + initRequest, initResponse, + programRequest, programResponse, + rebootRequest, send, + stateRequest, stateResponse, } from './actions'; import { @@ -62,10 +67,10 @@ import { function* encodeRequest(): Generator { // Using a while loop to serialize sending data to avoid "busy" errors. - const chan = yield* actionChannel((a: Action) => - Object.values(BootloaderRequestActionType).includes( - a.type as BootloaderRequestActionType, - ), + const chan = yield* actionChannel( + (a: AnyAction) => + typeof a.type === 'string' && + a.type.startsWith('bootloader.action.request.'), ); while (true) { @@ -78,54 +83,41 @@ function* encodeRequest(): Generator { // reasons (and also the city hub will disconnect if write with response // is used on this command). - switch (action.type) { - case BootloaderRequestActionType.Erase: - yield* put( - send( - createEraseFlashRequest(), - /* withResponse */ action.isCityHub && !isWindows(), - ), - ); - break; - case BootloaderRequestActionType.Program: - yield* put( - send( - createProgramFlashRequest(action.address, action.payload), - /* withResponse */ false, - ), - ); - break; - case BootloaderRequestActionType.Reboot: - yield* put(send(createStartAppRequest(), /* withResponse */ false)); - break; - case BootloaderRequestActionType.Init: - yield* put(send(createInitLoaderRequest(action.firmwareSize))); - break; - case BootloaderRequestActionType.Info: - yield* put(send(createGetInfoRequest())); - break; - case BootloaderRequestActionType.Checksum: - yield* put(send(createGetChecksumRequest())); - break; - case BootloaderRequestActionType.State: - yield* put(send(createGetFlashStateRequest())); - break; - case BootloaderRequestActionType.Disconnect: - yield* put(send(createDisconnectRequest(), /* withResponse */ false)); - break; - /* istanbul ignore next: should not be possible to reach */ - default: - console.error(`Unknown bootloader request action ${action}`); - continue; + /* istanbul ignore else: should not be possible to reach */ + if (eraseRequest.matches(action)) { + yield* put( + send( + createEraseFlashRequest(), + /* withResponse */ action.isCityHub && !isWindows(), + ), + ); + } else if (programRequest.matches(action)) { + yield* put( + send( + createProgramFlashRequest(action.address, action.payload), + /* withResponse */ false, + ), + ); + } else if (rebootRequest.matches(action)) { + yield* put(send(createStartAppRequest(), /* withResponse */ false)); + } else if (initRequest.matches(action)) { + yield* put(send(createInitLoaderRequest(action.firmwareSize))); + } else if (infoRequest.matches(action)) { + yield* put(send(createGetInfoRequest())); + } else if (checksumRequest.matches(action)) { + yield* put(send(createGetChecksumRequest())); + } else if (stateRequest.matches(action)) { + yield* put(send(createGetFlashStateRequest())); + } else if (disconnectRequest.matches(action)) { + yield* put(send(createDisconnectRequest(), /* withResponse */ false)); + } else { + console.error(`Unknown bootloader request action ${action}`); + continue; } const { failedToSend } = yield* race({ - sent: take( - BootloaderConnectionActionType.DidSend, - ), - failedToSend: take( - BootloaderConnectionActionType.DidFailToSend, - ), + sent: take>(didSend), + failedToSend: take>(didFailToSend), }); if (failedToSend) { @@ -140,7 +132,7 @@ function* encodeRequest(): Generator { * Converts an incoming connection message to a response action. * @param action The received response action. */ -function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generator { +function* decodeResponse(action: ReturnType): Generator { try { const responseType = getMessageType(action.data); switch (responseType) { @@ -178,5 +170,5 @@ function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generato export default function* (): Generator { yield* fork(encodeRequest); - yield* takeEvery(BootloaderConnectionActionType.DidReceive, decodeResponse); + yield* takeEvery(didReceive, decodeResponse); } diff --git a/src/mpy/actions.ts b/src/mpy/actions.ts index b84e8240..f0ae0f0c 100644 --- a/src/mpy/actions.ts +++ b/src/mpy/actions.ts @@ -1,46 +1,21 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors -import { Action } from 'redux'; - -export enum MpyActionType { - Compile = 'mpy.action.compile', - DidCompile = 'mpy.action.didCompile', - DidFailToCompile = 'mpy.action.didFailToCompile', -} +import { createAction } from '../actions'; /** Action that requests that a script is compiled. */ -export type MpyCompileAction = Action & { - /** The script to compile. */ - readonly script: string; - /** The compiler command line options */ - options: string[]; -}; +export const compile = createAction((script: string, options: string[]) => ({ + type: 'mpy.action.compile', + script, + options, +})); -export function compile(script: string, options: string[]): MpyCompileAction { - return { type: MpyActionType.Compile, script, options }; -} +export const didCompile = createAction((data: Uint8Array) => ({ + type: 'mpy.action.didCompile', + data, +})); -export type MpyDidCompileAction = Action & { - /** The compiled .mpy file. */ - readonly data: Uint8Array; -}; - -export function didCompile(data: Uint8Array): MpyDidCompileAction { - return { type: MpyActionType.DidCompile, data }; -} - -export type MpyDidFailToCompileAction = Action & { - /** Error output. */ - readonly err: string[]; -}; - -export function didFailToCompile(err: string[]): MpyDidFailToCompileAction { - return { type: MpyActionType.DidFailToCompile, err }; -} - -/** Common type for all mpy actions. */ -export type MpyAction = - | MpyCompileAction - | MpyDidCompileAction - | MpyDidFailToCompileAction; +export const didFailToCompile = createAction((err: string[]) => ({ + type: 'mpy.action.didFailToCompile', + err, +})); diff --git a/src/mpy/sagas.test.ts b/src/mpy/sagas.test.ts index 57878652..006bc140 100644 --- a/src/mpy/sagas.test.ts +++ b/src/mpy/sagas.test.ts @@ -1,13 +1,8 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors import { AsyncSaga } from '../../test'; -import { - MpyActionType, - MpyDidCompileAction, - MpyDidFailToCompileAction, - compile, -} from './actions'; +import { compile, didCompile, didFailToCompile } from './actions'; import mpy from './sagas'; enum MpyFeatureFlags { @@ -21,8 +16,8 @@ test('compiler works', async () => { saga.put(compile('print("hello!")', [])); const action = await saga.take(); - expect(action.type).toBe(MpyActionType.DidCompile); - const { data } = action as MpyDidCompileAction; + expect(action.type).toBe(didCompile.toString()); + const { data } = action as ReturnType; expect(data[0]).toBe('M'.charCodeAt(0)); expect(data[1]).toBe(5); // ABI version expect(data[2]).toBe(MpyFeatureFlags.MICROPY_PY_BUILTINS_STR_UNICODE); @@ -35,8 +30,8 @@ test('compiler error works', async () => { saga.put(compile('syntax error!', [])); const action = await saga.take(); - expect(action.type).toBe(MpyActionType.DidFailToCompile); - const { err } = action as MpyDidFailToCompileAction; + expect(action.type).toBe(didFailToCompile.toString()); + const { err } = action as ReturnType; expect(err).toMatchInlineSnapshot(` Array [ "Traceback (most recent call last):", diff --git a/src/mpy/sagas.ts b/src/mpy/sagas.ts index ad36a3b9..1b08473e 100644 --- a/src/mpy/sagas.ts +++ b/src/mpy/sagas.ts @@ -1,22 +1,17 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors import { compile as mpyCrossCompile } from '@pybricks/mpy-cross-v5'; import wasm from '@pybricks/mpy-cross-v5/build/mpy-cross.wasm'; import { call, put, takeEvery } from 'typed-redux-saga/macro'; -import { - MpyActionType, - MpyCompileAction, - didCompile, - didFailToCompile, -} from './actions'; +import { compile, didCompile, didFailToCompile } from './actions'; /** * Compiles a script to .mpy and dispatches either didCompile on success or * didFailToCompile on error. * @param action A mpy compile action. */ -function* compile(action: MpyCompileAction): Generator { +function* handleCompile(action: ReturnType): Generator { const result = yield* call(() => mpyCrossCompile( 'main.py', @@ -34,5 +29,5 @@ function* compile(action: MpyCompileAction): Generator { } export default function* (): Generator { - yield* takeEvery(MpyActionType.Compile, compile); + yield* takeEvery(compile, handleCompile); } diff --git a/src/notifications/actions.ts b/src/notifications/actions.ts index cf2def5f..668bf953 100644 --- a/src/notifications/actions.ts +++ b/src/notifications/actions.ts @@ -1,42 +1,21 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors -import { Action } from 'redux'; - -export enum NotificationActionType { - /** Add a notification to the list of notifications. */ - Add = 'notification.action.add', -} +import { createAction } from '../actions'; export type NotificationLevel = 'error' | 'warning' | 'info'; -export type NotificationAddAction = Action & { - /** - * The type of notification. - */ - readonly level: NotificationLevel; - /** - * The message to be displayed to the user. - */ - readonly message: string; - /** - * URL for help or more information. - */ - readonly helpUrl?: string; -}; - /** * Action to add a notification to the list. * @param level The severity level * @param message The message to display to the user * @param helpUrl An optional URL for more info */ -export function add( - level: NotificationLevel, - message: string, - helpUrl?: string, -): NotificationAddAction { - return { type: NotificationActionType.Add, level, message, helpUrl }; -} - -export type NotificationAction = NotificationAddAction; +export const add = createAction( + (level: NotificationLevel, message: string, helpUrl?: string) => ({ + type: 'notification.action.add', + level, + message, + helpUrl, + }), +); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index d4240157..33acfa14 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -10,44 +10,31 @@ import React from 'react'; import { channel } from 'redux-saga'; import * as semver from 'semver'; import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro'; -import { AppActionType, AppDidCheckForUpdateAction, reload } from '../app/actions'; +import { didCheckForUpdate, reload } from '../app/actions'; import { appName } from '../app/constants'; +import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { - BleDIServiceActionType, - BleDIServiceDidReceiveFirmwareRevisionAction, -} from '../ble-device-info-service/actions'; -import { - BleDeviceActionType, - BleDeviceDidFailToConnectAction, BleDeviceFailToConnectReasonType, + didFailToConnect as bleDeviceDidFailToConnect, } from '../ble/actions'; -import { EditorActionType, EditorDidFailToSaveAsAction } from '../editor/actions'; +import { didFailToSaveAs } from '../editor/actions'; import { - FileStorageActionType, - FileStorageDidFailToInitializeAction, - FileStorageDidFailToReadFileAction, - FileStorageDidFailToWriteFileAction, + fileStorageDidFailToInitialize, + fileStorageDidFailToReadFile, + fileStorageDidFailToWriteFile, } from '../fileStorage/actions'; +import { FailToFinishReasonType, didFailToFinish } from '../firmware/actions'; import { - FailToFinishReasonType, - FlashFirmwareActionType, - FlashFirmwareDidFailToFinishAction, -} from '../firmware/actions'; -import { - BootloaderConnectionActionType, - BootloaderConnectionDidFailToConnectAction, BootloaderConnectionFailureReason, + didFailToConnect as bootloaderDidFailToConnect, } from '../lwp3-bootloader/actions'; -import { MpyActionType, MpyDidFailToCompileAction } from '../mpy/actions'; -import { - ServiceWorkerAction, - ServiceWorkerActionType, -} from '../service-worker/actions'; +import { didCompile, didFailToCompile } from '../mpy/actions'; +import { didUpdate as serviceWorkerDidUpdate } from '../service-worker/actions'; import { pythonVersionToSemver } from '../utils/version'; import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import UnexpectedErrorNotification from './UnexpectedErrorNotification'; -import { NotificationActionType, NotificationAddAction } from './actions'; +import { add as addNotification } from './actions'; import { MessageId } from './i18n'; type NotificationContext = { @@ -172,7 +159,7 @@ function* showUnexpectedError(messageId: MessageId, err: Error): Generator { } function* showBleDeviceDidFailToConnectError( - action: BleDeviceDidFailToConnectAction, + action: ReturnType, ): Generator { switch (action.reason) { case BleDeviceFailToConnectReasonType.NoGatt: @@ -211,7 +198,7 @@ function* showBleDeviceDidFailToConnectError( } function* showBootloaderDidFailToConnectError( - action: BootloaderConnectionDidFailToConnectAction, + action: ReturnType, ): Generator { switch (action.reason) { case BootloaderConnectionFailureReason.GattServiceNotFound: @@ -239,7 +226,9 @@ function* showBootloaderDidFailToConnectError( } } -function* showEditorFailToSaveFile(action: EditorDidFailToSaveAsAction): Generator { +function* showEditorFailToSaveFile( + action: ReturnType, +): Generator { if (action.err.name === 'AbortError') { // user clicked cancel button - not an error return; @@ -249,7 +238,7 @@ function* showEditorFailToSaveFile(action: EditorDidFailToSaveAsAction): Generat } function* showFlashFirmwareError( - action: FlashFirmwareDidFailToFinishAction, + action: ReturnType, ): Generator { switch (action.reason.reason) { case FailToFinishReasonType.TimedOut: @@ -316,7 +305,7 @@ function* dismissCompilerError(): Generator { toaster.dismiss(MessageId.MpyError); } -function* showCompilerError(action: MpyDidFailToCompileAction): Generator { +function* showCompilerError(action: ReturnType): Generator { yield* showSingleton(Level.Error, MessageId.MpyError, { errorMessage: React.createElement( 'pre', @@ -326,7 +315,7 @@ function* showCompilerError(action: MpyDidFailToCompileAction): Generator { }); } -function* addNotification(action: NotificationAddAction): Generator { +function* handleAddNotification(action: ReturnType): Generator { const { toaster } = yield* getContext('notification'); toaster.show({ @@ -339,10 +328,10 @@ function* addNotification(action: NotificationAddAction): Generator { } function* showServiceWorkerUpdate( - updateAction: ServiceWorkerAction, + action: ReturnType, ): Generator { const ch = channel>(); - const action = dispatchAction( + const userAction = dispatchAction( MessageId.ServiceWorkerUpdateAction, ch.put, 'refresh', @@ -352,18 +341,18 @@ function* showServiceWorkerUpdate( MessageId.ServiceWorkerUpdateMessage, { appName, - action: React.createElement('strong', undefined, action.text), + action: React.createElement('strong', undefined, userAction.text), }, - action, + userAction, ch.close, ); yield* take(ch); - yield* put(reload(updateAction.registration)); + yield* put(reload(action.registration)); } -function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator { +function* showNoUpdateInfo(action: ReturnType): Generator { if (action.updateFound) { // this will be handled by ServiceWorkerActionType.DidUpdate action return; @@ -381,7 +370,7 @@ function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator { } function* checkVersion( - action: BleDIServiceDidReceiveFirmwareRevisionAction, + action: ReturnType, ): Generator { // ensure the actual hub firmware version is the same as the shipped // firmware version or newer @@ -396,50 +385,35 @@ function* checkVersion( } function* showFileStorageFailToInitialize( - action: FileStorageDidFailToInitializeAction, + action: ReturnType, ): Generator { yield* showUnexpectedError(MessageId.FileStorageFailedToInitialize, action.error); } function* showFileStorageFailToRead( - action: FileStorageDidFailToReadFileAction, + action: ReturnType, ): Generator { yield* showUnexpectedError(MessageId.FileStorageFailedToRead, action.error); } function* showFileStorageFailToWrite( - action: FileStorageDidFailToWriteFileAction, + action: ReturnType, ): Generator { yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error); } export default function* (): Generator { - yield* takeEvery( - BleDeviceActionType.DidFailToConnect, - showBleDeviceDidFailToConnectError, - ); - yield* takeEvery( - BootloaderConnectionActionType.DidFailToConnect, - showBootloaderDidFailToConnectError, - ); - yield* takeEvery(EditorActionType.DidFailToSaveAs, showEditorFailToSaveFile); - yield* takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError); - yield* takeEvery(MpyActionType.DidCompile, dismissCompilerError); - yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError); - yield* takeEvery(NotificationActionType.Add, addNotification); - yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate); - yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo); - yield* takeEvery(BleDIServiceActionType.DidReceiveFirmwareRevision, checkVersion); - yield* takeEvery( - FileStorageActionType.DidFailToInitialize, - showFileStorageFailToInitialize, - ); - yield* takeEvery( - FileStorageActionType.DidFailToReadFile, - showFileStorageFailToRead, - ); - yield* takeEvery( - FileStorageActionType.DidFailToWriteFile, - showFileStorageFailToWrite, - ); + yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError); + yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError); + yield* takeEvery(didFailToSaveAs, showEditorFailToSaveFile); + yield* takeEvery(didFailToFinish, showFlashFirmwareError); + yield* takeEvery(didCompile, dismissCompilerError); + yield* takeEvery(didFailToCompile, showCompilerError); + yield* takeEvery(addNotification, handleAddNotification); + yield* takeEvery(serviceWorkerDidUpdate, showServiceWorkerUpdate); + yield* takeEvery(didCheckForUpdate, showNoUpdateInfo); + yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion); + yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); + yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead); + yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite); } diff --git a/src/service-worker/actions.ts b/src/service-worker/actions.ts index dc113950..3a719c13 100644 --- a/src/service-worker/actions.ts +++ b/src/service-worker/actions.ts @@ -1,27 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors -import { Action } from 'redux'; +import { createAction } from '../actions'; -export enum ServiceWorkerActionType { - DidUpdate = 'serviceWorker.didUpdate', - DidSucceed = 'serviceWorker.didSucceed', -} +export const didUpdate = createAction((registration: ServiceWorkerRegistration) => ({ + type: 'serviceWorker.action.didUpdate', + registration, +})); -export type ServiceWorkerAction< - T extends ServiceWorkerActionType = ServiceWorkerActionType, -> = Action & { - registration: ServiceWorkerRegistration; -}; - -export function didUpdate( - registration: ServiceWorkerRegistration, -): ServiceWorkerAction { - return { type: ServiceWorkerActionType.DidUpdate, registration }; -} - -export function didSucceed( - registration: ServiceWorkerRegistration, -): ServiceWorkerAction { - return { type: ServiceWorkerActionType.DidSucceed, registration }; -} +export const didSucceed = createAction((registration: ServiceWorkerRegistration) => ({ + type: 'serviceWorker.action.didSucceed', + registration, +})); diff --git a/src/settings/actions.ts b/src/settings/actions.ts index 2e1dd651..80077de4 100644 --- a/src/settings/actions.ts +++ b/src/settings/actions.ts @@ -1,120 +1,56 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { Action } from 'redux'; +import { createAction } from '../actions'; import { BooleanSettingId, StringSettingId } from './defaults'; -/** Actions related to settings. */ -export enum SettingsActionType { - SetBoolean = 'settings.action.setBoolean', - ToggleBoolean = 'settings.action.toggleBoolean', - DidFailToSetBoolean = 'settings.action.didFailToSetBoolean', - DidBooleanChange = 'settings.action.didBooleanChange', - SetString = 'settings.action.setString', - DidFailToSetString = 'settings.action.didFailToSetString', - DidStringChange = 'settings.action.didStringChange', -} - -type SettingInfo = { - /** The ID of the setting. */ - id: TId; - /** The new state for the setting. */ - newState: TState; -}; - -/** Action to set/store a setting. */ -export type SettingsSetBooleanAction = Action & - SettingInfo; - /** Creates an action to set/store a setting. */ -export function setBoolean( - id: BooleanSettingId, - newState: boolean, -): SettingsSetBooleanAction { - return { type: SettingsActionType.SetBoolean, id, newState }; -} - -/** Action to toggle a setting. */ -export type SettingsToggleBooleanAction = Action & { - id: BooleanSettingId; -}; +export const setBoolean = createAction((id: BooleanSettingId, newState: boolean) => ({ + type: 'settings.action.setBoolean', + id, + newState, +})); /** Creates an action to toggle a setting. */ -export function toggleBoolean(id: BooleanSettingId): SettingsToggleBooleanAction { - return { type: SettingsActionType.ToggleBoolean, id }; -} - -/** Action that indicates setting/storing a setting failed. */ -export type SettingsDidFailToSetBooleanAction = - Action & { - id: BooleanSettingId; - err: Error; - }; +export const toggleBoolean = createAction((id: BooleanSettingId) => ({ + type: 'settings.action.toggleBoolean', + id, +})); /** Creates an action indicating that setting/storing a setting failed. */ -export function didFailToSetBoolean( - id: BooleanSettingId, - err: Error, -): SettingsDidFailToSetBooleanAction { - return { type: SettingsActionType.DidFailToSetBoolean, id, err }; -} - -/** Action that indicates a stored boolean setting value changed. */ -export type SettingsDidBooleanChangeAction = - Action & - SettingInfo; +export const didFailToSetBoolean = createAction((id: BooleanSettingId, err: Error) => ({ + type: 'settings.action.didFailToSetBoolean', + id, + err, +})); /** Creates an action that indicates a stored boolean setting value changed. */ -export function didBooleanChange( - id: BooleanSettingId, - newState: boolean, -): SettingsDidBooleanChangeAction { - return { type: SettingsActionType.DidBooleanChange, id, newState }; -} - -/** Action to set/store a setting. */ -export type SettingsSetStringAction = Action & - SettingInfo; +export const didBooleanChange = createAction( + (id: BooleanSettingId, newState: boolean) => ({ + type: 'settings.action.didBooleanChange', + id, + newState, + }), +); /** Creates an action to set/store a setting. */ -export function setString( - id: StringSettingId, - newState: string, -): SettingsSetStringAction { - return { type: SettingsActionType.SetString, id, newState }; -} - -/** Action that indicates setting/storing a setting failed. */ -export type SettingsDidFailToSetStringAction = - Action & { - id: StringSettingId; - err: Error; - }; +export const setString = createAction((id: StringSettingId, newState: string) => ({ + type: 'settings.action.setString', + id, + newState, +})); /** Creates an action indicating that setting/storing a setting failed. */ -export function didFailToSetString( - id: StringSettingId, - err: Error, -): SettingsDidFailToSetStringAction { - return { type: SettingsActionType.DidFailToSetString, id, err }; -} +export const didFailToSetString = createAction((id: StringSettingId, err: Error) => ({ + type: 'settings.action.didFailToSetString', + id, + err, +})); -export type SettingsDidStringChangeAction = Action & - SettingInfo; - -export function didStringChange( - id: StringSettingId, - newState: string, -): SettingsDidStringChangeAction { - return { type: SettingsActionType.DidStringChange, id, newState }; -} - -/** Common type for all settings actions. */ -export type SettingsAction = - | SettingsSetBooleanAction - | SettingsToggleBooleanAction - | SettingsDidFailToSetBooleanAction - | SettingsDidBooleanChangeAction - | SettingsSetStringAction - | SettingsDidFailToSetStringAction - | SettingsDidStringChangeAction; +export const didStringChange = createAction( + (id: StringSettingId, newState: string) => ({ + type: 'settings.action.didStringChange', + id, + newState, + }), +); diff --git a/src/settings/reducers.test.ts b/src/settings/reducers.test.ts index 9c237c9e..3b760c71 100644 --- a/src/settings/reducers.test.ts +++ b/src/settings/reducers.test.ts @@ -1,4 +1,4 @@ -import { Action } from '../actions'; +import { AnyAction } from 'redux'; import { didBooleanChange, didStringChange } from './actions'; import { BooleanSettingId, StringSettingId } from './defaults'; import reducers from './reducers'; @@ -6,7 +6,7 @@ import reducers from './reducers'; type State = ReturnType; test('initial state', () => { - expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "darkMode": false, "flashCurrentProgram": false, diff --git a/src/settings/reducers.ts b/src/settings/reducers.ts index 29e9d5c6..f53f4376 100644 --- a/src/settings/reducers.ts +++ b/src/settings/reducers.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; -import { Action } from '../actions'; -import { SettingsActionType } from './actions'; +import { didBooleanChange, didStringChange } from './actions'; import { BooleanSettingId, StringSettingId, @@ -13,83 +12,78 @@ import { const encoder = new TextEncoder(); -const darkMode: Reducer = ( +const darkMode: Reducer = ( state = getDefaultBooleanValue(BooleanSettingId.DarkMode), action, ) => { - switch (action.type) { - case SettingsActionType.DidBooleanChange: - if (action.id === BooleanSettingId.DarkMode) { - return action.newState; - } - return state; - default: - return state; + if (didBooleanChange.matches(action)) { + if (action.id === BooleanSettingId.DarkMode) { + return action.newState; + } + return state; } + + return state; }; -const showDocs: Reducer = ( +const showDocs: Reducer = ( state = getDefaultBooleanValue(BooleanSettingId.ShowDocs), action, ) => { - switch (action.type) { - case SettingsActionType.DidBooleanChange: - if (action.id === BooleanSettingId.ShowDocs) { - return action.newState; - } - return state; - default: - return state; + if (didBooleanChange.matches(action)) { + if (action.id === BooleanSettingId.ShowDocs) { + return action.newState; + } + return state; } + + return state; }; -const flashCurrentProgram: Reducer = ( +const flashCurrentProgram: Reducer = ( state = getDefaultBooleanValue(BooleanSettingId.FlashCurrentProgram), action, ) => { - switch (action.type) { - case SettingsActionType.DidBooleanChange: - if (action.id === BooleanSettingId.FlashCurrentProgram) { - return action.newState; - } - return state; - default: - return state; + if (didBooleanChange.matches(action)) { + if (action.id === BooleanSettingId.FlashCurrentProgram) { + return action.newState; + } + return state; } + + return state; }; -const hubName: Reducer = ( +const hubName: Reducer = ( state = getDefaultStringValue(StringSettingId.HubName), action, ) => { - switch (action.type) { - case SettingsActionType.DidStringChange: - if (action.id === StringSettingId.HubName) { - return action.newState; - } - return state; - default: - return state; + if (didStringChange.matches(action)) { + if (action.id === StringSettingId.HubName) { + return action.newState; + } + return state; } + + return state; }; -const isHubNameValid: Reducer = (state = true, action) => { - switch (action.type) { - case SettingsActionType.DidStringChange: - if (action.id === StringSettingId.HubName) { - const encoded = encoder.encode(action.newState); +const isHubNameValid: Reducer = (state = true, action) => { + if (didStringChange.matches(action)) { + if (action.id === StringSettingId.HubName) { + const encoded = encoder.encode(action.newState); - // Technically, the max hub name size is determined by each individual - // firmware file, so we can't check until the firmware has been selected. - // However all firmware currently has 16 bytes allocated (including zero- - // termination), so we can hard code the check here to allow notifying the - // user earlier for better UX. - return encoded.length < 16; - } - return state; - default: - return state; + // Technically, the max hub name size is determined by each individual + // firmware file, so we can't check until the firmware has been selected. + // However all firmware currently has 16 bytes allocated (including zero- + // termination), so we can hard code the check here to allow notifying the + // user earlier for better UX. + return encoded.length < 16; + } + return state; } + + return state; }; export default combineReducers({ diff --git a/src/settings/sagas.ts b/src/settings/sagas.ts index a0821317..dcb1f1b0 100644 --- a/src/settings/sagas.ts +++ b/src/settings/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // This manages settings by storing them in local storage whenever the app // request to set a setting. When local storage changes, it triggers a did @@ -7,19 +7,17 @@ import { EventChannel, eventChannel } from 'redux-saga'; import { call, fork, put, select, take, takeEvery } from 'typed-redux-saga/macro'; -import { AppActionType } from '../app/actions'; +import { didStart } from '../app/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { - SettingsActionType, - SettingsSetBooleanAction, - SettingsSetStringAction, - SettingsToggleBooleanAction, didBooleanChange, didFailToSetBoolean, didFailToSetString, didStringChange, setBoolean, + setString, + toggleBoolean, } from './actions'; import { BooleanSettingId, @@ -102,7 +100,7 @@ function* loadSettings(): Generator { } } -function* storeBooleanSetting(action: SettingsSetBooleanAction): Generator { +function* storeBooleanSetting(action: ReturnType): Generator { const key = `setting.${action.id}`; const newValue = String(action.newState); @@ -128,12 +126,12 @@ function* storeBooleanSetting(action: SettingsSetBooleanAction): Generator { } } -function* toggleBooleanSetting(action: SettingsToggleBooleanAction): Generator { +function* toggleBooleanSetting(action: ReturnType): Generator { const oldValue = yield* select((s: RootState) => s.settings[action.id]); yield* storeBooleanSetting(setBoolean(action.id, !oldValue)); } -function* storeStringSetting(action: SettingsSetStringAction): Generator { +function* storeStringSetting(action: ReturnType): Generator { const key = `setting.${action.id}`; const newValue = action.newState; @@ -161,8 +159,8 @@ function* storeStringSetting(action: SettingsSetStringAction): Generator { export default function* (): Generator { yield* fork(monitorLocalStorage); - yield* takeEvery(AppActionType.DidStart, loadSettings); - yield* takeEvery(SettingsActionType.SetBoolean, storeBooleanSetting); - yield* takeEvery(SettingsActionType.ToggleBoolean, toggleBooleanSetting); - yield* takeEvery(SettingsActionType.SetString, storeStringSetting); + yield* takeEvery(didStart, loadSettings); + yield* takeEvery(setBoolean, storeBooleanSetting); + yield* takeEvery(toggleBoolean, toggleBooleanSetting); + yield* takeEvery(setString, storeStringSetting); } diff --git a/src/terminal/actions.ts b/src/terminal/actions.ts index c3a779b5..3303cf2c 100644 --- a/src/terminal/actions.ts +++ b/src/terminal/actions.ts @@ -1,35 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020,2022 The Pybricks Authors -import { Action } from 'redux'; +import { createAction } from '../actions'; -export enum TerminalActionType { - /** - * Send data. - */ - SendData = 'terminal.action.sendData', - /** - * Data was received. - */ - ReceivedData = 'terminal.action.receiveData', -} +export const sendData = createAction((data: string) => ({ + type: 'terminal.action.sendData', + value: data, +})); -export type TerminalDataSendDataAction = Action & { - value: string; -}; - -export function sendData(data: string): TerminalDataSendDataAction { - return { type: TerminalActionType.SendData, value: data }; -} - -export type TerminalDataReceiveDataAction = Action & { - value: string; -}; - -export function receiveData(data: string): TerminalDataReceiveDataAction { - return { type: TerminalActionType.ReceivedData, value: data }; -} - -export type TerminalDataAction = - | TerminalDataSendDataAction - | TerminalDataReceiveDataAction; +export const receiveData = createAction((data: string) => ({ + type: 'terminal.action.receiveData', + value: data, +})); diff --git a/src/terminal/sagas.test.ts b/src/terminal/sagas.test.ts index 495eea54..ed99fc3a 100644 --- a/src/terminal/sagas.test.ts +++ b/src/terminal/sagas.test.ts @@ -1,24 +1,18 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import PushStream from 'zen-push'; import { AsyncSaga, delay } from '../../test'; import { - BleUartActionType, - BleUartWriteAction, didFailToWrite, didNotify, didWrite, + write, } from '../ble-nordic-uart-service/actions'; -import { HubChecksumMessageAction, HubMessageActionType } from '../hub/actions'; +import { checksum } from '../hub/actions'; import { HubRuntimeState } from '../hub/reducers'; import { createCountFunc } from '../utils/iter'; -import { - TerminalActionType, - TerminalDataSendDataAction, - receiveData, - sendData, -} from './actions'; +import { receiveData, sendData } from './actions'; import terminal from './sagas'; describe('Data receiver filters out hub status', () => { @@ -33,8 +27,8 @@ describe('Data receiver filters out hub status', () => { saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer))); const action = await saga.take(); - expect(action.type).toBe(TerminalActionType.SendData); - expect((action as TerminalDataSendDataAction).value).toBe(' '); + expect(action.type).toBe(sendData.toString()); + expect((action as ReturnType).value).toBe(' '); await saga.end(); }); @@ -49,8 +43,8 @@ describe('Data receiver filters out hub status', () => { saga.put(didNotify(new DataView(new Uint8Array([0xaa]).buffer))); const action = await saga.take(); - expect(action.type).toBe(HubMessageActionType.Checksum); - expect((action as HubChecksumMessageAction).checksum).toBe(0xaa); + expect(action.type).toBe(checksum.toString()); + expect((action as ReturnType).checksum).toBe(0xaa); await saga.end(); }); @@ -89,8 +83,8 @@ describe('Terminal data source responds to receive data actions', () => { saga.put(receiveData('test1234')); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); - expect((action as BleUartWriteAction).value).toEqual(expected); + expect(action.type).toBe(write.toString()); + expect((action as ReturnType).value).toEqual(expected); await saga.end(); }); @@ -106,19 +100,19 @@ describe('Terminal data source responds to receive data actions', () => { expect(saga.numPending()).toBe(1); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); - expect((action as BleUartWriteAction).value).toEqual(expected); + expect(action.type).toBe(write.toString()); + expect((action as ReturnType).value).toEqual(expected); // second message is queued until didWrite or didFailToWrite expect(saga.numPending()).toBe(0); - saga.put(didWrite((action as BleUartWriteAction).id)); + saga.put(didWrite((action as ReturnType).id)); const action2 = await saga.take(); - expect(action2.type).toBe(BleUartActionType.Write); - expect((action2 as BleUartWriteAction).value).toEqual(expected); + expect(action2.type).toBe(write.toString()); + expect((action2 as ReturnType).value).toEqual(expected); - saga.put(didWrite((action2 as BleUartWriteAction).id)); + saga.put(didWrite((action2 as ReturnType).id)); await saga.end(); }); @@ -134,21 +128,24 @@ describe('Terminal data source responds to receive data actions', () => { expect(saga.numPending()).toBe(1); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); - expect((action as BleUartWriteAction).value).toEqual(expected); + expect(action.type).toBe(write.toString()); + expect((action as ReturnType).value).toEqual(expected); // second message is queued until didWrite or didFailToWrite expect(saga.numPending()).toBe(0); saga.put( - didFailToWrite((action as BleUartWriteAction).id, new Error('test error')), + didFailToWrite( + (action as ReturnType).id, + new Error('test error'), + ), ); const action2 = await saga.take(); - expect(action2.type).toBe(BleUartActionType.Write); - expect((action2 as BleUartWriteAction).value).toEqual(expected); + expect(action2.type).toBe(write.toString()); + expect((action2 as ReturnType).value).toEqual(expected); - saga.put(didWrite((action2 as BleUartWriteAction).id)); + saga.put(didWrite((action2 as ReturnType).id)); await saga.end(); }); @@ -160,8 +157,8 @@ describe('Terminal data source responds to receive data actions', () => { saga.put(receiveData('test1234')); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); - expect((action as BleUartWriteAction).value).toEqual( + expect(action.type).toBe(write.toString()); + expect((action as ReturnType).value).toEqual( new Uint8Array([...expected, ...expected]), ); @@ -174,16 +171,16 @@ describe('Terminal data source responds to receive data actions', () => { saga.put(receiveData('012345678901234567890123456789')); const action = await saga.take(); - expect(action.type).toBe(BleUartActionType.Write); - expect((action as BleUartWriteAction).value.length).toEqual(20); + expect(action.type).toBe(write.toString()); + expect((action as ReturnType).value.length).toEqual(20); - saga.put(didWrite((action as BleUartWriteAction).id)); + saga.put(didWrite((action as ReturnType).id)); const action2 = await saga.take(); - expect(action2.type).toBe(BleUartActionType.Write); - expect((action2 as BleUartWriteAction).value.length).toEqual(10); + expect(action2.type).toBe(write.toString()); + expect((action2 as ReturnType).value.length).toEqual(10); - saga.put(didWrite((action2 as BleUartWriteAction).id)); + saga.put(didWrite((action2 as ReturnType).id)); await saga.end(); }); diff --git a/src/terminal/sagas.ts b/src/terminal/sagas.ts index bd5f021f..6418c5bd 100644 --- a/src/terminal/sagas.ts +++ b/src/terminal/sagas.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors +import { AnyAction } from 'redux'; import { actionChannel, delay, @@ -12,10 +13,10 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { Action } from '../actions'; import { - BleUartActionType, - BleUartDidNotifyAction, + didFailToWrite, + didNotify, + didWrite, write, } from '../ble-nordic-uart-service/actions'; import { SafeTxCharLength } from '../ble-nordic-uart-service/protocol'; @@ -24,12 +25,12 @@ import { HubRuntimeState } from '../hub/reducers'; import { RootState } from '../reducers'; import { defined } from '../utils'; import { TerminalContextValue } from './TerminalContext'; -import { TerminalActionType, TerminalDataReceiveDataAction, sendData } from './actions'; +import { receiveData, sendData } from './actions'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -function* receiveUartData(action: BleUartDidNotifyAction): Generator { +function* receiveUartData(action: ReturnType): Generator { const hubState = yield* select((s: RootState) => s.hub.runtime); if (hubState === HubRuntimeState.Loading && action.value.buffer.byteLength === 1) { @@ -43,9 +44,7 @@ function* receiveUartData(action: BleUartDidNotifyAction): Generator { } function* receiveTerminalData(): Generator { - const channel = yield* actionChannel( - TerminalActionType.ReceivedData, - ); + const channel = yield* actionChannel>(receiveData); while (true) { // wait for input from terminal const action = yield* take(channel); @@ -74,26 +73,24 @@ function* receiveTerminalData(): Generator { ); yield* take( - (a: Action) => - (a.type === BleUartActionType.DidWrite || - a.type === BleUartActionType.DidFailToWrite) && - a.id === id, + (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(BleUartActionType.DidNotify), delay(100)]); + yield* race([take(didNotify), delay(100)]); } } } -function* sendTerminalData(action: TerminalDataReceiveDataAction): Generator { +function* sendTerminalData(action: ReturnType): Generator { const { dataSource } = yield* getContext('terminal'); // This is used to provide a data source for the Terminal component dataSource.next(action.value); } export default function* (): Generator { - yield* takeEvery(BleUartActionType.DidNotify, receiveUartData); + yield* takeEvery(didNotify, receiveUartData); yield* fork(receiveTerminalData); - yield* takeEvery(TerminalActionType.SendData, sendTerminalData); + yield* takeEvery(sendData, sendTerminalData); }