diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 611461be..271312ff 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -20,12 +20,14 @@ type StateProps = { open: boolean; showDocs: boolean; darkMode: boolean; + flashCurrentProgram: boolean; }; type DispatchProps = { onClose: () => void; onShowDocsChanged: (checked: boolean) => void; onDarkModeChanged: (checked: boolean) => void; + onFlashCurrentProgramChanged: (checked: boolean) => void; }; type SettingsProps = StateProps & DispatchProps & WithI18nProps; @@ -40,6 +42,8 @@ class SettingsDrawer extends React.PureComponent { onShowDocsChanged, darkMode, onDarkModeChanged, + flashCurrentProgram, + onFlashCurrentProgramChanged, } = this.props; return ( { } /> + + + onFlashCurrentProgramChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + ); @@ -95,6 +113,7 @@ const mapStateToProps = (state: RootState): StateProps => ({ open: state.app.showSettings, showDocs: state.settings.showDocs, darkMode: state.settings.darkMode, + flashCurrentProgram: state.settings.flashCurrentProgram, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ @@ -103,6 +122,8 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ dispatch(setBoolean(SettingId.ShowDocs, checked)), onDarkModeChanged: (checked): Action => dispatch(setBoolean(SettingId.DarkMode, checked)), + onFlashCurrentProgramChanged: (checked): Action => + dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)), }); export default connect( diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 4cdbfaed..7ac9a327 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -12,6 +12,12 @@ "zoom": { "help": "Use {in} and {out} to zoom." } + }, + "firmware": { + "title": "Firmware", + "flash-current-program": { + "label": "Flash my program" + } } } } diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index c56cda37..baea36ce 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -9,4 +9,6 @@ export enum SettingsStringId { AppearanceDocumentationLabel = 'settings.appearance.documentation.label', AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label', AppearanceZoomHelp = 'settings.appearance.zoom.help', + FirmwareTitle = 'settings.firmware.title', + FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label', } diff --git a/src/components/settings.scss b/src/components/settings.scss index e79c6349..a1c23ce4 100644 --- a/src/components/settings.scss +++ b/src/components/settings.scss @@ -3,7 +3,7 @@ // Custom styling for the Settings* controls. -.pb-settings { +.pb-settings .bp3-form-group { margin: 25px; } diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index 884814ed..51d4294e 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -9,6 +9,7 @@ import { SettingId, getDefaultBooleanValue } from '../settings'; export interface SettingsState { readonly darkMode: boolean; readonly showDocs: boolean; + readonly flashCurrentProgram: boolean; } const darkMode: Reducer = ( @@ -41,4 +42,19 @@ const showDocs: Reducer = ( } }; -export default combineReducers({ darkMode, showDocs }); +const flashCurrentProgram: Reducer = ( + state = getDefaultBooleanValue(SettingId.FlashCurrentProgram), + action, +) => { + switch (action.type) { + case SettingsActionType.DidBooleanChange: + if (action.id === SettingId.FlashCurrentProgram) { + return action.newState; + } + return state; + default: + return state; + } +}; + +export default combineReducers({ darkMode, showDocs, flashCurrentProgram }); diff --git a/src/sagas/flash-firmware.ts b/src/sagas/flash-firmware.ts index eb7a862b..7ef58344 100644 --- a/src/sagas/flash-firmware.ts +++ b/src/sagas/flash-firmware.ts @@ -5,6 +5,7 @@ import { FirmwareMetadata, FirmwareReader, HubType } from '@pybricks/firmware'; 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 { Ace } from 'ace-builds'; import { Effect, all, @@ -12,6 +13,7 @@ import { delay, put, race, + select, take, takeEvery, } from 'redux-saga/effects'; @@ -59,6 +61,7 @@ import { } from '../actions/mpy'; import * as notification from '../actions/notification'; import { MaxProgramFlashSize } from '../protocols/lwp3-bootloader'; +import { RootState } from '../reducers'; import { fmod, sumComplement32 } from '../utils/math'; const firmwareZipMap = new Map([ @@ -112,15 +115,21 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator { /** * Loads Pybricks firmware from a .zip file * @param data The zip file raw data + * @param program User program or `undefined` to use main.py from firmware.zip */ function* loadFirmware( data: ArrayBuffer, + program: string | undefined, ): Generator { const reader = (yield call(() => FirmwareReader.load(data))) as FirmwareReader; const firmwareBase = (yield call(() => reader.readFirmwareBase())) as Uint8Array; const metadata = (yield call(() => reader.readMetadata())) as FirmwareMetadata; - const main = (yield call(() => reader.readMainPy())) as string; + + // if a user program was not given, then use main.py from the frimware.zip + if (program === undefined) { + program = (yield call(() => reader.readMainPy())) as string; + } if (metadata['mpy-abi-version'] !== 5) { throw Error( @@ -128,7 +137,7 @@ function* loadFirmware( ); } - yield put(compile(main, metadata['mpy-cross-options'])); + yield put(compile(program, metadata['mpy-cross-options'])); const [mpy, mpyFail] = (yield race([ take(MpyActionType.DidCompile), take(MpyActionType.DidFailToCompile), @@ -174,8 +183,28 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { let firmware: Uint8Array | undefined = undefined; let deviceId: HubType | undefined = undefined; + let program: string | undefined = undefined; + + const flashCurrentProgram = (yield select( + (s: RootState) => s.settings.flashCurrentProgram, + )) as boolean; + + if (flashCurrentProgram) { + const editor = (yield select( + (s: RootState) => s.editor.current, + )) as Ace.EditSession | null; + + // istanbul ignore if: it is a bug to dispatch this action with no current editor + if (editor === null) { + console.error('flashFirmware: No current editor'); + return; + } + + program = editor.getValue(); + } + if (action.data !== undefined) { - ({ firmware, deviceId } = yield* loadFirmware(action.data)); + ({ firmware, deviceId } = yield* loadFirmware(action.data, program)); } yield put(connect()); @@ -227,7 +256,7 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { } const data = (yield call(() => response.arrayBuffer())) as ArrayBuffer; - ({ firmware, deviceId } = yield* loadFirmware(data)); + ({ firmware, deviceId } = yield* loadFirmware(data, program)); if (deviceId !== undefined && info[0].hubType !== deviceId) { throw Error( diff --git a/src/sagas/settings.test.ts b/src/sagas/settings.test.ts index 99f9aeb9..ad4c762a 100644 --- a/src/sagas/settings.test.ts +++ b/src/sagas/settings.test.ts @@ -275,6 +275,26 @@ describe('store settings to local storage', () => { await saga.end(); }); + + test('flashCurrentProgram', async () => { + const saga = new AsyncSaga(settings); + + const mockSetItem = jest + .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') + .mockImplementation((key, value) => { + expect(key).toBe('setting.flashCurrentProgram'); + expect(value).toBe('false'); + }); + + saga.setState({ settings: { flashCurrentProgram: true } as SettingsState }); + saga.put(setBoolean(SettingId.FlashCurrentProgram, false)); + expect(mockSetItem).toHaveBeenCalled(); + + const action = await saga.take(); + expect(action).toEqual(didBooleanChange(SettingId.FlashCurrentProgram, false)); + + await saga.end(); + }); }); describe('storage monitor', () => { diff --git a/src/settings/index.ts b/src/settings/index.ts index 09be058e..0c9a2406 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -6,6 +6,7 @@ export enum SettingId { ShowDocs = 'showDocs', DarkMode = 'darkMode', + FlashCurrentProgram = 'flashCurrentProgram', } export function getDefaultBooleanValue(id: SettingId): boolean { @@ -13,7 +14,9 @@ export function getDefaultBooleanValue(id: SettingId): boolean { case SettingId.ShowDocs: return window.innerWidth >= 1024; case SettingId.DarkMode: + case SettingId.FlashCurrentProgram: return false; + // istanbul ignore next: it is a programmer error if we hit this default: throw Error(`Bad setting id: ${id}`); }