diff --git a/main/MenuTemplate/HelpMenu.ts b/main/MenuTemplate/HelpMenu.ts index 27df46c9..0e3f1ae2 100644 --- a/main/MenuTemplate/HelpMenu.ts +++ b/main/MenuTemplate/HelpMenu.ts @@ -4,6 +4,7 @@ import RendererBridge from '../RendererBridge'; import showAPI from '../main-process'; import { MenuItemConstructorOptions } from 'electron'; +import { ipcChannels } from '../../shared'; const HelpMenu: MenuItemConstructorOptions = { label: 'Help', @@ -11,7 +12,7 @@ const HelpMenu: MenuItemConstructorOptions = { { label: 'Interactive Tutorial', click() { - RendererBridge.dispatch('main', 'start-interactive-tour'); + RendererBridge.dispatch('main', ipcChannels.START_INTERACTIVE_TOUR); }, accelerator: 'CommandOrControl+T', }, diff --git a/main/RendererBridge.ts b/main/RendererBridge.ts index 149716ba..0458adf7 100644 --- a/main/RendererBridge.ts +++ b/main/RendererBridge.ts @@ -5,6 +5,7 @@ import _ from 'lodash'; import { BrowserWindow } from "electron"; +import { ipcChannels } from '../shared'; type SingleOrArray = T | T[]; @@ -58,7 +59,7 @@ class RendererBridge { const registeredWindow = this.registeredWindows[key]; // Special case for dispatching Redux since we can only dispatch one action at a time - if (channel === 'reduxDispatch') { + if (channel === ipcChannels.REDUX_DISPATCH) { data = data[0]; } @@ -75,7 +76,7 @@ class RendererBridge { * Redux actions to the main window to avoid large refactors from the current usage of this method. */ reduxDispatch = (action: any, windowKeys: SingleOrArray | 'all' | 'main' = 'main') => { - this.dispatch(windowKeys, 'reduxDispatch', action); + this.dispatch(windowKeys, ipcChannels.REDUX_DISPATCH, action); }; }; diff --git a/main/networking/Runtime.ts b/main/networking/Runtime.ts index 55494d69..3923bffb 100644 --- a/main/networking/Runtime.ts +++ b/main/networking/Runtime.ts @@ -1,6 +1,7 @@ import { Socket as TCPSocket } from 'net'; import { ipcMain, IpcMainEvent } from 'electron'; import * as protos from '../../protos-main'; +import { ipcChannels } from '../../shared'; import RendererBridge from '../RendererBridge'; import { updateConsole } from '../../renderer/actions/ConsoleActions'; @@ -227,7 +228,7 @@ class RuntimeConnection { break; default: - this.logger.log(`Unsupported received message type: ${packet.type}`) + this.logger.log(`Unsupported received message type: ${packet.type}`); } } @@ -237,13 +238,17 @@ class RuntimeConnection { /** * TCP Socket IPC Connections */ - ipcMain.on('runModeUpdate', (event: IpcMainEvent, ...args: any[]) => this.whenConnectionEstablished(this.sendRunMode, event, ...args)); - ipcMain.on('initiateLatencyCheck', (event: IpcMainEvent, ...args: any[]) => + ipcMain.on(ipcChannels.RUN_MODE_UPDATE, (event: IpcMainEvent, ...args: any[]) => + this.whenConnectionEstablished(this.sendRunMode, event, ...args) + ); + ipcMain.on(ipcChannels.INITIATE_LATENCY_CHECK, (event: IpcMainEvent, ...args: any[]) => this.whenConnectionEstablished(this.initiateLatencyCheck, event, ...args) ); - ipcMain.on('stateUpdate', (event: IpcMainEvent, ...args: any[]) => this.whenConnectionEstablished(this.sendInputs, event, ...args)); + ipcMain.on(ipcChannels.STATE_UPDATE, (event: IpcMainEvent, ...args: any[]) => + this.whenConnectionEstablished(this.sendInputs, event, ...args) + ); - ipcMain.on('ipAddress', this.ipAddressListener); + ipcMain.on(ipcChannels.IP_ADDRESS, this.ipAddressListener); } whenConnectionEstablished = (cb: (event: IpcMainEvent, ...args: any[]) => void, event: IpcMainEvent, ...args: any[]) => { @@ -326,10 +331,10 @@ class RuntimeConnection { close = () => { this.socket.end(); - ipcMain.removeListener('runModeUpdate', this.sendRunMode); - ipcMain.removeListener('ipAddress', this.ipAddressListener); - ipcMain.removeListener('initiateLatencyCheck', this.initiateLatencyCheck); - ipcMain.removeListener('stateUpdate', this.sendInputs); + ipcMain.removeListener(ipcChannels.RUN_MODE_UPDATE, this.sendRunMode); + ipcMain.removeListener(ipcChannels.IP_ADDRESS, this.ipAddressListener); + ipcMain.removeListener(ipcChannels.INITIATE_LATENCY_CHECK, this.initiateLatencyCheck); + ipcMain.removeListener(ipcChannels.STATE_UPDATE, this.sendInputs); }; } diff --git a/package.json b/package.json index c2e9c240..7b000c31 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,8 @@ "json-loader": "0.5.7", "keymirror": "0.1.1", "lodash": ">=4.17.21", + "mobx": "^6.3.2", + "mobx-react": "^7.2.0", "mousetrap": "1.6.1", "numeral": "2.0.6", "object-assign": "4.1.1", diff --git a/renderer/components/App.tsx b/renderer/components/App.tsx index 11ccd266..032fb866 100644 --- a/renderer/components/App.tsx +++ b/renderer/components/App.tsx @@ -13,6 +13,9 @@ import { logging, startLog } from '../utils/utils'; import { FieldControlConfig } from '../types'; import { library } from '@fortawesome/fontawesome-svg-core'; import { fas } from '@fortawesome/free-solid-svg-icons'; +import { Observer } from 'mobx-react'; +import { useStores } from '../hooks'; +import { ipcChannels } from '../../shared'; type ElectronJSONStorage = typeof electronJSONStorage; @@ -30,7 +33,6 @@ interface StateProps { } interface DispatchProps { - onAlertDone: (id: number) => void; onFCUpdate: (param: FieldControlConfig) => void; } @@ -41,9 +43,11 @@ export const AppComponent = (props: Props) => { const [tourRunning, changeTourRunning] = useState(false); startLog(); + const { info, settings, fieldStore } = useStores(); + useEffect(() => { addSteps(joyrideSteps); - ipcRenderer.on('start-interactive-tour', () => { + ipcRenderer.on(ipcChannels.START_INTERACTIVE_TOUR, () => { startTour(); }); storage.has('firstTime', (hasErr: any, hasKey: any) => { @@ -67,7 +71,7 @@ export const AppComponent = (props: Props) => { return; } const fieldControlConfig = data as FieldControlConfig; - props.onFCUpdate(fieldControlConfig); + fieldStore.updateFCConfig(fieldControlConfig); ipcRenderer.send('FC_CONFIG_CHANGE', fieldControlConfig); }); }, []); @@ -93,19 +97,11 @@ export const AppComponent = (props: Props) => { } }; - const { runtimeStatus, masterStatus, connectionStatus, isRunningCode } = props; - const bsPrefix = props.globalTheme === 'dark' ? 'text-light bg-dark' : ''; return (
- + { }} />
- + {() => }
); }; @@ -143,9 +133,6 @@ const mapStateToProps = (state: ApplicationState) => ({ }); const mapDispatchToProps = (dispatch: Dispatch) => ({ - onAlertDone: (id: number) => { - dispatch(removeAsyncAlert(id)); - }, onFCUpdate: (param: FieldControlConfig) => { dispatch(updateFieldControl(param)); } diff --git a/renderer/components/ConfigBox.tsx b/renderer/components/ConfigBox.tsx index e3924dd7..d0147b9b 100644 --- a/renderer/components/ConfigBox.tsx +++ b/renderer/components/ConfigBox.tsx @@ -9,6 +9,8 @@ import { updateFieldControl } from '../actions/FieldActions'; import { ipChange, sshIpChange } from '../actions/InfoActions'; import storage from 'electron-json-storage'; import { Formik } from 'formik'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; interface Config { stationNumber: number; @@ -33,30 +35,32 @@ interface OwnProps { hide: () => void; } -type Props = StateProps & DispatchProps & OwnProps; +type Props = DispatchProps & OwnProps; type FormControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; - +// TODO: lots of changes to be done here export const ConfigBoxComponent = (props: Props) => { - const [ipAddress, setIPAddress] = useState(props.ipAddress); - const [sshAddress, setSSHAddress] = useState(props.sshAddress); - const [fcAddress, setFCAddress] = useState(props.fcAddress); - const [stationNumber, setStationNumber] = useState(props.stationNumber); - const [originalIPAddress, setOriginalIPAddress] = useState(props.ipAddress); - const [originalSSHAddress, setOriginalSSHAddress] = useState(props.sshAddress); - const [originalStationNumber, setOriginalStationNumber] = useState(props.stationNumber); - const [originalFCAddress, setOriginalFCAddress] = useState(props.fcAddress); + + const {info, fieldStore} = useStores(); + + const [ipAddress, setIPAddress] = useState(info.ipAddress); + const [sshAddress, setSSHAddress] = useState(info.sshAddress); + const [fcAddress, setFCAddress] = useState(props.fcAddress); //TODO + const [stationNumber, setStationNumber] = useState(fieldStore.stationNumber); + const [originalIPAddress, setOriginalIPAddress] = useState(info.ipAddress); + const [originalSSHAddress, setOriginalSSHAddress] = useState(info.sshAddress); + const [originalStationNumber, setOriginalStationNumber] = useState(fieldStore.stationNumber); + const [originalFCAddress, setOriginalFCAddress] = useState(props.fcAddress); //TODO const saveChanges = (e: React.FormEvent) => { e.preventDefault(); - props.onIPChange(ipAddress); - setOriginalIPAddress(ipAddress); + info.ipChange(ipAddress); storage.set('ipAddress', { ipAddress }, (err: any) => { if (err) logging.log(err); }); - props.onSSHAddressChange(sshAddress); + info.sshIpChange(sshAddress); setOriginalSSHAddress(sshAddress); storage.set('sshAddress', { sshAddress }, (err: any) => { if (err) { @@ -68,7 +72,7 @@ export const ConfigBoxComponent = (props: Props) => { stationNumber: stationNumber, bridgeAddress: fcAddress, }; - props.onFCUpdate(newConfig); + fieldStore.updateFCConfig(newConfig); setOriginalStationNumber(stationNumber); setOriginalFCAddress(fcAddress); @@ -118,7 +122,7 @@ export const ConfigBoxComponent = (props: Props) => { } else if (!_.isEmpty(data)) { const ipAddress = (data as { ipAddress: string | undefined }).ipAddress ?? defaults.IPADDRESS; - props.onIPChange(ipAddress); + info.ipChange(ipAddress); setIPAddress(ipAddress); setOriginalIPAddress(ipAddress); } @@ -130,7 +134,7 @@ export const ConfigBoxComponent = (props: Props) => { } else if (!_.isEmpty(data)) { const sshAddress = (data as { sshAddress: string | undefined }).sshAddress ?? defaults.IPADDRESS; - props.onSSHAddressChange(sshAddress); + info.sshIpChange(sshAddress); setSSHAddress(sshAddress); setOriginalSSHAddress(sshAddress); } diff --git a/renderer/components/ConsoleOutput.tsx b/renderer/components/ConsoleOutput.tsx index 60f234da..614ceb73 100644 --- a/renderer/components/ConsoleOutput.tsx +++ b/renderer/components/ConsoleOutput.tsx @@ -1,23 +1,22 @@ import React, { useEffect, useRef } from 'react'; import { Card } from 'react-bootstrap'; - -interface StateProps { - toggleConsole: () => void; - disableScroll: boolean; -} +import { useStores } from '../hooks'; interface OwnProps { + toggleConsole: () => void; height: number; output: string[]; show: boolean; } -type Props = StateProps & OwnProps; +type Props = OwnProps; export function ConsoleOutput(props: Props) { let outerDiv: HTMLDivElement | HTMLPreElement | null; const { show, output } = props; + const { console } = useStores(); + const prevOutputRef = useRef([] as string[]); const prevOutput = prevOutputRef.current; useEffect(() => { @@ -29,7 +28,7 @@ export function ConsoleOutput(props: Props) { }); const scrollToBottom = () => { - if (!props.disableScroll) { + if (!console.disableScroll.get()) { if (outerDiv !== null) { outerDiv.scrollTop = outerDiv.scrollHeight; } diff --git a/renderer/components/DNav.tsx b/renderer/components/DNav.tsx index 873dace4..6f2923ef 100644 --- a/renderer/components/DNav.tsx +++ b/renderer/components/DNav.tsx @@ -7,27 +7,19 @@ import { StatusLabel } from './StatusLabel'; import { TooltipButton } from './TooltipButton'; import { VERSION } from '../consts'; import { robotState } from '../utils/utils'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; interface StateProps { - runtimeVersion: string; codeStatus: number; - heart: boolean; - blueMasterTeamNumber: number; - goldMasterTeamNumber: number; - ipAddress: string; - sshAddress: string; + runtimeVersion: string; fieldControlStatus: boolean; latencyValue: number; globalTheme: string; } interface OwnProps { - ipAddress: string; startTour: () => void; - runtimeStatus: boolean; - masterStatus: boolean; - connectionStatus: boolean; - isRunningCode: boolean; } type Props = StateProps & OwnProps; @@ -44,9 +36,11 @@ const DNavComponent = (props: Props) => { const [showUpdateModal, toggleUpdateModal] = useState(false); const [showConfigModal, toggleConfigModal] = useState(false); + const { info, fieldStore, settings } = useStores(); + const createHeader = () => { - if (props.fieldControlStatus) { - return `Dawn FC v${VERSION} ${props.heart ? '+' : '-'}`; + if (fieldStore.fieldControl) { + return `Dawn FC v${VERSION} ${fieldStore.heart ? '+' : '-'}`; } return `Dawn v${VERSION}`; }; @@ -70,102 +64,76 @@ const DNavComponent = (props: Props) => { return `${latency} ms`; }; - const { - connectionStatus, - runtimeStatus, - masterStatus, - isRunningCode, - ipAddress, - sshAddress, - runtimeVersion, - codeStatus, - blueMasterTeamNumber, - goldMasterTeamNumber, - fieldControlStatus, - startTour - } = props; + const { runtimeVersion, codeStatus, fieldControlStatus, startTour } = props; return ( - - toggleUpdateModal(!showUpdateModal)} - /> - toggleConfigModal(!showConfigModal)} - /> - - {createHeader()} - - - - {runtimeStatus ? ( - - {`Runtime v${runtimeVersion}: ${String(robotState[codeStatus])}`} - - ) : ( - '' - )} -
- - - -
- - {`Latency: ${formatLatencyValue(props.latencyValue)}`} - - {/* Adding ml-auto aligns the nav bar to the right */} - - - - - toggleConfigModal(!showConfigModal)} - id="update-address-button" - icon="exchange-alt" - disabled={false} - /> - toggleUpdateModal(!showUpdateModal)} - disabled={!runtimeStatus} - id="update-software-button" - icon="cloud-upload-alt" - /> - - + + {() => ( + + toggleUpdateModal(!showUpdateModal)} /> + toggleConfigModal(!showConfigModal)} /> + + {createHeader()} + + + + {info.runtimeStatus ? ( + + {`Runtime v${runtimeVersion}: ${String(robotState[codeStatus])}`} + + ) : ( + '' + )} +
+ + + +
+ + {`Latency: ${formatLatencyValue(props.latencyValue)}`} + + {/* Adding ml-auto aligns the nav bar to the right */} + + + + + toggleConfigModal(!showConfigModal)} + id="update-address-button" + icon="exchange-alt" + disabled={false} + /> + toggleUpdateModal(!showUpdateModal)} + disabled={!info.runtimeStatus} + id="update-software-button" + icon="cloud-upload-alt" + /> + + + +
-
-
+ )} + ); }; diff --git a/renderer/components/Dashboard.tsx b/renderer/components/Dashboard.tsx index 885db71e..e52e83db 100644 --- a/renderer/components/Dashboard.tsx +++ b/renderer/components/Dashboard.tsx @@ -4,27 +4,33 @@ import { Step } from 'react-joyride'; import PeripheralList from './PeripheralList'; import { GamepadList } from './GamepadList'; import { EditorContainer } from './EditorContainer'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; interface StateProps { addSteps: (steps: Array) => void; - connectionStatus: boolean; - runtimeStatus: boolean; isRunningCode: boolean; // Currently not provided by runtime, and not used in Editor } //smPush={8} and smPull={4} straight up removed -export const Dashboard = (props: StateProps) => ( +export const Dashboard = (props: StateProps) => { + + const {info} = useStores(); + + return( + {()=> - + - + - -); + } + ) +}; diff --git a/renderer/components/Dummy.tsx b/renderer/components/Dummy.tsx new file mode 100644 index 00000000..7e35c31e --- /dev/null +++ b/renderer/components/Dummy.tsx @@ -0,0 +1,10 @@ +import { Observer } from 'mobx-react'; +import React from 'react'; +import { useStores } from '../hooks'; + +// Use as an example of how to use the useStores hook - will delete later +export const Dummy = () => { + const { console } = useStores(); + + return {() =>
{console.consoleUnread}
}
+} \ No newline at end of file diff --git a/renderer/components/Editor.tsx b/renderer/components/Editor.tsx index b898e931..38194b0e 100644 --- a/renderer/components/Editor.tsx +++ b/renderer/components/Editor.tsx @@ -16,6 +16,7 @@ import { Ace } from 'ace-builds'; import { remote } from 'electron'; import storage from 'electron-json-storage'; import _ from 'lodash'; +import { Observer } from 'mobx-react'; // React-ace extensions and modes import 'ace-builds/src-noconflict/ext-language_tools'; @@ -36,7 +37,7 @@ import 'ace-builds/src-noconflict/theme-terminal'; import { ConsoleOutput } from './ConsoleOutput'; import { TooltipButton } from './TooltipButton'; import { AUTOCOMPLETION_LIST, MAX_FONT_SIZE, MIN_FONT_SIZE, ROBOT_STAFF_CODE } from '../consts'; -import { useConsole, useFontResizer, useKeyboardMode } from '../hooks'; +import { useConsole, useFontResizer, useKeyboardMode, useStores } from '../hooks'; import { correctText, pathToName, robotState, logging, windowInfo } from '../utils/utils'; const { dialog } = remote; @@ -77,7 +78,7 @@ interface OwnProps { onInitiateLatencyCheck: () => void; } -type Props = StateProps & OwnProps; +type Props = {}; // StateProps & OwnProps; const FONT_SIZES = [8, 12, 14, 16, 20, 24, 28]; @@ -109,9 +110,11 @@ export const Editor = (props: Props) => { submitFontSize } = useFontResizer(); + const { editor, settings } = useStores(); + const { isKeyboardModeToggled, toggleKeyboardControl } = useKeyboardMode({ - onUpdateKeyboardBitmap: props.onUpdateKeyboardBitmap, - onUpdateKeyboardModeToggle: props.onUpdateKeyboardModeToggle + onUpdateKeyboardBitmap: editor.updateKeyboardBitmap, + onUpdateKeyboardModeToggle: editor.updateIsKeyboardModeToggled }); let CodeEditor: AceEditor; @@ -149,14 +152,15 @@ export const Editor = (props: Props) => { if (err) { logging.log(err); } else if (!_.isEmpty(data)) { - props.onChangeTheme(data.theme ?? 'github'); + settings.changeTheme(data.theme ?? 'github'); } }); - function beforeUnload(event: any) { + function beforeUnload(event: BeforeUnloadEvent) { // If there are unsaved changes and the user tries to close Dawn, // check if they want to save their changes first. if (hasUnsavedChanges()) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises dialog .showMessageBox(currentWindow, { type: 'warning', @@ -173,7 +177,7 @@ export const Editor = (props: Props) => { if (clickedId.response === 0) { // FIXME: Figure out a way to make Save and Close, well, close. event.returnValue = false; - props.onSaveFile(); + editor.saveFile(); // TODO: figure out how to remove promise dependency } else if (clickedId.response === 2) { event.returnValue = false; } @@ -190,7 +194,7 @@ export const Editor = (props: Props) => { window.addEventListener('drop', (e: DragEvent) => { e.preventDefault(); - props.onDragFile(e.dataTransfer?.files?.[0].path ?? ''); + editor.dragFile(e.dataTransfer?.files?.[0].path ?? ''); return false; }); @@ -201,7 +205,7 @@ export const Editor = (props: Props) => { }, []); const checkLatency = () => { - props.onInitiateLatencyCheck(); + editor.initiateLatencyCheck.run(); }; const insertRobotStaffCode = () => { @@ -270,11 +274,15 @@ export const Editor = (props: Props) => { return ( - - - Editing: {pathToName(props.filepath) ? pathToName(props.filepath) : '[ New File ]'} {changeMarker} - - + + {() => ( + + + Editing: {pathToName(editor.filepath.get()) ? pathToName(editor.filepath.get()) : '[ New File ]'} {changeMarker} + + + )} +
@@ -301,7 +309,7 @@ export const Editor = (props: Props) => { icon="play" disabled={isRunning || !props.runtimeStatus || props.fieldControlActivity} /> - + { editorProps={{ $blockScrolling: Infinity }} readOnly={isKeyboardModeToggled} /> - + ); }; + +const FileOperationButtons = () => { + const { alert, editor, info, settings } = useStores(); + + const upload = () => { + const filepath = editor.filepath.get(); + + if (filepath === '') { + alert.addAsyncAlert('Not Working on a File', 'Please save first'); + logging.log('Upload: Not Working on File'); + return; + } + if (hasUnsavedChanges()) { + alert.addAsyncAlert('Unsaved File', 'Please save first'); + logging.log('Upload: Not Working on Saved File'); + return; + } + if (correctText(props.editorCode) !== props.editorCode) { + props.onAlertAdd( + 'Invalid characters detected', + "Your code has non-ASCII characters, which won't work on the robot. " + 'Please remove them and try again.' + ); + logging.log('Upload: Non-ASCII Issue'); + return; + } + + props.onUploadCode(); + }; + + return ( + + {() => ( + + + editor.openFile('create')}>New File + editor.openFile('open')}>Open + editor.saveFile()}>Save + editor.saveFile({ saveAs: true })}>Save As + + + editor.transferStudentCode.run('download')} + icon="arrow-circle-down" + disabled={!info.runtimeStatus.get()} + /> + + )} + + ); +}; diff --git a/renderer/components/GamepadList.tsx b/renderer/components/GamepadList.tsx index 8329428d..7eb800c5 100644 --- a/renderer/components/GamepadList.tsx +++ b/renderer/components/GamepadList.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { Card, ListGroup } from 'react-bootstrap'; import { connect } from 'react-redux'; import _ from 'lodash'; import { Gamepad } from './Gamepad'; import { Input } from '../../protos-main'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; interface StateProps { gamepads: Input[] | undefined; @@ -13,42 +15,43 @@ interface StateProps { type Props = StateProps; const GamepadListComponent = (props: Props) => { - let interior; + const { settings } = useStores(); + + let interior: ReactNode; + if (_.some(props.gamepads, (gamepad: Input) => gamepad !== undefined)) { - interior = _.map( - props.gamepads, - (gamepad: Input, index: string) => , - ); + interior = _.map(props.gamepads, (gamepad: Input, index: string) => ( + + )); } else { interior = ( -

- There doesn't seem to be any gamepads connected. - Connect a gamepad and press any button on it. -

+

There doesn't seem to be any gamepads connected. Connect a gamepad and press any button on it.

); } + return ( - - Gamepads - - - {interior} - - - + + {() => ( + + Gamepads + + {interior} + + + )} + ); }; const mapStateToProps = (state: ApplicationState) => ({ gamepads: state.gamepads.gamepads, - globalTheme: state.settings.globalTheme, + globalTheme: state.settings.globalTheme }); export const GamepadList = connect(mapStateToProps)(GamepadListComponent); - diff --git a/renderer/components/PeripheralList.tsx b/renderer/components/PeripheralList.tsx index 75aaeb9f..0e8c5337 100644 --- a/renderer/components/PeripheralList.tsx +++ b/renderer/components/PeripheralList.tsx @@ -5,14 +5,11 @@ import { Card, CardGroup, ListGroup } from 'react-bootstrap'; import { Peripheral, PeripheralList } from '../types'; import { connect } from 'react-redux'; import PeripheralGroup from './PeripheralGroup'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; // const filter = new Set(); -interface OwnProps { - connectionStatus: boolean; - runtimeStatus: boolean; -} - interface StateProps { peripheralList: PeripheralList; globalTheme: string; @@ -46,12 +43,14 @@ const handleAccordion = (devices: Peripheral[]) => { }); }; -const PeripheralListComponent = (props: StateProps & OwnProps) => { +const PeripheralListComponent = (props: StateProps) => { + const {info, settings} = useStores(); + let errorMsg = null; - if (!props.connectionStatus) { + if (!info.connectionStatus) { errorMsg = 'You are currently disconnected from the robot.'; - } else if (!props.runtimeStatus) { + } else if (!info.runtimeStatus) { errorMsg = 'There appears to be some sort of General error. No data is being received.'; } @@ -63,17 +62,18 @@ const PeripheralListComponent = (props: StateProps & OwnProps) => { } return ( + {() => Peripherals {panelBody} - + } ); }; diff --git a/renderer/components/StatusLabel.tsx b/renderer/components/StatusLabel.tsx index 25e89c3e..ecaaf54f 100644 --- a/renderer/components/StatusLabel.tsx +++ b/renderer/components/StatusLabel.tsx @@ -2,44 +2,42 @@ import React from 'react'; import { Badge } from 'react-bootstrap'; import { connect } from 'react-redux'; import numeral from 'numeral'; +import { useStores } from '../hooks'; +import { Observer } from 'mobx-react'; interface StateProps { - connectionStatus: boolean; - runtimeStatus: boolean; - masterStatus: boolean; batteryLevel?: number; batterySafety?: boolean; - blueMasterTeamNumber: number; - goldMasterTeamNumber: number; - ipAddress: string; } interface OwnProps { fieldControlStatus?: boolean; - masterStatus: boolean; } type Props = StateProps & OwnProps; const StatusLabelComponent = (props: Props) => { + + const {info, fieldStore} = useStores(); + let labelStyle = 'default'; let labelText = 'Disconnected'; const masterRobotHeader = 'Master Robot: Team '; - const teamIP = props.ipAddress.substring(props.ipAddress.length - 2, props.ipAddress.length); + const teamIP = info.ipAddress.substring(info.ipAddress.length - 2, info.ipAddress.length); const shouldDisplayMaster = (teamNumber: number) => parseInt(teamIP, 10) === teamNumber && props.fieldControlStatus; let masterRobot = null; let masterRobotStyle = ' '; - if (shouldDisplayMaster(props.blueMasterTeamNumber)) { - masterRobot = props.blueMasterTeamNumber; + if (shouldDisplayMaster(fieldStore.blueMasterTeamNumber)) { + masterRobot = fieldStore.blueMasterTeamNumber; masterRobotStyle = 'primary'; - } else if (shouldDisplayMaster(props.goldMasterTeamNumber)) { - masterRobot = props.goldMasterTeamNumber; + } else if (shouldDisplayMaster(fieldStore.goldMasterTeamNumber)) { + masterRobot =fieldStore.goldMasterTeamNumber; masterRobotStyle = 'warning'; } - if (props.connectionStatus) { - if (!props.runtimeStatus) { + if (info.connectionStatus) { + if (!info.runtimeStatus) { labelStyle = 'danger'; labelText = 'General Error'; } else if (props.batterySafety) { diff --git a/renderer/components/TooltipButton.tsx b/renderer/components/TooltipButton.tsx index 8957d032..9ba55042 100644 --- a/renderer/components/TooltipButton.tsx +++ b/renderer/components/TooltipButton.tsx @@ -10,6 +10,7 @@ import { IconLookup } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Observer } from 'mobx-react'; interface StateProp{ id: string; diff --git a/renderer/components/UpdateBox.tsx b/renderer/components/UpdateBox.tsx index a28bc24c..fe8d83b4 100644 --- a/renderer/components/UpdateBox.tsx +++ b/renderer/components/UpdateBox.tsx @@ -1,20 +1,14 @@ import { OpenDialogReturnValue, remote } from 'electron'; +import { Observer } from 'mobx-react'; import React, { useState } from 'react'; import { Modal, Button } from 'react-bootstrap'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { Client, SFTPWrapper } from 'ssh2'; import { addAsyncAlert } from '../actions/AlertActions'; +import { useStores } from '../hooks'; import { defaults, logging } from '../utils/utils'; -interface StateProps { - connectionStatus: boolean; - runtimeStatus: boolean; - masterStatus: boolean; - isRunningCode: boolean; - ipAddress: string; -} - interface DispatchProps { onAlertAdd: (heading: string, message: string) => void; } @@ -24,12 +18,14 @@ interface OwnProps { hide: () => void; } -type Props = StateProps & DispatchProps & OwnProps; +type Props = DispatchProps & OwnProps; export function UpdateBoxContainer(props: Props) { const [isUploading, changeIsUploading] = useState(false); const [updateFilepath, changeUpdateFilepath] = useState(''); + const {info, alert} = useStores(); + const chooseUpdate = () => { remote.dialog .showOpenDialog({ @@ -59,17 +55,17 @@ export function UpdateBoxContainer(props: Props) { changeIsUploading(false); props.hide(); if (err2) { - props.onAlertAdd( - 'Robot Connectivity Error', - `Dawn was unable to upload the update to the robot. - Please check your connectivity, or try restarting the robot.` + alert.addAsyncAlert( + {heading:'Robot Connectivity Error', + message: `Dawn was unable to upload the update to the robot. + Please check your connectivity, or try restarting the robot.`} ); logging.log(err2); } else { - props.onAlertAdd( - 'Robot Update Initiated', - `Update is installing and Runtime will restart soon. - Please leave your robot on for the next 1 minute.` + alert.addAsyncAlert( + {heading: 'Robot Update Initiated', + message:`Update is installing and Runtime will restart soon. + Please leave your robot on for the next 1 minute.`} ); } }); @@ -80,7 +76,7 @@ export function UpdateBoxContainer(props: Props) { debug: (debugInfo: string) => { logging.log(debugInfo); }, - host: props.ipAddress, + host: info.ipAddress, port: defaults.PORT, username: defaults.USERNAME, password: defaults.PASSWORD @@ -88,7 +84,7 @@ export function UpdateBoxContainer(props: Props) { }; const disableUploadUpdate = () => { - return !updateFilepath || isUploading || !(props.connectionStatus && props.runtimeStatus) || props.isRunningCode; + return !updateFilepath || isUploading || !(info.connectionStatus && info.runtimeStatus) || info.isRunningCode; }; const { shouldShow, hide } = props; diff --git a/renderer/components/editor/ConsoleButtons.tsx b/renderer/components/editor/ConsoleButtons.tsx new file mode 100644 index 00000000..c778e7e8 --- /dev/null +++ b/renderer/components/editor/ConsoleButtons.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { ButtonGroup, DropdownButton, Dropdown } from 'react-bootstrap'; +import { Observer } from 'mobx-react'; + +import { TooltipButton } from '../TooltipButton'; +import { useStores } from '../../hooks'; +import { getRobotStateReadableString, RobotState } from '../../utils/utils'; + +export const ConsoleButtons = () => { + const { info, settings } = useStores(); + + return ( + + {() => ( + + + + windowInfo.CONSOLEMAX} + /> + + + + )} + + ); +}; diff --git a/renderer/components/editor/ControlRobotStateAndCodeButtons.tsx b/renderer/components/editor/ControlRobotStateAndCodeButtons.tsx new file mode 100644 index 00000000..b30d8966 --- /dev/null +++ b/renderer/components/editor/ControlRobotStateAndCodeButtons.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { ButtonGroup, DropdownButton, Dropdown } from 'react-bootstrap'; +import { Observer } from 'mobx-react'; + +import { TooltipButton } from '../TooltipButton'; +import { ROBOT_STAFF_CODE } from '../../consts'; +import { useStores } from '../../hooks'; +import { getRobotStateReadableString, RobotState } from '../../utils/utils'; + +export const ControlRobotStateAndCodeButtons = () => { + const { editor, info, settings } = useStores(); + + const [isRobotRunning, setIsRobotRunning] = useState(false); + const [mode, setMode] = useState(RobotState.TELEOP); + + const startRobot = () => { + setIsRobotRunning(true); + info.updateStudentCodeStatus(mode); + }; + + const stopRobot = () => { + setIsRobotRunning(false); + info.updateStudentCodeStatus(RobotState.IDLE); + }; + + const insertRobotStaffCode = () => { + editor.updateEditorCode(ROBOT_STAFF_CODE); + }; + + const onImportStaffCodeButtonClick = () => { + if (editor.editorCode.get() === editor.latestSaveCode.get()) { + insertRobotStaffCode(); + + return; + } + + const shouldOverwriteExistingCode = window.confirm( + 'You currently have unsaved changes. Do you really want to overwrite your code with Staff Code?' + ); + + if (shouldOverwriteExistingCode) { + insertRobotStaffCode(); + } + }; + + return ( + + {() => ( + + + + + { + setMode(RobotState.AUTONOMOUS); + }} + > + Autonomous + + { + setMode(RobotState.TELEOP); + }} + > + Tele-Operated + + + + + )} + + ); +}; diff --git a/renderer/components/editor/FileOperationsButtons.tsx b/renderer/components/editor/FileOperationsButtons.tsx new file mode 100644 index 00000000..8c458db3 --- /dev/null +++ b/renderer/components/editor/FileOperationsButtons.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { ButtonGroup, DropdownButton, Dropdown } from 'react-bootstrap'; +import { Observer } from 'mobx-react'; + +import { TooltipButton } from '../TooltipButton'; +import { useStores } from '../../hooks'; +import { correctText, logging } from '../../utils/utils'; + +export const FileOperationButtons = () => { + const { alert, editor, info, settings } = useStores(); + + const upload = () => { + const filepath = editor.filepath.get(); + + const hasUnsavedChanges = editor.latestSaveCode.get() !== editor.editorCode.get(); + + if (filepath === '') { + alert.addAsyncAlert('Not Working on a File', 'Please save first'); + logging.log('Upload: Not Working on File'); + + return; + } + + if (hasUnsavedChanges) { + alert.addAsyncAlert('Unsaved File', 'Please save first'); + logging.log('Upload: Not Working on Saved File'); + + return; + } + + if (correctText(editor.editorCode.get()) !== editor.editorCode.get()) { + alert.addAsyncAlert( + 'Invalid characters detected', + "Your code has non-ASCII characters, which won't work on the robot. " + 'Please remove them and try again.' + ); + logging.log('Upload: Non-ASCII Issue'); + + return; + } + + editor.transferStudentCode.run('upload'); + }; + + return ( + + {() => ( + + + editor.openFile('create')}>New File + editor.openFile('open')}>Open + editor.saveFile()}>Save + editor.saveFile({ saveAs: true })}>Save As + + + editor.transferStudentCode.run('download')} + icon="arrow-circle-down" + disabled={!info.runtimeStatus.get()} + /> + + )} + + ); +}; diff --git a/renderer/components/editor/FontSizeButtons.tsx b/renderer/components/editor/FontSizeButtons.tsx new file mode 100644 index 00000000..ef1f8166 --- /dev/null +++ b/renderer/components/editor/FontSizeButtons.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { ButtonGroup, DropdownButton, Dropdown, InputGroup, FormControl, OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { Observer } from 'mobx-react'; + +import { TooltipButton } from '../TooltipButton'; +import { MAX_FONT_SIZE, MIN_FONT_SIZE } from '../../consts'; +import { useFontResizer } from '../../hooks'; + +const FONT_SIZES = [8, 12, 14, 16, 20, 24, 28]; + +export const FontSizeButtons = () => { + const { + onChangeFontSize, + submittedFontSize, + decreaseFontsize, + increaseFontsize, + handleChangeFontsize, + handleOnBlurFontSize, + handleOnKeyDownFontSize, + submitFontSize + } = useFontResizer(); + + return ( + <> + + + Text Size}> + + {FONT_SIZES.map((fontSize: number) => ( + submitFontSize(fontSize)}> + {fontSize} + + ))} + + + + + = MAX_FONT_SIZE} + /> + + + + ); +}; diff --git a/renderer/components/editor/index.tsx b/renderer/components/editor/index.tsx new file mode 100644 index 00000000..8641c6b7 --- /dev/null +++ b/renderer/components/editor/index.tsx @@ -0,0 +1,336 @@ +import React, { useEffect, useState } from 'react'; +import { + Card, + ButtonGroup, + DropdownButton, + FormGroup, + FormControl, + Form, + InputGroup, + OverlayTrigger, + Tooltip, + Dropdown +} from 'react-bootstrap'; +import AceEditor from 'react-ace'; +import { Ace } from 'ace-builds'; +import { remote } from 'electron'; +import storage from 'electron-json-storage'; +import _ from 'lodash'; +import { Observer } from 'mobx-react'; + +// React-ace extensions and modes +import 'ace-builds/src-noconflict/ext-language_tools'; +import 'ace-builds/src-noconflict/ext-searchbox'; +import 'ace-builds/src-noconflict/mode-python'; +// React-ace themes +import 'ace-builds/src-noconflict/theme-monokai'; +import 'ace-builds/src-noconflict/theme-github'; +import 'ace-builds/src-noconflict/theme-tomorrow'; +import 'ace-builds/src-noconflict/theme-kuroir'; +import 'ace-builds/src-noconflict/theme-twilight'; +import 'ace-builds/src-noconflict/theme-xcode'; +import 'ace-builds/src-noconflict/theme-textmate'; +import 'ace-builds/src-noconflict/theme-solarized_dark'; +import 'ace-builds/src-noconflict/theme-solarized_light'; +import 'ace-builds/src-noconflict/theme-terminal'; + +import { ControlRobotStateAndCodeButtons } from './ControlRobotStateAndCodeButtons'; +import { FileOperationButtons } from './FileOperationsButtons'; +import { FontSizeButtons } from './FontSizeButtons'; +import { ConsoleOutput } from '../ConsoleOutput'; +import { TooltipButton } from '../TooltipButton'; +import { AUTOCOMPLETION_LIST, MAX_FONT_SIZE, MIN_FONT_SIZE, ROBOT_STAFF_CODE } from '../../consts'; +import { useConsole, useFontResizer, useKeyboardMode, useStores } from '../../hooks'; +import { correctText, pathToName, robotState, logging, windowInfo } from '../../utils/utils'; + +const { dialog } = remote; +const currentWindow = remote.getCurrentWindow(); + +interface StateProps { + editorTheme: string; + editorCode: string; + latestSaveCode: string; + filepath: string; + fontSize: number; + showConsole: boolean; + consoleData: string[]; + runtimeStatus: boolean; + fieldControlActivity: boolean; + disableScroll: boolean; + consoleUnread: boolean; + latencyValue: number; + globalTheme: string; +} + +interface OwnProps { + onAlertAdd: (heading: string, message: string) => void; + onEditorUpdate: (newVal: string) => void; + onSaveFile: (saveAs?: boolean) => void; + onDragFile: (filepath: string) => void; + onOpenFile: () => void; + onCreateNewFile: () => void; + onChangeTheme: (theme: string) => void; + onChangeFontsize: (newFontsize: number) => void; + toggleConsole: () => void; + onClearConsole: () => void; + onUpdateCodeStatus: (status: number) => void; + onDownloadCode: () => void; + onUploadCode: () => void; + onUpdateKeyboardBitmap: (keyboardBitmap: number) => void; + onUpdateKeyboardModeToggle: (isKeyboardToggled: boolean) => void; + onInitiateLatencyCheck: () => void; +} + +type Props = {}; // StateProps & OwnProps; + +const FONT_SIZES = [8, 12, 14, 16, 20, 24, 28]; + +export const Editor = (props: Props) => { + const [editorHeight, setEditorHeight] = useState('0px'); + const [mode, setMode] = useState(robotState.TELEOP); + const [modeDisplay, setModeDisplay] = useState(robotState.TELEOPSTR); + const [isRunning, setIsRunning] = useState(false); + + const onConsoleToggle = () => { + // Resize since the console overlaps with the editor, but enough time for console changes + setTimeout(() => onWindowResize(), 0.01); + }; + + const { consoleData, isConsoleOpen, isConsoleUnread, consoleHeight, toggleConsole, raiseConsole, lowerConsole, copyConsole } = useConsole( + { + onToggled: onConsoleToggle + } + ); + + const { + onChangeFontSize, + submittedFontSize, + decreaseFontsize, + increaseFontsize, + handleChangeFontsize, + handleOnBlurFontSize, + handleOnKeyDownFontSize, + submitFontSize + } = useFontResizer(); + + const { editor, settings } = useStores(); + + const { isKeyboardModeToggled, toggleKeyboardControl } = useKeyboardMode({ + onUpdateKeyboardBitmap: editor.updateKeyboardBitmap, + onUpdateKeyboardModeToggle: editor.updateIsKeyboardModeToggled + }); + + let CodeEditor: AceEditor; + const themes: string[] = [ + 'monokai', + 'github', + 'tomorrow', + 'kuroir', + 'twilight', + 'xcode', + 'textmate', + 'solarized_dark', + 'solarized_light', + 'terminal' + ]; + + /* + * Confirmation Dialog on Quit, Stored Editor Settings, Window Size-Editor Re-render + */ + useEffect(() => { + CodeEditor.editor.setOptions({ + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }); + const autoComplete = { + getCompletions(_editor: Ace.Editor, _session: Ace.EditSession, _pos: Ace.Point, _prefix: string, callback: Ace.CompleterCallback) { + callback(null, AUTOCOMPLETION_LIST); + } + }; + CodeEditor.editor.completers = [autoComplete]; + + onWindowResize(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storage.get('editorTheme', (err: any, data: { theme?: string }) => { + if (err) { + logging.log(err); + } else if (!_.isEmpty(data)) { + settings.changeTheme(data.theme ?? 'github'); + } + }); + + function beforeUnload(event: BeforeUnloadEvent) { + // If there are unsaved changes and the user tries to close Dawn, + // check if they want to save their changes first. + if (hasUnsavedChanges()) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + dialog + .showMessageBox(currentWindow, { + type: 'warning', + buttons: ['Save...', "Don't Save", 'Cancel'], + defaultId: 0, + cancelId: 2, + title: 'You have unsaved changes!', + message: 'Do you want to save the changes made to your program?', + detail: "Your changes will be lost if you don't save them." + }) + // NOTE: For whatever reason, `event.preventDefault()` does not work within + // beforeunload events, so we use `event.returnValue = false` instead. + .then((clickedId) => { + if (clickedId.response === 0) { + // FIXME: Figure out a way to make Save and Close, well, close. + event.returnValue = false; + editor.saveFile(); // TODO: figure out how to remove promise dependency + } else if (clickedId.response === 2) { + event.returnValue = false; + } + }); + } + } + + window.addEventListener('beforeunload', beforeUnload); + window.addEventListener('resize', onWindowResize, { passive: true }); + window.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + return false; + }); + + window.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + editor.dragFile(e.dataTransfer?.files?.[0].path ?? ''); + return false; + }); + + return () => { + window.removeEventListener('beforeunload', beforeUnload); + window.removeEventListener('resize', onWindowResize); + }; + }, []); + + const checkLatency = () => { + editor.initiateLatencyCheck.run(); + }; + + const insertRobotStaffCode = () => { + props.onEditorUpdate(ROBOT_STAFF_CODE); + }; + + const onWindowResize = () => { + // Trigger editor to re-render on window resizing. + const windowNonEditorHeight = windowInfo.NONEDITOR + +!!isConsoleOpen * (consoleHeight + windowInfo.CONSOLEPAD); + const newEditorHeight = `${window.innerHeight - windowNonEditorHeight}px`; + + setEditorHeight(newEditorHeight); + }; + + const hasUnsavedChanges = () => { + return props.latestSaveCode !== props.editorCode; + }; + + const changeTheme = (theme: string) => { + props.onChangeTheme(theme); + storage.set('editorTheme', { theme }, (err: any) => { + if (err) logging.log(err); + }); + }; + + useEffect(() => { + onWindowResize(); + }, [consoleHeight, onWindowResize]); + + const changeMarker = hasUnsavedChanges() ? '*' : ''; + + return ( + + {() => ( + + + {() => ( + + + Editing: {pathToName(editor.filepath.get()) ? pathToName(editor.filepath.get()) : '[ New File ]'} {changeMarker} + + + )} + + + + + + + + + windowInfo.CONSOLEMAX} + /> + + + + + + + {themes.map((theme: string) => ( + + {theme} + + ))} + + + + + + + {() => ( + { + CodeEditor = input; + }} + name="CodeEditor" + height={editorHeight} + value={editor.editorCode.get()} + onChange={editor.updateEditorCode} + editorProps={{ $blockScrolling: Infinity }} + readOnly={isKeyboardModeToggled} + /> + )} + + + + + )} + + ); +}; diff --git a/renderer/contexts/index.ts b/renderer/contexts/index.ts new file mode 100644 index 00000000..a8c69102 --- /dev/null +++ b/renderer/contexts/index.ts @@ -0,0 +1 @@ +export { storesContext } from './stores'; diff --git a/renderer/contexts/stores.ts b/renderer/contexts/stores.ts new file mode 100644 index 00000000..8869d286 --- /dev/null +++ b/renderer/contexts/stores.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { RootStore } from '../stores'; + +export const storesContext = React.createContext(RootStore); diff --git a/renderer/hooks/index.ts b/renderer/hooks/index.ts index 07554856..2a6beaf7 100644 --- a/renderer/hooks/index.ts +++ b/renderer/hooks/index.ts @@ -1 +1,2 @@ +export { useStores } from './useStores'; export * from './editor'; diff --git a/renderer/hooks/useStores.ts b/renderer/hooks/useStores.ts new file mode 100644 index 00000000..456a3f1c --- /dev/null +++ b/renderer/hooks/useStores.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { storesContext } from '../contexts'; + +export const useStores = () => React.useContext(storesContext); diff --git a/renderer/reducers/info.ts b/renderer/reducers/info.ts index 88c04d27..8348e04f 100644 --- a/renderer/reducers/info.ts +++ b/renderer/reducers/info.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron'; +import { ipcChannels } from '../../shared'; import { robotState, defaults } from '../utils/utils'; import * as consts from '../consts'; import { @@ -10,7 +11,7 @@ import { IpChangeAction, SSHIpChangeAction, UpdateRobotAction, - NotificationChangeAction, + NotificationChangeAction } from '../types'; type Actions = @@ -47,7 +48,7 @@ const initialInfoState = { masterStatus: false, notificationHold: 0, fieldControlDirective: robotState.TELEOP, - fieldControlActivity: false, + fieldControlActivity: false }; export const info = (state: InfoState = initialInfoState, action: Actions): InfoState => { @@ -55,56 +56,56 @@ export const info = (state: InfoState = initialInfoState, action: Actions): Info case consts.InfoActionsTypes.PER_MESSAGE: return { ...state, - connectionStatus: true, + connectionStatus: true }; case consts.InfoActionsTypes.NOTIFICATION_CHANGE: return { ...state, - notificationHold: action.notificationHold, + notificationHold: action.notificationHold }; case consts.InfoActionsTypes.RUNTIME_CONNECT: return { ...state, - runtimeStatus: true, + runtimeStatus: true }; case consts.InfoActionsTypes.RUNTIME_DISCONNECT: return { ...state, runtimeStatus: false, connectionStatus: false, - studentCodeStatus: robotState.IDLE, + studentCodeStatus: robotState.IDLE }; case consts.InfoActionsTypes.MASTER_ROBOT: return { ...state, - masterStatus: true, + masterStatus: true }; case consts.InfoActionsTypes.CODE_STATUS: - ipcRenderer.send('runModeUpdate', { mode: action.studentCodeStatus }); + ipcRenderer.send(ipcChannels.RUN_MODE_UPDATE, { mode: action.studentCodeStatus }); return { ...state, - studentCodeStatus: action.studentCodeStatus, + studentCodeStatus: action.studentCodeStatus }; case consts.InfoActionsTypes.IP_CHANGE: - ipcRenderer.send('ipAddress', action.ipAddress); + ipcRenderer.send(ipcChannels.IP_ADDRESS, action.ipAddress); return { ...state, - ipAddress: action.ipAddress, + ipAddress: action.ipAddress }; case consts.InfoActionsTypes.SSH_IP_CHANGE: return { ...state, sshAddress: action.ipAddress - } + }; case consts.FieldActionsTypes.UPDATE_ROBOT: { - const stateChange = (action.autonomous) ? robotState.AUTONOMOUS : robotState.TELEOP; - const codeStatus = (!action.enabled) ? robotState.IDLE : stateChange; + const stateChange = action.autonomous ? robotState.AUTONOMOUS : robotState.TELEOP; + const codeStatus = !action.enabled ? robotState.IDLE : stateChange; return { ...state, fieldControlDirective: stateChange, fieldControlActivity: action.enabled, // eslint-disable-next-line no-nested-ternary - studentCodeStatus: codeStatus, + studentCodeStatus: codeStatus }; } default: diff --git a/renderer/stores/alert.ts b/renderer/stores/alert.ts new file mode 100644 index 00000000..3f0d75d4 --- /dev/null +++ b/renderer/stores/alert.ts @@ -0,0 +1,35 @@ +import { IObservableArray, observable } from 'mobx'; +import { RootStore } from './root'; +import seedrandom from 'seedrandom'; + +interface AsyncAlert { + id?: number; + heading: string; + message: string; +} + +const rng = seedrandom('alertseed'); +export class AlertStore { + rootStore: typeof RootStore; + + alertState: IObservableArray = observable.array([], { deep: false }); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + addAsyncAlert = (heading: string, message: string) => { + this.alertState.replace([ + ...this.alertState, + { + id: rng.int32(), + heading: heading, + message: message + } + ]); + }; + + removeAsyncAlert = (id: number) => { + this.alertState.filter((el: AsyncAlert) => el.id !== id); + }; +} diff --git a/renderer/stores/console.ts b/renderer/stores/console.ts new file mode 100644 index 00000000..f28dda51 --- /dev/null +++ b/renderer/stores/console.ts @@ -0,0 +1,29 @@ +import { IObservableArray, observable } from 'mobx'; +import { RootStore } from './root'; + +export class ConsoleStore { + rootStore: typeof RootStore; + + showConsole = observable.box(false); + consoleData: IObservableArray = observable.array([], { deep: false }); + disableScroll = observable.box(false); + consoleUnread = observable.box(false); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + updateConsole = (value: string[]) => { + this.consoleData.replace([...this.consoleData, ...value]); + this.consoleUnread.set(!this.consoleUnread.get()); + }; + + clearConsole = () => { + this.consoleData.clear(); + }; + + toggleConsole = () => { + this.showConsole.set(!this.showConsole.get()); + this.consoleUnread.set(false); + }; +} diff --git a/renderer/stores/editor.ts b/renderer/stores/editor.ts new file mode 100644 index 00000000..3f6c86f9 --- /dev/null +++ b/renderer/stores/editor.ts @@ -0,0 +1,282 @@ +import { ipcRenderer, remote } from 'electron'; +import fs from 'fs'; +import { IObservableValue, observable } from 'mobx'; +import { Client, SFTPWrapper } from 'ssh2'; +import { Input, Source, TimeStamps } from '../../protos-main'; +import { defaults, logging, sleep } from '../utils/utils'; +import { RootStore } from './root'; +import { + createErrorCallback, + createTask, + openFileDialog, + saveFileDialog, + transferFile, + openUnsavedFileDialog, + UnsavedDialogActions, + UnsavedDialogButtonOptions +} from './utils'; +import { ipcChannels } from '../../shared'; + +interface SaveFile { + saveAs?: boolean; +} + +export class EditorStore { + rootStore: typeof RootStore; + filepath: IObservableValue = observable.box(''); + latestSaveCode: IObservableValue = observable.box(''); + editorCode: IObservableValue = observable.box(''); + keyboardBitmap: IObservableValue = observable.box(0); + isKeyboardModeToggled: IObservableValue = observable.box(false); + latencyValue: IObservableValue = observable.box(0); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + updateEditorCode = (newEditorCode: string) => { + this.editorCode.set(newEditorCode); + }; + + updateKeyboardBitmap = (keyboardBitmap: number) => { + this.keyboardBitmap.set(keyboardBitmap); + }; + + setLatencyValue = (latencyValue: number) => { + this.latencyValue.set(latencyValue); + }; + + updateIsKeyboardModeToggled = (isKeyboardModeToggled: boolean) => { + this.isKeyboardModeToggled.set(isKeyboardModeToggled); + }; + + // Filesystem for editor + openFileSucceeded = (code: string, filepath: string) => { + this.editorCode.set(code); + this.filepath.set(filepath); + this.latestSaveCode.set(code); + }; + + /** + * Simple helper function to write to a codefile and dispatch action + * notifying store of the save. + */ + writeCodeToFile = (filepath: string, code: string) => { + fs.writeFile(filepath, code, createErrorCallback('writeCodeToFile')); + + this.saveFileSucceeded({ latestSaveCode: code, filepath }); + }; + + saveFile = async (args: SaveFile = {}) => { + const shouldSaveFile = args.saveAs ?? false; + let filepath = this.filepath.get(); + const code = this.editorCode.get(); + // If the action is a "save as" OR there is no filepath (ie, a new file) + // then we open the save file dialog so the user can specify a filename before saving. + if (shouldSaveFile || !this.filepath) { + try { + filepath = await saveFileDialog(); + + if (filepath === undefined) { + return; + } + + this.writeCodeToFile(filepath, code); + } catch (e) { + logging.log('No filename specified, file not saved.'); + } + } else { + this.writeCodeToFile(filepath, code); + } + }; + + openFile = async (action: UnsavedDialogActions) => { + const LOG_PREFIX = 'openFile:'; + + let chosenAction: UnsavedDialogButtonOptions | undefined = 'discardAction'; + + if (this.editorCode.get() !== this.latestSaveCode.get()) { + chosenAction = await openUnsavedFileDialog(action); + + if (chosenAction === undefined) { + return; + } + + if (chosenAction === 'saveAction') { + await this.saveFile({ + saveAs: false + }); + } + } + + switch (chosenAction) { + case 'saveAction': + case 'discardAction': + if (action === 'open') { + try { + const filepath: string = await openFileDialog(); + + fs.readFile(filepath, (error: NodeJS.ErrnoException | null, data: Buffer) => { + if (error === null) { + this.openFileSucceeded(data.toString(), filepath); + } + + console.error(`Error - ${LOG_PREFIX}: ${error}`); + }); + } catch (e) { + logging.log(LOG_PREFIX, 'No filename specified, no file opened.'); + } + } else if (action === 'create') { + this.openFileSucceeded('', ''); + } + break; + + default: + logging.log(LOG_PREFIX, `File ${action} canceled.`); + } + }; + + dragFile = createTask({ + config: { taskId: 'dragFile' }, + implementation: async (filepath: string) => { + const LOG_PREFIX = 'dragFile:'; + + const code = this.editorCode.get(); + const savedCode = this.latestSaveCode.get(); + let chosenAction: UnsavedDialogButtonOptions | undefined = 'discardAction'; + + if (code !== savedCode) { + chosenAction = await openUnsavedFileDialog('open'); + + if (chosenAction === undefined) { + return; + } + + if (chosenAction === 'saveAction') { + await this.saveFile({ + saveAs: false + }); + } + } + + switch (chosenAction) { + case 'saveAction': + case 'discardAction': + try { + fs.readFile(filepath, (error: NodeJS.ErrnoException | null, data: Buffer) => { + if (error === null) { + this.openFileSucceeded(data.toString(), filepath); + } + + console.error(`Error - ${LOG_PREFIX}: ${error}`); + }); + } catch (e) { + logging.log('Failure to Drag File In'); + } + break; + default: { + logging.log('Drag File Operation Canceled'); + } + } + } + }); + + saveFileSucceeded = ({ filepath, latestSaveCode }: { filepath: string; latestSaveCode: string }) => { + this.filepath.set(filepath); + this.latestSaveCode.set(latestSaveCode); + }; + + transferStudentCode = createTask({ + config: { taskId: 'transferStudentCode' }, + implementation: async (transferType: 'download' | 'upload') => { + const isRuntimeConnected = this.rootStore.info.runtimeStatus.get(); + + if (!isRuntimeConnected) { + logging.log(`Runtime not connected - could not ${transferType} student code`); + return; + } + + let port = defaults.PORT; + let ip = this.rootStore.info.ipAddress.get(); + if (ip.includes(':')) { + const split = ip.split(':'); + ip = split[0]; + port = Number(split[1]); + } + + const response = await transferFile({ localFilePath: this.filepath.get(), port, ip, transferType }); + + const transferTypeHumanString = { download: 'Download', upload: 'Upload' }[transferType]; + + switch (response) { + case 'fileTransmissionSuccess': { + // @todo: split between download and upload + this.rootStore.alert.addAsyncAlert(`${transferTypeHumanString} Success`, `File ${transferTypeHumanString}ed Successfully`); + break; + } + case 'sftpError': { + this.rootStore.alert.addAsyncAlert(`${transferTypeHumanString} Issue`, 'SFTP session could not be initiated'); + break; + } + case 'fileTransmissionError': { + this.rootStore.alert.addAsyncAlert(`${transferTypeHumanString} Issue`, 'File failed to be transmitted'); + break; + } + case 'connectionError': { + this.rootStore.alert.addAsyncAlert(`${transferTypeHumanString} Issue`, 'Robot could not be connected'); + break; + } + default: { + this.rootStore.alert.addAsyncAlert(`${transferTypeHumanString} Issue`, 'Unknown Error'); + break; + } + } + + // TODO: need timeout? + // setTimeout(() => { + // conn.end(); + // }, 50); + } + }); + + initiateLatencyCheck = createTask({ + config: { taskId: 'initiateLatencyCheck', refreshIntervalMsec: 5000 }, + implementation: () => { + const time: number = Date.now(); + const timestamps = new TimeStamps({ + dawnTimestamp: time, + runtimeTimestamp: 0 + }); + + ipcRenderer.send(ipcChannels.INITIATE_LATENCY_CHECK, timestamps); + } + }); + + sendKeyboardConnectionStatus = createTask({ + config: { taskId: 'sendKeyboardConnectionStatus' }, + implementation: () => { + const keyboardConnectionStatus = new Input({ + connected: this.isKeyboardModeToggled.get(), + axes: [], + buttons: 0, + source: Source.KEYBOARD + }); + + ipcRenderer.send(ipcChannels.STATE_UPDATE, [keyboardConnectionStatus], Source.KEYBOARD); + } + }); + + sendKeyboardInputs = createTask({ + config: { taskId: 'sendKeyboardInputs' }, + implementation: () => { + const keyboard = new Input({ + connected: true, + axes: [], + buttons: this.keyboardBitmap.get(), + source: Source.KEYBOARD + }); + + ipcRenderer.send(ipcChannels.STATE_UPDATE, [keyboard], Source.KEYBOARD); + } + }); +} diff --git a/renderer/stores/fieldStore.ts b/renderer/stores/fieldStore.ts new file mode 100644 index 00000000..095c3e8e --- /dev/null +++ b/renderer/stores/fieldStore.ts @@ -0,0 +1,52 @@ +import { ipcRenderer } from 'electron'; +import { IObservableArray, IObservableValue, observable } from 'mobx'; +import { FieldControlConfig } from '../types'; +import { RootStore } from './root'; + +export class FieldStore { + rootStore: typeof RootStore; + + stationNumber: IObservableValue = observable.box(4); + bridgeAddress: IObservableValue = observable.box('localhost'); + isFieldControlModeOn: IObservableValue = observable.box(false); + rTeamNumber: IObservableValue = observable.box(0); + rTeamName: IObservableValue = observable.box('Unknown'); + heart: IObservableValue = observable.box(false); + mMatchNumber: IObservableValue = observable.box(0); + mTeamNumbers: IObservableArray = observable.array([0, 0, 0, 0], { deep: false }); + mTeamNames: IObservableArray = observable.array(['Offline', 'Offline', 'Offline', 'Offline'], { deep: false }); + teamNumber: IObservableValue = observable.box(0); + teamColor: IObservableValue = observable.box('Unknown'); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + updateFCConfig(data: FieldControlConfig) { + //Changed into a single object because it is decompiled in the actions + this.stationNumber.set(data.stationNumber); + this.bridgeAddress.set(data.bridgeAddress); + } + + toggleFieldControl = () => { + if (this.isFieldControlModeOn.get()) { + this.isFieldControlModeOn.set(false); + ipcRenderer.send('FC_TEARDOWN'); + } else { + this.isFieldControlModeOn.set(true); + ipcRenderer.send('FC_INITIALIZE'); + } + }; + + updateHeart() { + this.heart.set(!this.heart.get()); + } + + updateMatch(matchNumber: number, teamNumbers: number[], teamNames: string[]) { + this.mMatchNumber.set(matchNumber); + this.mTeamNumbers.replace(teamNumbers); + this.mTeamNames.replace(teamNames); + this.rTeamNumber.set(teamNumbers[this.stationNumber.get()]); + this.rTeamName.set(teamNames[this.stationNumber.get()]); + } +} diff --git a/renderer/stores/gamepads.ts b/renderer/stores/gamepads.ts new file mode 100644 index 00000000..fa3ceb01 --- /dev/null +++ b/renderer/stores/gamepads.ts @@ -0,0 +1,88 @@ +import { makeAutoObservable } from 'mobx'; +import { RootStore } from './root'; +import { Input, Source } from '../../protos-main'; +import { ipcChannels } from '../../shared'; +import _ from 'lodash'; +import { ipcRenderer } from 'electron'; +import { logging, sleep } from '../utils/utils'; + +export class GamepadsStore { + rootStore: typeof RootStore; + + gamepads?: Input[]; + + constructor(rootStore: typeof RootStore) { + makeAutoObservable(this); + this.rootStore = rootStore; + } + + updateGamepads(newGamepads: Input[]) { + this.gamepads = newGamepads; + } + + _needToUpdate = (newGamepads: (Gamepad | null)[]): boolean => { + const _timestamps: Array = [0, 0, 0, 0]; + + return _.some(newGamepads, (gamepad, index) => { + if (gamepad != null && gamepad.timestamp > (_timestamps[index] ?? 0)) { + _timestamps[index] = gamepad.timestamp; + return true; + } else if (gamepad == null && _timestamps[index] != null) { + _timestamps[index] = null; + return true; + } + return false; + }); + }; + + runtimeGamepads = async () => { + let timestamp = Date.now(); + + if (!this.rootStore.editor.isKeyboardModeToggled) { + while (true) { + // navigator.getGamepads always returns a reference to the same object. This + // confuses redux, so we use assignIn to clone to a new object each time. + const newGamepads = navigator.getGamepads(); + if (this._needToUpdate(newGamepads) || Date.now() - timestamp > 100) { + const formattedGamepads = this.formatGamepads(newGamepads); + this.updateGamepads(formattedGamepads); + + // Send gamepad data to Runtime. + if (_.some(newGamepads) || Date.now() - timestamp > 100) { + timestamp = Date.now(); + this.updateMainProcess(); + } + } + await sleep(50); // wait 50 ms before updating again + } + } + }; + + formatGamepads = (newGamepads: (Gamepad | null)[]): Input[] => { + const formattedGamepads: Input[] = []; + // Currently there is a bug on windows where navigator.getGamepads() + // returns a second, 'ghost' gamepad even when only one is connected. + // The filter on 'mapping' filters out the ghost gamepad. + _.forEach(_.filter(newGamepads, { mapping: 'standard' }), (gamepad: Gamepad | null, indexGamepad: number) => { + if (gamepad) { + let bitmap = 0; + gamepad.buttons.forEach((button, index) => { + if (button.pressed) { + bitmap |= 1 << index; + } + }); + formattedGamepads[indexGamepad] = new Input({ + connected: gamepad.connected, + axes: gamepad.axes.slice(), + buttons: bitmap, + source: Source.GAMEPAD + }); + } + }); + return formattedGamepads; + }; + + updateMainProcess = () => { + ipcRenderer.send(ipcChannels.STATE_UPDATE, this.gamepads, Source.GAMEPAD); + }; +} diff --git a/renderer/stores/index.ts b/renderer/stores/index.ts new file mode 100644 index 00000000..2788108a --- /dev/null +++ b/renderer/stores/index.ts @@ -0,0 +1 @@ +export { RootStore } from './root'; diff --git a/renderer/stores/info.ts b/renderer/stores/info.ts new file mode 100644 index 00000000..b087dc2c --- /dev/null +++ b/renderer/stores/info.ts @@ -0,0 +1,69 @@ +import { ipcRenderer } from 'electron'; +import { IObservableValue, observable } from 'mobx'; +import { ipcChannels } from '../../shared'; +import { robotState, defaults } from '../utils/utils'; +import { RootStore } from './root'; + +type updateState = { + autonomous: number; + enabled: boolean; +}; + +export class InfoStore { + rootStore: typeof RootStore; + + ipAddress: IObservableValue = observable.box(defaults.IPADDRESS); + sshAddress: IObservableValue = observable.box(defaults.IPADDRESS); + studentCodeStatus: IObservableValue = observable.box(robotState.IDLE); + isRunningCode: IObservableValue = observable.box(false); + connectionStatus: IObservableValue = observable.box(false); + runtimeStatus: IObservableValue = observable.box(false); + notificationHold: IObservableValue = observable.box(0); // TODO: not sure what this is + fieldControlDirective: IObservableValue = observable.box(robotState.TELEOP); // TODO: not sure what this is + fieldControlActivity: IObservableValue = observable.box(false); // TODO: not sure what this is + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + perMessage = () => { + this.connectionStatus.set(true); + }; + + notificationChange = (notification: number) => { + this.notificationHold.set(notification); + }; + + runtimeConnect = () => { + this.runtimeStatus.set(true); + }; + + runtimeDisconnect = () => { + this.runtimeStatus.set(false); + this.connectionStatus.set(false); + this.studentCodeStatus.set(robotState.IDLE); + }; + + updateStudentCodeStatus = (codeStatus: number) => { + ipcRenderer.send(ipcChannels.RUN_MODE_UPDATE, { mode: codeStatus }); + this.studentCodeStatus.set(codeStatus); + }; + + ipChange = (address: string) => { + ipcRenderer.send(ipcChannels.IP_ADDRESS, address); + this.ipAddress.set(address); + }; + + sshIpChange = (address: string) => { + this.sshAddress.set(address); + }; + + updateRobot = (status: updateState) => { + const stateChange = status.autonomous ? robotState.AUTONOMOUS : robotState.TELEOP; + const codeStatus = !status.enabled ? robotState.IDLE : stateChange; + + this.fieldControlDirective.set(stateChange); + this.fieldControlActivity.set(status.enabled); + this.studentCodeStatus.set(codeStatus); + }; +} diff --git a/renderer/stores/peripherals.ts b/renderer/stores/peripherals.ts new file mode 100644 index 00000000..817ab2a8 --- /dev/null +++ b/renderer/stores/peripherals.ts @@ -0,0 +1,37 @@ +import { IObservableValue, observable, ObservableMap } from 'mobx'; +import { RootStore } from './root'; +import * as consts from '../consts'; +import { Peripheral, PeripheralList } from '../types'; + +export class PeripheralsStore { + rootStore: typeof RootStore; + + peripheralList: ObservableMap = observable.map({}); + batterySafety: IObservableValue = observable.box(false); + batteryLevel: IObservableValue = observable.box(0); + runtimeVersion: IObservableValue = observable.box('1.0.0'); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + updatePeripherals = (peripherals: Peripheral[]) => { + const keys: string[] = []; + + (peripherals ?? []).forEach((peripheral: Peripheral) => { + const key = `${peripheral.type}_${peripheral.uid}`; + keys.push(key); + + if (key in this.peripheralList) { + peripheral.name = this.peripheralList[key].name; // ensures that the device keeps the name, if it was a custom name + } + this.peripheralList[key] = { ...peripheral, uid: key }; + }); + + for (const uid in this.peripheralList.keys()) { + if (keys.indexOf(uid) === -1) { + this.peripheralList.delete(uid); // Delete old devices + } + } + }; +} diff --git a/renderer/stores/root.ts b/renderer/stores/root.ts new file mode 100644 index 00000000..a44e629a --- /dev/null +++ b/renderer/stores/root.ts @@ -0,0 +1,39 @@ +import { AlertStore } from './alert'; +import { ConsoleStore } from './console'; +import { EditorStore } from './editor'; +import { FieldStore } from './fieldStore' +import { GamepadsStore } from './gamepads'; +import { InfoStore } from './info'; +import { PeripheralsStore } from './peripherals'; +import { SettingsStore } from './settings'; +import { TimerStore } from './timer' + +// Make sure store imports are in alphabetical order + +class RootStore_ { + alert: AlertStore; + console: ConsoleStore; + editor: EditorStore; + fieldStore: FieldStore; + gamepads: GamepadsStore; + info: InfoStore; + peripherals: PeripheralsStore; + settings: SettingsStore; + timer: TimerStore; + + constructor() { + this.alert = new AlertStore(this); + this.console = new ConsoleStore(this); + this.editor = new EditorStore(this); + this.fieldStore = new FieldStore(this); + this.gamepads = new GamepadsStore(this); + this.info = new InfoStore(this); + this.peripherals = new PeripheralsStore(this); + this.settings = new SettingsStore(this); + this.timer = new TimerStore(this); + + /** Initialize more stores here (try to keep it in alphabetical order) */ + } +} + +export const RootStore = new RootStore_(); diff --git a/renderer/stores/settings.ts b/renderer/stores/settings.ts new file mode 100644 index 00000000..557d1373 --- /dev/null +++ b/renderer/stores/settings.ts @@ -0,0 +1,26 @@ +import { IObservableValue, observable } from 'mobx'; +import { RootStore } from './root'; + +export class SettingsStore { + rootStore: typeof RootStore; + + fontSize: IObservableValue = observable.box(14); + editorTheme: IObservableValue = observable.box('tomorrow'); + globalTheme = observable.box<'light' | 'dark'>('light'); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + changeFontSize(size: number) { + this.fontSize.set(size); + } + + changeTheme(theme: string) { + this.editorTheme.set(theme); + } + + toggleThemeGlobal() { + this.globalTheme.set(this.globalTheme.get() === 'dark' ? 'light' : 'dark'); + } +} diff --git a/renderer/stores/test.utils.ts b/renderer/stores/test.utils.ts new file mode 100644 index 00000000..3d660f8b --- /dev/null +++ b/renderer/stores/test.utils.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import _ from 'lodash'; +import { OpenDialogReturnValue, SaveDialogReturnValue, MessageBoxReturnValue, remote } from 'electron'; +import { defaults, logging, sleep } from '../utils/utils'; +import { Client, SFTPWrapper } from 'ssh2'; + +export type UnsavedDialogButtonOptions = 'saveAction' | 'discardAction' | 'cancelAction'; +const unsavedDialogButtonMappings: Record = { + 0: 'saveAction', + 1: 'discardAction', + 2: 'cancelAction' +}; + +export type UnsavedDialogActions = 'open' | 'create'; + +export async function unsavedDialog(action: UnsavedDialogActions): Promise { + const messageBoxReturnValue: MessageBoxReturnValue = await remote.dialog.showMessageBox({ + type: 'warning', + buttons: [`Save and ${action}`, `Discard and ${action}`, 'Cancel action'], + title: 'You have unsaved changes!', + message: `You are trying to ${action} a new file, but you have unsaved changes to your current one. What do you want to do?` + }); + + const { response: responseIdx } = messageBoxReturnValue; + // `responseIdx` is an integer corrseponding to index in button list above. + if ([0, 1, 2].includes(responseIdx)) { + return unsavedDialogButtonMappings[responseIdx]; + } + + return undefined; +} + +export async function openFileDialog(): Promise { + const openDialogReturnValue: OpenDialogReturnValue = await remote.dialog.showOpenDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }); + + const { filePaths } = openDialogReturnValue; + + // If filepaths is undefined, the user did not specify a file. + if (_.isEmpty(filePaths)) { + return ''; + } + + return filePaths[0]; +} + +export async function saveFileDialog(): Promise { + const saveDialogReturnValue: SaveDialogReturnValue = await remote.dialog.showSaveDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }); + + let { filePath } = saveDialogReturnValue; + // If filepath is undefined, the user did not specify a file. + if (filePath === undefined) { + return ''; + } + + // Automatically append .py extension if they don't have it + if (!filePath.endsWith('.py')) { + filePath = `${filePath}.py`; + } + + return filePath; +} + +export const createErrorCallback = (logPrefix: string) => (error: NodeJS.ErrnoException | null) => { + if (error !== null) { + console.error(`Error - ${logPrefix}: ${error}`); + } +}; + +export const transferFile = async ({ + localFilePath, + ip, + port, + transferType +}: { + localFilePath: string; + ip: string; + port: number; + transferType: 'download' | 'upload'; +}) => { + const conn = new Client(); + + return await new Promise((resolve) => { + conn.on('error', (err: any) => { + logging.log(err); + resolve('connectionError'); + }); + + conn + .on('ready', () => { + conn.sftp((err: Error | undefined, sftp: SFTPWrapper) => { + if (err) { + logging.log(err); + resolve('sftpError'); + } + + let transferMethod: 'fastGet' | 'fastPut'; + let srcPath; + let destPath; + + switch (transferType) { + case 'download': + // eslint-disable-next-line @typescript-eslint/unbound-method + transferMethod = 'fastGet'; + srcPath = defaults.STUDENTCODELOC; + destPath = `${defaults.DESKTOP_LOC}/robotCode.py`; + break; + + case 'upload': + // eslint-disable-next-line @typescript-eslint/unbound-method + transferMethod = 'fastPut'; + srcPath = localFilePath; + destPath = defaults.STUDENTCODELOC; + break; + + default: + return; + } + + sftp[transferMethod](srcPath, destPath, (err2: any) => { + if (err2) { + logging.log(err2); + resolve('fileTransmissionError'); + } + resolve('fileTransmissionSuccess'); + }); + }); + }) + .connect({ + debug: (input: any) => { + logging.log(input); + }, + host: ip, + port, + username: defaults.USERNAME, + password: defaults.PASSWORD + }); + }); +}; diff --git a/renderer/stores/timer.ts b/renderer/stores/timer.ts new file mode 100644 index 00000000..44e85a48 --- /dev/null +++ b/renderer/stores/timer.ts @@ -0,0 +1,50 @@ +import { ipcRenderer } from 'electron'; +import { IObservableValue, observable } from 'mobx'; +import { logging } from '../utils/utils'; +import { RootStore } from './root'; + +interface TimerState { + timestamp: number; + timeLeft: number; + computedTime: number; + totalTime: number; + stage: string; +} + +// TODO: is this still needed? +export class TimerStore { + rootStore: typeof RootStore; + + timestamp: IObservableValue = observable.box(0); + timeLeft: IObservableValue = observable.box(0); + computedTime: IObservableValue = observable.box(0); + totalTime: IObservableValue = observable.box(0); + stage: IObservableValue = observable.box(''); + + constructor(rootStore: typeof RootStore) { + this.rootStore = rootStore; + } + + updateTimer = (timer: TimerState) => { + this.timestamp.set(Date.now()); + this.timeLeft.set(timer.timeLeft); + this.computedTime.set(timer.computedTime); + this.totalTime.set(timer.totalTime); + this.stage.set(timer.stage); + }; + + timestampBounceback = () => { + logging.log('Timestamp Requested in Sagas'); + ipcRenderer.send('TIMESTAMP_SEND'); + }; +} + +// TODO: Move Elsewhere +// function refreshTimer() { +// let timeLeft = (_timerData.timeLeft - (Date.now() - _timerData.timestamp)); +// if (timeLeft < 0){ +// timeLeft = 0; +// } +// _timerData.computedTime = timeLeft; +// } +// setInterval(refreshTimer, 200); diff --git a/renderer/stores/utils/callbacks.ts b/renderer/stores/utils/callbacks.ts new file mode 100644 index 00000000..ee40ceeb --- /dev/null +++ b/renderer/stores/utils/callbacks.ts @@ -0,0 +1,5 @@ +export const createErrorCallback = (logPrefix: string) => (error: NodeJS.ErrnoException | null) => { + if (error !== null) { + console.error(`Error - ${logPrefix}: ${error}`); + } +}; diff --git a/renderer/stores/utils/createTask.ts b/renderer/stores/utils/createTask.ts new file mode 100644 index 00000000..d9a745c5 --- /dev/null +++ b/renderer/stores/utils/createTask.ts @@ -0,0 +1,66 @@ +import { logging } from '../../utils/utils'; + +export interface TaskConfig { + taskId?: string; + /** If defined, will rerun the task implementation on intervals using the provided interval in msec */ + refreshIntervalMsec?: number; +} + +export interface Task { + run: (...args: ArgsT) => OutputT | void; + cancel: () => void; +} + +export interface CreateTask { + config?: TaskConfig; + implementation: (...args: ArgsT) => Promise | OutputT | void; +} + +export const createTask = ({ + config = {}, + implementation +}: CreateTask): Task => { + let timeout: ReturnType | undefined = undefined; + + const run = (...args: ArgsT) => { + const internalRun = async () => { + return await implementation(...args); + }; + + let output; + internalRun() + .then((result) => { + output = result; + }) + .catch((e) => { + logging.log(`[TASK] ${config.taskId ?? ''}`, e); + }) + .finally(() => { + const refreshIntervalMsec = config.refreshIntervalMsec; + if (refreshIntervalMsec !== undefined) { + cleanupCurrTimeout(); + timeout = setTimeout(() => run(...args), refreshIntervalMsec); + } + }); + + // TODO: fix output always undefined + return output; + }; + + const cleanupCurrTimeout = () => { + if (timeout !== undefined) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + const cancel = () => { + logging.log(`[TASK] ${config.taskId ?? ''} canceled`); + cleanupCurrTimeout(); + }; + + return { + run, + cancel + } as Task; +}; diff --git a/renderer/stores/utils/dialogs.ts b/renderer/stores/utils/dialogs.ts new file mode 100644 index 00000000..a5764d68 --- /dev/null +++ b/renderer/stores/utils/dialogs.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import _ from 'lodash'; +import { OpenDialogReturnValue, SaveDialogReturnValue, MessageBoxReturnValue, remote } from 'electron'; +import { defaults, logging, sleep } from '../utils/utils'; + +export type UnsavedDialogButtonOptions = 'saveAction' | 'discardAction' | 'cancelAction'; +const unsavedDialogButtonMappings: Record = { + 0: 'saveAction', + 1: 'discardAction', + 2: 'cancelAction' +}; + +export type UnsavedDialogActions = 'open' | 'create'; + +export async function openUnsavedFileDialog(action: UnsavedDialogActions): Promise { + const messageBoxReturnValue: MessageBoxReturnValue = await remote.dialog.showMessageBox({ + type: 'warning', + buttons: [`Save and ${action}`, `Discard and ${action}`, 'Cancel action'], + title: 'You have unsaved changes!', + message: `You are trying to ${action} a new file, but you have unsaved changes to your current one. What do you want to do?` + }); + + const { response: responseIdx } = messageBoxReturnValue; + // `responseIdx` is an integer corrseponding to index in button list above. + if ([0, 1, 2].includes(responseIdx)) { + return unsavedDialogButtonMappings[responseIdx]; + } + + return undefined; +} + +export async function openFileDialog(): Promise { + const openDialogReturnValue: OpenDialogReturnValue = await remote.dialog.showOpenDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }); + + const { filePaths } = openDialogReturnValue; + + // If filepaths is undefined, the user did not specify a file. + if (_.isEmpty(filePaths)) { + return ''; + } + + return filePaths[0]; +} + +export async function saveFileDialog(): Promise { + const saveDialogReturnValue: SaveDialogReturnValue = await remote.dialog.showSaveDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }); + + let { filePath } = saveDialogReturnValue; + // If filepath is undefined, the user did not specify a file. + if (filePath === undefined) { + return ''; + } + + // Automatically append .py extension if they don't have it + if (!filePath.endsWith('.py')) { + filePath = `${filePath}.py`; + } + + return filePath; +} diff --git a/renderer/stores/utils/index.ts b/renderer/stores/utils/index.ts new file mode 100644 index 00000000..2edb4e38 --- /dev/null +++ b/renderer/stores/utils/index.ts @@ -0,0 +1,4 @@ +export * from './callbacks'; +export * from './createTask'; +export * from './dialogs'; +export * from './transferFile'; diff --git a/renderer/stores/utils/transferFile.ts b/renderer/stores/utils/transferFile.ts new file mode 100644 index 00000000..74090fed --- /dev/null +++ b/renderer/stores/utils/transferFile.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { defaults, logging } from '../../utils/utils'; +import { Client, SFTPWrapper } from 'ssh2'; + +export const transferFile = async ({ + localFilePath, + ip, + port, + transferType +}: { + localFilePath: string; + ip: string; + port: number; + transferType: 'download' | 'upload'; +}) => { + const conn = new Client(); + + return await new Promise((resolve) => { + conn.on('error', (err: any) => { + logging.log(err); + resolve('connectionError'); + }); + + conn + .on('ready', () => { + conn.sftp((err: Error | undefined, sftp: SFTPWrapper) => { + if (err) { + logging.log(err); + resolve('sftpError'); + } + + let transferMethod: 'fastGet' | 'fastPut'; + let srcPath; + let destPath; + + switch (transferType) { + case 'download': + // eslint-disable-next-line @typescript-eslint/unbound-method + transferMethod = 'fastGet'; + srcPath = defaults.STUDENTCODELOC; + destPath = `${defaults.DESKTOP_LOC}/robotCode.py`; + break; + + case 'upload': + // eslint-disable-next-line @typescript-eslint/unbound-method + transferMethod = 'fastPut'; + srcPath = localFilePath; + destPath = defaults.STUDENTCODELOC; + break; + + default: + return; + } + + sftp[transferMethod](srcPath, destPath, (err2: any) => { + if (err2) { + logging.log(err2); + resolve('fileTransmissionError'); + } + resolve('fileTransmissionSuccess'); + }); + }); + }) + .connect({ + debug: (input: any) => { + logging.log(input); + }, + host: ip, + port, + username: defaults.USERNAME, + password: defaults.PASSWORD + }); + }); +}; diff --git a/renderer/utils/sagas.ts b/renderer/utils/sagas.ts index 5f21dcb0..323be3f5 100644 --- a/renderer/utils/sagas.ts +++ b/renderer/utils/sagas.ts @@ -15,6 +15,7 @@ import { openFileSucceeded, saveFileSucceeded } from '../actions/EditorActions'; import { toggleFieldControl } from '../actions/FieldActions'; import { updateGamepads } from '../actions/GamepadsActions'; import { runtimeConnect, runtimeDisconnect } from '../actions/InfoActions'; +import { ipcChannels } from '../../shared'; import { TIMEOUT, defaults, logging } from '../utils/utils'; import { Input, Source, TimeStamps } from '../../protos-main'; @@ -30,19 +31,22 @@ let timestamp = Date.now(); */ function openFileDialog() { return new Promise((resolve, reject) => { - remote.dialog.showOpenDialog({ - filters: [{ name: 'python', extensions: ['py'] }], - }).then((openDialogReturnValue: OpenDialogReturnValue) => { - const { filePaths } = openDialogReturnValue; - // If filepaths is undefined, the user did not specify a file. - if (_.isEmpty(filePaths)) { - reject(); - } else { - resolve(filePaths[0]); - } - }).catch((error) => { - reject(error); - }); + remote.dialog + .showOpenDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }) + .then((openDialogReturnValue: OpenDialogReturnValue) => { + const { filePaths } = openDialogReturnValue; + // If filepaths is undefined, the user did not specify a file. + if (_.isEmpty(filePaths)) { + reject(); + } else { + resolve(filePaths[0]); + } + }) + .catch((error) => { + reject(error); + }); }); } @@ -54,22 +58,24 @@ function openFileDialog() { */ function saveFileDialog() { return new Promise((resolve, reject) => { - remote.dialog.showSaveDialog({ - filters: [{ name: 'python', extensions: ['py'] }], - }).then((saveDialogReturnValue: SaveDialogReturnValue) => { - const { filePath } = saveDialogReturnValue; - // If filepath is undefined, the user did not specify a file. - if (filePath === undefined) { - reject(); - return; - } + remote.dialog + .showSaveDialog({ + filters: [{ name: 'python', extensions: ['py'] }] + }) + .then((saveDialogReturnValue: SaveDialogReturnValue) => { + const { filePath } = saveDialogReturnValue; + // If filepath is undefined, the user did not specify a file. + if (filePath === undefined) { + reject(); + return; + } - // Automatically append .py extension if they don't have it - if (!filePath.endsWith('.py')) { - resolve(`${filePath}.py`); - } - resolve(filePath); - }); + // Automatically append .py extension if they don't have it + if (!filePath.endsWith('.py')) { + resolve(`${filePath}.py`); + } + resolve(filePath); + }); }); } @@ -81,21 +87,23 @@ function saveFileDialog() { */ function unsavedDialog(action: string) { return new Promise((resolve, reject) => { - remote.dialog.showMessageBox({ - type: 'warning', - buttons: [`Save and ${action}`, `Discard and ${action}`, 'Cancel action'], - title: 'You have unsaved changes!', - message: `You are trying to ${action} a new file, but you have unsaved changes to -your current one. What do you want to do?`, - }).then((messageBoxReturnValue: MessageBoxReturnValue) => { - const { response } = messageBoxReturnValue; - // 'res' is an integer corrseponding to index in button list above. - if (response === 0 || response === 1 || response === 2) { - resolve(response); - } else { - reject(); - } - }) + remote.dialog + .showMessageBox({ + type: 'warning', + buttons: [`Save and ${action}`, `Discard and ${action}`, 'Cancel action'], + title: 'You have unsaved changes!', + message: `You are trying to ${action} a new file, but you have unsaved changes to +your current one. What do you want to do?` + }) + .then((messageBoxReturnValue: MessageBoxReturnValue) => { + const { response } = messageBoxReturnValue; + // 'res' is an integer corrseponding to index in button list above. + if (response === 0 || response === 1 || response === 2) { + resolve(response); + } else { + reject(); + } + }); }); } @@ -135,11 +143,11 @@ function* saveFile(action: any) { const editorSavedState = (state: any) => ({ savedCode: state.editor.latestSaveCode, - code: state.editor.editorCode, + code: state.editor.editorCode }); function* openFile(action: any) { - const type = (action.type === 'OPEN_FILE') ? 'open' : 'create'; + const type = action.type === 'OPEN_FILE' ? 'open' : 'create'; const result = yield select(editorSavedState); let res = 1; if (result.code !== result.savedCode) { @@ -147,7 +155,7 @@ function* openFile(action: any) { if (res === 0) { yield* saveFile({ type: 'SAVE_FILE', - saveAs: false, + saveAs: false }); } } @@ -176,7 +184,7 @@ function* dragFile(action: any) { if (res === 0) { yield* saveFile({ type: 'SAVE_FILE', - saveAs: false, + saveAs: false }); } } @@ -223,7 +231,7 @@ const _timestamps: Array = [0, 0, 0, 0]; function _needToUpdate(newGamepads: (Gamepad | null)[]): boolean { return _.some(newGamepads, (gamepad, index) => { - if (gamepad != null && (gamepad.timestamp > (_timestamps[index] ?? 0))) { + if (gamepad != null && gamepad.timestamp > (_timestamps[index] ?? 0)) { _timestamps[index] = gamepad.timestamp; return true; } else if (gamepad == null && _timestamps[index] != null) { @@ -235,16 +243,16 @@ function _needToUpdate(newGamepads: (Gamepad | null)[]): boolean { } function formatGamepads(newGamepads: (Gamepad | null)[]): Input[] { - let formattedGamepads: Input[] = []; + const formattedGamepads: Input[] = []; // Currently there is a bug on windows where navigator.getGamepads() // returns a second, 'ghost' gamepad even when only one is connected. // The filter on 'mapping' filters out the ghost gamepad. _.forEach(_.filter(newGamepads, { mapping: 'standard' }), (gamepad: Gamepad | null, indexGamepad: number) => { if (gamepad) { - let bitmap: number = 0; + let bitmap = 0; gamepad.buttons.forEach((button, index) => { if (button.pressed) { - bitmap |= (1 << index); + bitmap |= 1 << index; } }); formattedGamepads[indexGamepad] = new Input({ @@ -266,11 +274,10 @@ function* initiateLatencyCheck() { runtimeTimestamp: 0 }); - ipcRenderer.send('initiateLatencyCheck', timestamps); + ipcRenderer.send(ipcChannels.INITIATE_LATENCY_CHECK, timestamps); yield delay(5000); } - } /* Send an update to Runtime indicating whether keyboard mode is on/off @@ -285,7 +292,7 @@ function* sendKeyboardConnectionStatus() { source: Source.KEYBOARD }); - ipcRenderer.send('stateUpdate', [keyboardConnectionStatus], Source.KEYBOARD); + ipcRenderer.send(ipcChannels.STATE_UPDATE, [keyboardConnectionStatus], Source.KEYBOARD); } function* sendKeyboardInputs() { @@ -298,7 +305,7 @@ function* sendKeyboardInputs() { source: Source.KEYBOARD }); - ipcRenderer.send('stateUpdate', [keyboard], Source.KEYBOARD); + ipcRenderer.send(ipcChannels.STATE_UPDATE, [keyboard], Source.KEYBOARD); } /** @@ -306,8 +313,7 @@ function* sendKeyboardInputs() { * redux action to update gamepad state. */ function* runtimeGamepads() { - - const currEditorState = yield select(editorState) + const currEditorState = yield select(editorState); if (!currEditorState.isKeyboardModeToggled) { while (true) { @@ -340,10 +346,10 @@ function runtimeReceiver() { emitter(action); }; // Suscribe listener to dispatches from main process. - ipcRenderer.on('reduxDispatch', listener); + ipcRenderer.on(ipcChannels.REDUX_DISPATCH, listener); // Return an unsuscribe function. return () => { - ipcRenderer.removeListener('reduxDispatch', listener); + ipcRenderer.removeListener(ipcChannels.REDUX_DISPATCH, listener); }; }); } @@ -373,47 +379,46 @@ const gamepadsState = (state: any) => state.gamepads.gamepads; */ function* updateMainProcess() { const stateSlice = yield select(gamepadsState); // Get gamepads from Redux state store - ipcRenderer.send('stateUpdate', stateSlice, Source.GAMEPAD); + ipcRenderer.send(ipcChannels.STATE_UPDATE, stateSlice, Source.GAMEPAD); } function* restartRuntime() { const conn = new Client(); const stateSlice = yield select((state: any) => ({ runtimeStatus: state.info.runtimeStatus, - ipAddress: state.info.ipAddress, + ipAddress: state.info.ipAddress })); if (stateSlice.runtimeStatus && stateSlice.ipAddress !== defaults.IPADDRESS) { - const network = yield call(() => new Promise((resolve) => { - conn.on('ready', () => { - conn.exec( - 'sudo systemctl restart runtime.service', - { pty: true }, (uperr: any, stream: any) => { - if (uperr) { - resolve(1); - } - stream.write(`${defaults.PASSWORD}\n`); - stream.on('exit', (code: any) => { - logging.log(`Runtime Restart: Returned ${code}`); - conn.end(); - resolve(0); + const network = yield call( + () => + new Promise((resolve) => { + conn + .on('ready', () => { + conn.exec('sudo systemctl restart runtime.service', { pty: true }, (uperr: any, stream: any) => { + if (uperr) { + resolve(1); + } + stream.write(`${defaults.PASSWORD}\n`); + stream.on('exit', (code: any) => { + logging.log(`Runtime Restart: Returned ${code}`); + conn.end(); + resolve(0); + }); + }); + }) + .connect({ + debug: (inpt: any) => { + logging.log(inpt); + }, + host: stateSlice.ipAddress, + port: defaults.PORT, + username: defaults.USERNAME, + password: defaults.PASSWORD }); - }, - ); - }).connect({ - debug: (inpt: any) => { - logging.log(inpt); - }, - host: stateSlice.ipAddress, - port: defaults.PORT, - username: defaults.USERNAME, - password: defaults.PASSWORD, - }); - })); + }) + ); if (network === 1) { - yield addAsyncAlert( - 'Runtime Restart Error', - 'Dawn was unable to run restart commands. Please check your robot connectivity.', - ); + yield addAsyncAlert('Runtime Restart Error', 'Dawn was unable to run restart commands. Please check your robot connectivity.'); } } } @@ -422,7 +427,7 @@ function* downloadStudentCode() { const conn = new Client(); const stateSlice = yield select((state: any) => ({ runtimeStatus: state.info.runtimeStatus, - ipAddress: state.info.sshAddress, + ipAddress: state.info.sshAddress })); let port = defaults.PORT; let ip = stateSlice.ipAddress; @@ -440,75 +445,62 @@ function* downloadStudentCode() { } if (stateSlice.runtimeStatus) { logging.log(`Downloading to ${path}`); - const errors = yield call(() => new Promise((resolve) => { - conn.on('error', (err: any) => { - logging.log(err); - resolve(3); - }); - - conn.on('ready', () => { - conn.sftp((err: any, sftp: any) => { - if (err) { + const errors = yield call( + () => + new Promise((resolve) => { + conn.on('error', (err: any) => { logging.log(err); - resolve(1); - } - sftp.fastGet( - defaults.STUDENTCODELOC, `${path}/robotCode.py`, - (err2: any) => { - if (err2) { - logging.log(err2); - resolve(2); - } - resolve(0); - }, - ); - }); - }).connect({ - debug: (inpt: any) => { - logging.log(inpt); - }, - host: ip, - port: port, - username: defaults.USERNAME, - password: defaults.PASSWORD, - }); - })); + resolve(3); + }); + + conn + .on('ready', () => { + conn.sftp((err: any, sftp: any) => { + if (err) { + logging.log(err); + resolve(1); + } + sftp.fastGet(defaults.STUDENTCODELOC, `${path}/robotCode.py`, (err2: any) => { + if (err2) { + logging.log(err2); + resolve(2); + } + resolve(0); + }); + }); + }) + .connect({ + debug: (inpt: any) => { + logging.log(inpt); + }, + host: ip, + port: port, + username: defaults.USERNAME, + password: defaults.PASSWORD + }); + }) + ); switch (errors) { case 0: { const data: Buffer = yield cps(fs.readFile, `${path}/robotCode.py`); yield put(openFileSucceeded(data.toString(), `${path}/robotCode.py`)); - yield put(addAsyncAlert( - 'Download Success', - 'File Downloaded Successfully', - )); + yield put(addAsyncAlert('Download Success', 'File Downloaded Successfully')); break; } case 1: { - yield put(addAsyncAlert( - 'Download Issue', - 'SFTP session could not be initiated', - )); + yield put(addAsyncAlert('Download Issue', 'SFTP session could not be initiated')); break; } case 2: { - yield put(addAsyncAlert( - 'Download Issue', - 'File failed to be downloaded', - )); + yield put(addAsyncAlert('Download Issue', 'File failed to be downloaded')); break; } case 3: { - yield put(addAsyncAlert( - 'Download Issue', - 'Robot could not be connected.', - )); + yield put(addAsyncAlert('Download Issue', 'Robot could not be connected.')); break; } default: { - yield put(addAsyncAlert( - 'Download Issue', - 'Unknown Error', - )); + yield put(addAsyncAlert('Download Issue', 'Unknown Error')); break; } } @@ -523,7 +515,7 @@ function* uploadStudentCode() { const stateSlice = yield select((state: any) => ({ runtimeStatus: state.info.runtimeStatus, ipAddress: state.info.sshAddress, - filepath: state.editor.filepath, + filepath: state.editor.filepath })); let port = defaults.PORT; let ip = stateSlice.ipAddress; @@ -534,74 +526,61 @@ function* uploadStudentCode() { } if (stateSlice.runtimeStatus) { logging.log(`Uploading ${stateSlice.filepath}`); - const errors = yield call(() => new Promise((resolve) => { - conn.on('error', (err: any) => { - logging.log(err); - resolve(3); - }); - - conn.on('ready', () => { - conn.sftp((err: any, sftp: any) => { - if (err) { + const errors = yield call( + () => + new Promise((resolve) => { + conn.on('error', (err: any) => { logging.log(err); - resolve(1); - } - sftp.fastPut( - stateSlice.filepath, defaults.STUDENTCODELOC, - (err2: any) => { - if (err2) { - logging.log(err2); - resolve(2); - } - resolve(0); - }, - ); - }); - }).connect({ - debug: (input: any) => { - logging.log(input); - }, - host: ip, - port: port, - username: defaults.USERNAME, - password: defaults.PASSWORD, - }); - })); + resolve(3); + }); + + conn + .on('ready', () => { + conn.sftp((err: any, sftp: any) => { + if (err) { + logging.log(err); + resolve(1); + } + sftp.fastPut(stateSlice.filepath, defaults.STUDENTCODELOC, (err2: any) => { + if (err2) { + logging.log(err2); + resolve(2); + } + resolve(0); + }); + }); + }) + .connect({ + debug: (input: any) => { + logging.log(input); + }, + host: ip, + port: port, + username: defaults.USERNAME, + password: defaults.PASSWORD + }); + }) + ); switch (errors) { case 0: { - yield put(addAsyncAlert( - 'Upload Success', - 'File Uploaded Successfully', - )); + yield put(addAsyncAlert('Upload Success', 'File Uploaded Successfully')); break; } case 1: { - yield put(addAsyncAlert( - 'Upload Issue', - 'SFTP session could not be initiated', - )); + yield put(addAsyncAlert('Upload Issue', 'SFTP session could not be initiated')); break; } case 2: { - yield put(addAsyncAlert( - 'Upload Issue', - 'File failed to be transmitted', - )); + yield put(addAsyncAlert('Upload Issue', 'File failed to be transmitted')); break; } case 3: { - yield put(addAsyncAlert( - 'Upload Issue', - 'Robot could not be connected', - )); + yield put(addAsyncAlert('Upload Issue', 'Robot could not be connected')); break; } default: { - yield put(addAsyncAlert( - 'Upload Issue', - 'Unknown Error', - )); + yield put(addAsyncAlert('Upload Issue', 'Unknown Error')); break; } } @@ -613,7 +592,7 @@ function* uploadStudentCode() { function* handleFieldControl() { const stateSlice = yield select((state: any) => ({ - fieldControlStatus: state.fieldStore.fieldControl, + fieldControlStatus: state.fieldStore.fieldControl })); if (stateSlice.fieldControlStatus) { yield put(toggleFieldControl(false)); @@ -666,5 +645,5 @@ export { gamepadsState, updateMainProcess, runtimeReceiver, - runtimeSaga, + runtimeSaga }; // for tests diff --git a/renderer/utils/utils.ts b/renderer/utils/utils.ts index 4a776135..0bf4f538 100644 --- a/renderer/utils/utils.ts +++ b/renderer/utils/utils.ts @@ -36,18 +36,27 @@ export const getValidationState = (testIPAddress: string) => { }; export const isValidationState = (testIPAddress: string) => { - if (IPV4_REGEX.test(testIPAddress)) { - return true; - } - return false; -} + if (IPV4_REGEX.test(testIPAddress)) { + return true; + } + return false; +}; export const uploadStatus = { RECEIVED: 0, SENT: 1, - ERROR: 2, + ERROR: 2 }; +export enum RobotState { + IDLE = 0, + AUTONOMOUS = 1, + TELEOP = 2, + SIMULATION = 3 +} + +// TODO: remove +/** @deprecated */ export const robotState = { IDLE: 0, IDLESTR: 'Idle', @@ -58,7 +67,23 @@ export const robotState = { TELEOP: 2, TELEOPSTR: 'Tele-Operated', 2: 'Tele-Operated', - SIMSTR: 'Simulation', + SIMSTR: 'Simulation' +}; + +export const getRobotStateReadableString = (robotState: RobotState) => { + switch (robotState) { + case RobotState.IDLE: + return 'Idle'; + + case RobotState.AUTONOMOUS: + return 'Autonomous'; + + case RobotState.TELEOP: + return 'Tele-Operatorated'; + + case RobotState.SIMULATION: + return 'Simulation'; + } }; // TODO: Synchronize this and the above state @@ -72,7 +97,7 @@ export const runtimeState = { TELEOP: 3, 3: 'Tele-Operated', AUTONOMOUS: 4, - 4: 'Autonomous', + 4: 'Autonomous' }; export const defaults = { @@ -80,15 +105,16 @@ export const defaults = { USERNAME: 'pi', PASSWORD: 'raspberry', IPADDRESS: '192.168.0.0', + DESKTOP_LOC: remote.app.getPath('desktop'), STUDENTCODELOC: '/home/pi/runtime/executor/studentcode.py', - NGROK: true, + NGROK: true }; export const timings = { AUTO: 30, IDLE: 5, TELEOP: 120, - SEC: 1000, + SEC: 1000 }; export const windowInfo = { @@ -97,9 +123,11 @@ export const windowInfo = { CONSOLEPAD: 40, CONSOLESTART: 250, CONSOLEMAX: 350, - CONSOLEMIN: 100, + CONSOLEMIN: 100 }; +export const sleep = (durationMSec: number): Promise => new Promise((resolve) => setTimeout(resolve, durationMSec)); + export class Logger { log_file: fs.WriteStream; lastStr: string; @@ -121,15 +149,21 @@ export class Logger { this.lastStr = ''; } - log = (output: string) => { - console.log(output); - this._write(output, `\n[${(new Date()).toString()}]`); - } - debug = (output: string) => { - this._write(output, `\n[${(new Date()).toString()} DEBUG]`); - } + debug = (message?: any, ...optionalParams: any[]) => { + console.debug(message, optionalParams); + this._write(`[${new Date().toString()} DEBUG]`, `${message} ${optionalParams}`); + }; + + error = (message?: any, ...optionalParams: any[]) => { + console.error(message, optionalParams); + }; - _write = (output: string, prefix: string) => { + log = (message?: any, ...optionalParams: any[]) => { + console.log(message, optionalParams); + this._write('[${(new Date()).toString()}]', `${message} ${optionalParams}`); + }; + + _write = (prefix: string, output: any) => { output = String(output); if (output !== this.lastStr) { this.log_file.write(`${prefix} ${output}`); @@ -137,7 +171,7 @@ export class Logger { } else { // this.log_file.write('*'); } - } + }; } export let logging: Logger; // eslint-disable-line import/no-mutable-exports diff --git a/shared/consts/index.ts b/shared/consts/index.ts new file mode 100644 index 00000000..faa5775c --- /dev/null +++ b/shared/consts/index.ts @@ -0,0 +1 @@ +export * from './ipcChannels'; diff --git a/shared/consts/ipcChannels.ts b/shared/consts/ipcChannels.ts new file mode 100644 index 00000000..6ab873eb --- /dev/null +++ b/shared/consts/ipcChannels.ts @@ -0,0 +1,15 @@ +const RUN_MODE_UPDATE = 'RUN_MODE_UPDATE'; +const INITIATE_LATENCY_CHECK = 'INITIATE_LATENCY_CHECK'; +const STATE_UPDATE = 'STATE_UPDATE'; +const IP_ADDRESS = 'IP_ADDRESS'; +const START_INTERACTIVE_TOUR = 'START_INTERACTIVE_TOUR'; +const REDUX_DISPATCH = 'REDUX_DISPATCH'; + +export const ipcChannels = { + INITIATE_LATENCY_CHECK, + IP_ADDRESS, + REDUX_DISPATCH, + RUN_MODE_UPDATE, + START_INTERACTIVE_TOUR, + STATE_UPDATE +}; diff --git a/shared/index.ts b/shared/index.ts new file mode 100644 index 00000000..208236d0 --- /dev/null +++ b/shared/index.ts @@ -0,0 +1 @@ +export * from './consts'; diff --git a/yarn.lock b/yarn.lock index 1606a5ba..2fda66c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6400,6 +6400,23 @@ mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mobx-react-lite@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.2.0.tgz#331d7365a6b053378dfe9c087315b4e41c5df69f" + integrity sha512-q5+UHIqYCOpBoFm/PElDuOhbcatvTllgRp3M1s+Hp5j0Z6XNgDbgqxawJ0ZAUEyKM8X1zs70PCuhAIzX1f4Q/g== + +mobx-react@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-7.2.0.tgz#241e925e963bb83a31d269f65f9f379e37ecbaeb" + integrity sha512-KHUjZ3HBmZlNnPd1M82jcdVsQRDlfym38zJhZEs33VxyVQTvL77hODCArq6+C1P1k/6erEeo2R7rpE7ZeOL7dg== + dependencies: + mobx-react-lite "^3.2.0" + +mobx@^6.3.2: + version "6.3.2" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.2.tgz#125590961f702a572c139ab69392bea416d2e51b" + integrity sha512-xGPM9dIE1qkK9Nrhevp0gzpsmELKU4MFUJRORW/jqxVFIHHWIoQrjDjL8vkwoJYY3C2CeVJqgvl38hgKTalTWg== + mocha@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.0.0.tgz#cccac988b0bc5477119cba0e43de7af6d6ad8f4e"