diff --git a/.env.example b/.env.example index f63fdc4b..eb89b720 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ +# Duplicate this file, rename it to .env, and replace your-access-token with your MapBox API token + MAPBOX_TOKEN=your-access-token diff --git a/.eslintignore b/.eslintignore index c16db3d1..da02cdbd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,2 @@ +# Directories for eslint to ignore dist -old-src diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100755 index dab00bdf..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "parser": "babel-eslint", - "env": { - "browser": true, - "node": true - }, - "extends": "airbnb", - "rules": { - "linebreak-style": "off", - "import/no-commonjs": "error" - } -} diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..6904f828 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,92 @@ +# Use typescript-eslint to lint our TypeScript code +parser: "@typescript-eslint/parser" + +# Use Airbnb rules to check code +extends: + - airbnb + - plugin:@typescript-eslint/recommended + +plugins: + - import + - chai-friendly + +env: + browser: true + node: true + +settings: + # TypeScript compatibility + import/parsers: + "@typescript-eslint/parser": + - .ts + - .tsx + + # TypeScript compatibility + import/resolver: + typescript: {} + + # Modules that should be a devDependency and will be used in the application + import/core-modules: + - electron + +# Additional rules +rules: + # Turn off linting about having wrong line ending, handled in .gitattributes + linebreak-style: off + + # Have chai friendly no-unused-expressions rules, otherwise code can look nasty + no-unused-expressions: off + chai-friendly/no-unused-expressions: error + + # Add strictness to type declarations in TypeScript + "@typescript-eslint/explicit-function-return-type": error + "@typescript-eslint/no-explicit-any": error + + # Enforce commenting styles + multiline-comment-style: error + + # Modifying react/jsx-filename-extension to support TypeScript React files + "react/jsx-filename-extension": + - error + - extensions: + - .jsx + - .tsx + + # Move no-useless-constructor to @typescript-eslint/no-useless-constructor + no-useless-constructor: off + "@typescript-eslint/no-useless-constructor": error + + # @typescript-eslint/no-use-before-define is already defined + no-use-before-define: off + + # Re-adding changed Airbnb rules (removed most likely from @typescript-eslint/recommended) + "@typescript-eslint/camelcase": + - error + - properties: never + + "@typescript-eslint/indent": + - error + - 2 + - SwitchCase: 1 + VariableDeclarator: 1 + outerIIFEBody: 1 + FunctionDeclaration: + parameters: 1 + body: 1 + FunctionExpression: + parameters: 1 + body: 1 + CallExpression: + arguments: 1 + ArrayExpression: 1 + ObjectExpression: 1 + ImportDeclaration: 1 + flatTernaryExpressions: false + ignoreComments: false + + + "@typescript-eslint/no-unused-vars": + - error + - vars: all + args: after-used + ignoreRestSiblings: true diff --git a/.gitattributes b/.gitattributes index 83721108..225586d0 100755 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -# prevent newline problems between macOS and Windows +# Prevent newline problems between different systems * text=auto *.sln text eol=lf diff --git a/.gitignore b/.gitignore index be9997e9..391bca0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -# files +# Ignore system generated files/folders .DS_Store -.env package-lock.json - -# folders dist node_modules + +# Ignore .env file (should be set per system) +.env diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 00000000..6336a5fd --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,2 @@ +# Directories for stylelint to ignore +dist diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index 829139d1..00000000 --- a/.stylelintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "stylelint-config-standard", - "ignoreFiles": ["dist/**", "old-src/**", "resources/**"], - "rules": { - "indentation": 2, - "declaration-block-no-duplicate-properties": true, - "no-empty-source": null, - "string-quotes": "single" - } -} diff --git a/.stylelintrc.yml b/.stylelintrc.yml new file mode 100644 index 00000000..f0263013 --- /dev/null +++ b/.stylelintrc.yml @@ -0,0 +1,12 @@ +# Rules to lint CSS + +# Use stylelint preset rules to lint +extends: stylelint-config-standard + +# Added rules +rules: + indentation: 2 + declaration-block-no-duplicate-properties: true + no-empty-source: null + string-quotes: single + max-line-length: 100 diff --git a/.travis.yml b/.travis.yml index afae5906..a04dfe76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,17 @@ -# testing with Travis CI, will run npm test +# Travis CI testing configuration language: node_js + +# Use latest version of Node.js node_js: - - "node" -cache: - directories: - - "node_modules" + - lts/* + +# Cache node_modules after testing +cache: npm + +# Scripts to run to test application +script: + # Run each test manually instead of npm test so that it runs sequentially (and not in parallel) + - npm run test:lint + - npm run test:types + - npm run test:unit diff --git a/README.md b/README.md index 121616be..c9bc5eb3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # NGCP Ground Control Station [![Build Status](https://travis-ci.com/NGCP/GCS.svg?branch=dev-2018)](https://travis-ci.com/NGCP/GCS) +[![dependencies Status](https://david-dm.org/NGCP/GCS/status.svg)](https://david-dm.org/NGCP/GCS) +[![devDependencies Status](https://david-dm.org/NGCP/GCS/dev-status.svg)](https://david-dm.org/NGCP/GCS?type=dev) The [Northrop Grumman Collaboration Project] presents the Ground Control Station. This project's objective is to view and set missions for all autonomous vehicle platforms in the project. @@ -9,11 +11,12 @@ This is based on [Node.js], [React], [Webpack], and [Electron]. ## Getting Started **:pencil2: Setting Things Up** -Open up your command line application and clone this repository +Install [Node.js]. The program will not be able to run without it. + +Open up your command line application and clone this repository: ```sh -# Command is accustomed to dev-2018 branch -git clone -b dev-2018 --single-branch https://github.com/NGCP/GCS.git +git clone https://github.com/NGCP/GCS.git ``` **:scroll: Running the Program** @@ -31,6 +34,9 @@ npm install npm start ``` +## License +[MIT](https://github.com/NGCP/GCS/blob/dev-2018/LICENSE) + [Northrop Grumman Collaboration Project]: http://www.ngcpcalpoly.com/about.html [Node.js]: https://github.com/nodejs/node [React]: https://github.com/facebook/react diff --git a/package.json b/package.json index 10e5ad49..57c7cf71 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,75 @@ { "name": "GCS", - "version": "0.5.1", + "version": "0.7.4", + "license": "MIT", + "author": "Northrop Grumman Collaboration Project", "description": "Ground Control Station for autonomous vehicle platforms in NGCP", + "repository": "https://github.com/NGCP/GCS", "scripts": { - "test": "npm run linttest", - "linttest": "eslint --ext .js,.jsx \".\" && stylelint \"**/*.css\"", - "postinstall": "electron-builder install-app-deps", - "lint": "eslint --fix --ext .js,.jsx \".\" && stylelint \"**/*.css\" --fix", + "postinstall": "electron-rebuild", + "test": "npm run test:lint && npm run test:types && npm run test:unit", + "test:lint": "eslint --ext .js,.jsx,.ts,.tsx \".\" & stylelint \"**/*.css\"", + "test:types": "tsc --project . --outDir dist --pretty", + "test:unit": "mocha --require ts-node/register test/**/*.test.ts", + "lint": "eslint --fix --ext .js,.jsx,.ts,.tsx \".\" & stylelint \"**/*.css\" --fix", "start": "electron-webpack dev", "build": "electron-webpack && electron-builder" }, "dependencies": { - "leaflet": "^1.3.4", - "moment": "^2.22.2", - "pouchdb": "^7.0.0", - "prop-types": "^15.6.2", - "react": "^16.5.2", - "react-dom": "^16.5.2", - "react-leaflet": "^2.1.0", - "react-leaflet-control": "^2.0.0", + "@fortawesome/fontawesome-free": "^5.8.1", + "leaflet": "^1.4.0", + "leaflet.offline": "^1.0.4", + "moment": "^2.24.0", + "msgpack-lite": "^0.1.26", + "prop-types": "^15.7.2", + "rc-slider": "^8.6.9", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-leaflet": "^2.2.1", + "react-leaflet-control": "^2.1.1", "react-virtualized": "^9.21.0", - "serialport": "^6.2.2", - "source-map-support": "^0.5.9", + "serialport": "^7.1.5", + "source-map-support": "^0.5.12", "xbee-api": "^0.6.0" }, "devDependencies": { "@babel/preset-react": "^7.0.0", + "@types/chai": "^4.1.7", + "@types/mocha": "^5.2.6", + "@types/msgpack-lite": "^0.1.6", + "@types/rc-slider": "^8.6.3", + "@types/react": "^16.8.16", + "@types/react-dom": "^16.8.4", + "@types/react-leaflet": "^2.2.1", + "@types/react-virtualized": "^9.21.1", + "@types/serialport": "^7.0.3", + "@typescript-eslint/eslint-plugin": "^1.7.0", + "@typescript-eslint/parser": "^1.7.0", "babel-eslint": "^10.0.1", - "dotenv-webpack": "^1.5.7", - "electron": "^3.0.2", - "electron-builder": "^20.28.4", - "electron-webpack": "2.3.1", - "eslint": "^5.7.0", + "chai": "^4.2.0", + "dotenv-webpack": "^1.7.0", + "electron": "^3.1.9", + "electron-builder": "^20.40.2", + "electron-rebuild": "^1.8.4", + "electron-webpack": "^2.6.2", + "electron-webpack-ts": "^3.1.1", + "eslint": "^5.16.0", "eslint-config-airbnb": "^17.1.0", - "eslint-plugin-import": "^2.14.0", + "eslint-import-resolver-typescript": "^1.1.1", + "eslint-plugin-chai-friendly": "^0.4.1", + "eslint-plugin-import": "^2.17.2", "eslint-plugin-jsx-a11y": "^6.2.1", - "eslint-plugin-react": "^7.11.1", - "stylelint": "^9.6.0", - "stylelint-config-standard": "^18.2.0", - "stylelint-order": "^1.0.0", - "webpack": "^4.22.0" + "eslint-plugin-react": "^7.13.0", + "mocha": "^6.1.4", + "stylelint": "^10.0.1", + "stylelint-config-standard": "^18.3.0", + "stylelint-order": "^3.0.0", + "ts-node": "^8.1.0", + "typescript": "^3.4.5", + "webpack": "^4.30.0" + }, + "build": { + "npmRebuild": false }, "electronWebpack": { "main": { @@ -48,15 +78,5 @@ "renderer": { "webpackConfig": "webpack.config.js" } - }, - "repository": { - "type": "git", - "url": "git+https://github.com/NGCP/GCS.git" - }, - "author": "Northrop Grumman Collaboration Project", - "license": "MIT", - "bugs": { - "url": "https://github.com/NGCP/GCS/issues" - }, - "homepage": "https://github.com/NGCP/GCS" + } } diff --git a/resources/config.json b/resources/config.json deleted file mode 100644 index b1c74fe5..00000000 --- a/resources/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "fixtures": true, - "cache": false, - "geolocation": true -} diff --git a/resources/images/other/fullmoon.png b/resources/images/other/fullmoon.png deleted file mode 100644 index 4c92247d..00000000 Binary files a/resources/images/other/fullmoon.png and /dev/null differ diff --git a/resources/images/other/fullmoon_hover.png b/resources/images/other/fullmoon_hover.png deleted file mode 100644 index 9f7cb171..00000000 Binary files a/resources/images/other/fullmoon_hover.png and /dev/null differ diff --git a/resources/images/other/geolocation.png b/resources/images/other/geolocation.png deleted file mode 100644 index 04efd561..00000000 Binary files a/resources/images/other/geolocation.png and /dev/null differ diff --git a/resources/images/other/geolocation_hover.png b/resources/images/other/geolocation_hover.png deleted file mode 100644 index 8d4d2521..00000000 Binary files a/resources/images/other/geolocation_hover.png and /dev/null differ diff --git a/resources/images/other/moon.png b/resources/images/other/moon.png deleted file mode 100644 index 5fba0109..00000000 Binary files a/resources/images/other/moon.png and /dev/null differ diff --git a/resources/images/other/moon_hover.png b/resources/images/other/moon_hover.png deleted file mode 100644 index 2ea39f8b..00000000 Binary files a/resources/images/other/moon_hover.png and /dev/null differ diff --git a/resources/images/pin.png b/resources/images/pin.png deleted file mode 100755 index 3d2c6945..00000000 Binary files a/resources/images/pin.png and /dev/null differ diff --git a/resources/index.js b/resources/index.js deleted file mode 100644 index 681aa9b1..00000000 --- a/resources/index.js +++ /dev/null @@ -1,60 +0,0 @@ -/* When importing anything inside resources, please import through this file */ - -/* eslint-disable camelcase */ - -import arrow from './images/arrow.png'; -import icon from './images/icon.png'; -import pin from './images/pin.png'; - -import ngcp_calpoly from './images/logo/ngcp_calpoly.png'; -import ngcp_pomona from './images/logo/ngcp_pomona.png'; - -import poi_fp from './images/markers/poi_fp.png'; -import poi_unknwn from './images/markers/poi_unkwn.png'; -import poi_vld from './images/markers/poi_vld.png'; - -import uav_red from './images/markers/vehicles/uav_red.png'; -import uav from './images/markers/vehicles/uav.png'; -import ugv_red from './images/markers/vehicles/ugv_red.png'; -import ugv from './images/markers/vehicles/ugv.png'; - -import fullmoon_hover from './images/other/fullmoon_hover.png'; -import fullmoon from './images/other/fullmoon.png'; -import geolocation_img_hover from './images/other/geolocation_hover.png'; -import geolocation_img from './images/other/geolocation.png'; -import moon_hover from './images/other/moon_hover.png'; -import moon from './images/other/moon.png'; - -import { cache, fixtures, geolocation } from './config.json'; -import { startLocation, locations } from './locations.json'; -import * as macAddress from './mac-address.json'; - -export { - cache, fixtures, geolocation, startLocation, locations, macAddress, -}; - -export const images = { - arrow, - icon, - pin, - logo: { ngcp_calpoly, ngcp_pomona }, - markers: { - poi_fp, - poi_unknwn, - poi_vld, - vehicles: { - uav_red, - uav, - ugv_red, - ugv, - }, - }, - other: { - fullmoon_hover, - fullmoon, - geolocation_hover: geolocation_img_hover, - geolocation: geolocation_img, - moon_hover, - moon, - }, -}; diff --git a/resources/locations.json b/resources/locations.json deleted file mode 100644 index 977b38da..00000000 --- a/resources/locations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "startLocation": "Cal Poly SLO", - "locations": { - "Cal Poly SLO": { - "latitude": 35.306205, - "longitude": -120.662227, - "zoom": 18 - }, - "Cal Poly Pomona": { - "latitude": 34.055869, - "longitude": -117.819964, - "zoom": 18 - } - } -} diff --git a/resources/mac-address.json b/resources/mac-address.json deleted file mode 100644 index d3243be0..00000000 --- a/resources/mac-address.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "isr": "", - "ipd": "", - "ugv": "", - "vtol": "" -} diff --git a/src/common/MessageHandler.ts b/src/common/MessageHandler.ts new file mode 100644 index 00000000..93affcd5 --- /dev/null +++ b/src/common/MessageHandler.ts @@ -0,0 +1,217 @@ +import { Event, ipcRenderer } from 'electron'; + +import { config, vehicleConfig } from '../static/index'; + +import * as Message from '../types/message'; + +import ipc from '../util/ipc'; + +import DictionaryList from './struct/DictionaryList'; +import UpdateHandler from './struct/UpdateHandler'; + +import xbee from './Xbee'; + +class MessageHandler { + /** + * Dictionary for all messages to be sent. Messages are in a list mapped by the vehicle + * id they are to be sent to. + */ + private outbox = new DictionaryList(); + + /** + * Messages that are currently being sent, map by the vehicle id it is being sent to. + */ + private sending = new Map(); + + /** + * Handler that listens for different update events. + */ + private updateHandler = new UpdateHandler(); + + /** + * ID of message being sent. + */ + private id = 0; + + /** + * Map of last message ID received from specific vehicle. + */ + private receivedMessageId: { [vehicleId: number]: number | undefined } = {}; + + public constructor() { + ipcRenderer.on('sendMessage', (_: Event, vehicleId: number, message: Message.Message): void => this.sendMessage(vehicleId, message)); + ipcRenderer.on('receiveMessage', (_: Event, message: any): void => this.receiveMessage(message)); // eslint-disable-line @typescript-eslint/no-explicit-any + + ipcRenderer.on('stopSendingMessage', (_: Event, ackMessage: Message.JSONMessage): void => this.stopSendingMessage(ackMessage)); + ipcRenderer.on('stopSendingMessages', (): void => this.stopSendingMessages()); + } + + /** + * Sends message to vehicle through Xbee. + * Acknowledgement/Connection Acknowledgement/Bad messages are all sent here, but + * all other messages are forwarded to sendMessageAsync. All messages except the + * three mentioned above are only sent one at a time (and everything else will) + * be stored in the outbox to be sent afterwards. + * + * @param vehicleId Vehicle id to send the message to. + * @param message Message format (note this does not have sid, tid, id, and time). + */ + private sendMessage(vehicleId: number, message: Message.Message): void { + const jsonMessage: Message.JSONMessage = { + id: this.id, + sid: 0, + tid: vehicleId, + time: Date.now(), + ...message, + }; + + this.id += 1; + + if (Message.TypeGuard.isAcknowledgementMessage(message) + || Message.TypeGuard.isConnectionAcknowledgementMessage(message) + || Message.TypeGuard.isBadMessage(message)) { + xbee.sendMessage(jsonMessage); + return; + } + + if (this.sending.has(jsonMessage.sid)) { + this.outbox.push(`${jsonMessage.sid}`, jsonMessage); + return; + } + + this.sendMessageAsync(jsonMessage); + } + + /** + * Either sends the message repeatedly or adds it to the outbox to be + * sent later. Once the message is acknowledged, the next one will be sent, + * if one is in the outbox. + */ + private sendMessageAsync(jsonMessage: Message.JSONMessage): void { + xbee.sendMessage(jsonMessage); + const expiry = setInterval( + (): void => xbee.sendMessage(jsonMessage), + config.messageSendRate * 1000, + ); + + // Keep track of message, to stop sending it once acknowledged. + const hash = `${jsonMessage.tid}#${jsonMessage.id}`; + this.sending.set(jsonMessage.tid, jsonMessage); + + // Callback when this message is acknowledged. + const onAcknowledge = (): boolean => { + clearInterval(expiry); + if (this.outbox.size(`${jsonMessage.tid}`) > 0) { + const nextMessage = this.outbox.shift(`${jsonMessage.tid}`) as Message.JSONMessage; + this.sendMessageAsync(nextMessage); + } else { + this.sending.delete(jsonMessage.tid); + } + return true; + }; + + const onDisconnect = (): void => { + clearInterval(expiry); + this.sending.delete(jsonMessage.tid); + this.outbox.clear(`${jsonMessage.tid}`); + ipc.postDisconnectFromVehicle(jsonMessage.tid); + }; + + this.updateHandler.addHandler(hash, + (): boolean => onAcknowledge(), { + time: config.vehicleDisconnectionTime * 1000, + callback: (): void => onDisconnect(), + }); + } + + /** + * Send a bad message. + * + * @param jsonMessage The bad message. + * @param error Error message. + */ + private sendBadMessage(jsonMessage: Message.JSONMessage, error?: string): void { + this.sendMessage(jsonMessage.sid, { + type: 'badMessage', + error, + }); + } + + /** + * Processes a message that was received. + * + * @param text The raw string in the message. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private receiveMessage(message: any): void { + if (!message) return; + + if (!Message.TypeGuard.isJSONMessage(message)) { + if (!Number.isNaN(message.sid) && vehicleConfig.isValidVehicleId(message.sid as number)) { + this.sendBadMessage(message as Message.JSONMessage, 'Invalid message, does not meet requirements for a message'); + } else { + ipc.postLogMessages({ + message: `Received JSON from Xbee that is not a valid message, could not send bad message to sender: ${message}`, + }); + } + return; + } + + const jsonMessage = message as Message.JSONMessage; + + // Ignore messages from unrecognized vehicles. + if (!vehicleConfig.isValidVehicleId(jsonMessage.sid)) return; + + const newMessage = !this.receivedMessageId[jsonMessage.sid] + || this.receivedMessageId[jsonMessage.sid] as number < jsonMessage.id; + + if (Message.TypeGuard.isConnectMessage(jsonMessage)) { + const shouldAcknowledge = !this.receivedMessageId[jsonMessage.sid] + || this.receivedMessageId[jsonMessage.sid] as number <= jsonMessage.id; + ipc.postConnectToVehicle(jsonMessage, newMessage, shouldAcknowledge); + } else if (Message.TypeGuard.isCompleteMessage(jsonMessage)) { + ipc.postHandleCompleteMessage(jsonMessage, newMessage); + } else if (Message.TypeGuard.isPOIMessage(jsonMessage)) { + ipc.postHandlePOIMessage(jsonMessage, newMessage); + } else if (Message.TypeGuard.isUpdateMessage(jsonMessage)) { + ipc.postHandleUpdateMessage(jsonMessage, newMessage); + } else if (Message.TypeGuard.isBadMessage(jsonMessage)) { + if (newMessage) ipc.postHandleBadMessage(jsonMessage, newMessage); + } else if (Message.TypeGuard.isAcknowledgementMessage(jsonMessage)) { + ipc.postHandleAcknowledgementMessage(jsonMessage, newMessage); + } else { + this.sendBadMessage(jsonMessage, `Message of type ${jsonMessage.type} is invalid or is not acceptable by GCS`); + } + + // Logic: https://ground-control-station.readthedocs.io/en/latest/communications/messages/other-messages.html#acknowledgement-message + if (newMessage) { + this.receivedMessageId[jsonMessage.sid] = jsonMessage.id; + } + } + + /** + * Stops sending certain message, specified by ackid and tid of message. + */ + private stopSendingMessage(ackMessage: Message.JSONMessage): void { + const hash = `${ackMessage.sid}#${(ackMessage as Message.AcknowledgementMessage).ackid}`; + this.updateHandler.event(hash, true); + } + + /** + * Stops sending all messages. + */ + private stopSendingMessages(): void { + this.outbox.clear(); + + this.sending.forEach((jsonMessage): void => { + const hash = `${jsonMessage.tid}#${jsonMessage.id}`; + this.updateHandler.event(hash, true); + }); + } +} + +/* + * This allows only one instance of the MessageHandler to be used. + * All requests are passed through an ipcRenderer notification. + */ +export default new MessageHandler(); diff --git a/src/common/Orchestrator.ts b/src/common/Orchestrator.ts new file mode 100644 index 00000000..a51f3651 --- /dev/null +++ b/src/common/Orchestrator.ts @@ -0,0 +1,433 @@ +import { ipcRenderer, Event } from 'electron'; + +import { + config, + vehicleConfig, + VehicleInfo, +} from '../static/index'; + +import * as Message from '../types/message'; +import * as MissionInformation from '../types/missionInformation'; +import * as Task from '../types/task'; + +import ipc from '../util/ipc'; + +import missionObject from './missions/index'; + +import Mission from './struct/Mission'; +import Vehicle from './struct/Vehicle'; + +import './MessageHandler'; + +class Orchestrator { + /** + * Posts error message when error happens in Orchestrator. + */ + private static postOrchestratorError(error: string): void { + ipc.postLogMessages({ + type: 'failure', + message: `Something wrong happened in Orchestrator: ${error}`, + }); + } + + /** + * Acknowledges a message. All messages are passed here through the MessageHandler. + * Only messages that are no acknowledged are bad messages and acknowledgements. + */ + private static acknowledgeMessage(jsonMessage: Message.JSONMessage): void { + ipc.postSendMessage(jsonMessage.sid, { + type: 'ack', + ackid: jsonMessage.id, + }); + } + + /** + * List of mission names and information for each mission. + */ + private missions: MissionInformation.Information[] = []; + + /** + * Current index of missions that mission is performing. + * Value is -1 if no mission is being performed. + */ + private currentMissionIndex = -1; + + /** + * True if a mission is running, false otherwise. + */ + private running = false; + + /** + * True if user wants confirmation between missions, false otherwise. + */ + private requireConfirmation = false; + + /** + * Current Mission being performed. Value is null + * if no mission is being performed. + */ + private currentMission: Mission | null = null; + + /** + * All vehicles that have connected to the GCS. + */ + private vehicles: { [vehicleId: number]: Vehicle } = {}; + + /** + * Used for a mission to process which vehicle is doing which job. + */ + private activeVehicleMapping: MissionInformation.ActiveVehicleMapping | null = null; + + /** + * Used for a mission to know options for specific missions. + */ + private options: MissionInformation.MissionOptions | null = null; + + public constructor() { + ipcRenderer.on('connectToVehicle', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean, shouldAcknowledge: boolean): void => this.connectToVehicle(jsonMessage, newMessage, shouldAcknowledge)); + ipcRenderer.on('disconnectFromVehicle', (_: Event, vehicleId: number): void => this.disconnectFromVehicle(vehicleId)); + + ipcRenderer.on('handleAcknowledgementMessage', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean): void => this.handleAcknowledgementMessage(jsonMessage, newMessage)); + ipcRenderer.on('handleBadMessage', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean): void => this.handleBadMessage(jsonMessage, newMessage)); + ipcRenderer.on('handleUpdateMessage', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean): void => this.handleUpdateMessage(jsonMessage, newMessage)); + ipcRenderer.on('handlePOIMessage', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean): void => this.handlePOIMessage(jsonMessage, newMessage)); + ipcRenderer.on('handleCompleteMessage', (_: Event, jsonMessage: Message.JSONMessage, newMessage: boolean): void => this.handleCompleteMessage(jsonMessage, newMessage)); + + ipcRenderer.on('startMissions', ( + _: Event, + missions: MissionInformation.Information[], + activeVehicleMapping: MissionInformation.ActiveVehicleMapping, + options: MissionInformation.MissionOptions, + requireConfirmation: boolean, + ): void => this.startMissions(missions, activeVehicleMapping, options, requireConfirmation)); + ipcRenderer.on('pauseMission', (): void => this.pauseMission()); + ipcRenderer.on('resumeMission', (): void => this.resumeMission()); + ipcRenderer.on('startNextMission', (): void => this.startNextMission()); + ipcRenderer.on('completeMission', (_: Event, missionName: string, completionParameters: Task.TaskParameters[]): void => this.completeMission(missionName, completionParameters)); + ipcRenderer.on('stopMissions', (): void => this.stopMissions()); + } + + /** + * Connects to vehicle specified by the connect message from message handler. + * @param jsonMessage The connect message. + */ + private connectToVehicle( + jsonMessage: Message.JSONMessage, + newMessage: boolean, + shouldAcknowledge: boolean, + ): void { + if (newMessage) { + if (!this.vehicles[jsonMessage.sid]) { + this.vehicles[jsonMessage.sid] = new Vehicle({ + sid: jsonMessage.sid, + jobs: (jsonMessage as Message.ConnectMessage).jobsAvailable, + status: 'ready', + }); + + setTimeout( + (): void => this.ping(this.vehicles[jsonMessage.sid]), + config.vehicleDisconnectionTime * 1000, + ); + } else { + this.vehicles[jsonMessage.sid].connect(); + } + + ipc.postLogMessages({ + type: 'success', + message: `${(vehicleConfig.vehicleInfos[jsonMessage.sid] as VehicleInfo).name} has connected`, + }); + } + + if (shouldAcknowledge) ipc.postSendMessage(jsonMessage.sid, { type: 'connectionAck' }); + } + + /** + * Checks if the vehicle is still connected. Does not send a message to the vehicle, + * simply checks the last time the vehicle has connected with the GCS and uses that. + * @param vehicle Vehicle to "ping". + */ + private ping(vehicle: Vehicle): void { + const delta = Math.max(0, Date.now() - vehicle.getLastConnectionTime()); + + if (delta <= config.vehicleDisconnectionTime * 1000) { + // Handler that expires and creates itself everytime it "pings" the vehicle. + setTimeout( + (): void => { this.ping(vehicle); }, + config.vehicleDisconnectionTime * 1000 - delta, + ); + } else { + this.disconnectFromVehicle(vehicle.getVehicleId()); + } + } + + /** + * Disconnects from a vehicle. + * @param vehicleId ID of vehicle to disconnect from. + */ + private disconnectFromVehicle(vehicleId: number): void { + if (this.vehicles[vehicleId].getStatus() === 'disconnected') return; + + this.vehicles[vehicleId].disconnect(); + ipc.postUpdateVehicles(this.vehicles[vehicleId].toObject()); + + ipc.postLogMessages({ + type: 'failure', + message: `${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name} has disconnected`, + }); + } + + /** + * Handles acknowledgement messages from the message handler. + * @param jsonMessage Message from vehicle. + */ + private handleAcknowledgementMessage( + jsonMessage: Message.JSONMessage, + newMessage: boolean, + ): void { + if (!this.vehicles[jsonMessage.sid] || this.vehicles[jsonMessage.sid].getStatus() === 'disconnected') return; + if (!newMessage) return; + + this.vehicles[jsonMessage.sid].update(jsonMessage); + + ipc.postStopSendingMessage(jsonMessage); + } + + /** + * Handles bad messages from the message handler. + * @param jsonMessage Message from vehicle. + */ + private handleBadMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + if (this.vehicles[jsonMessage.sid].getStatus() === 'disconnected') return; + if (!newMessage) return; + + this.vehicles[jsonMessage.sid].update(jsonMessage); + + const badMessage = jsonMessage as Message.BadMessage; + ipc.postLogMessages({ + type: 'failure', + message: `Received bad message from ${(vehicleConfig.vehicleInfos[jsonMessage.sid] as VehicleInfo).name}: ${badMessage.error || 'No error message specified'}}`, + }); + } + + /** + * Handles update messages from the message handler. + * @param jsonMessage Message from vehicle. + */ + private handleUpdateMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + if (this.vehicles[jsonMessage.sid].getStatus() === 'disconnected') return; + + if (newMessage) { + this.vehicles[jsonMessage.sid].update(jsonMessage); + if (this.currentMission && this.currentMission.getVehicles()[jsonMessage.sid]) { + this.currentMission.update(jsonMessage); + } else { + const status = this.vehicles[jsonMessage.sid].getStatus(); + if (!config.fixtures && (status === 'waiting' || status === 'running' || status === 'paused')) { + this.vehicles[jsonMessage.sid].stop(); + } + } + + ipc.postUpdateVehicles(this.vehicles[jsonMessage.sid].toObject()); + } + + Orchestrator.acknowledgeMessage(jsonMessage); + } + + /** + * Handles point of interest messages from the message handler. + * @param jsonMessage Message from vehicle. + */ + private handlePOIMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + if (this.vehicles[jsonMessage.sid].getStatus() === 'disconnected') return; + + if (newMessage) { + if (this.currentMission && !this.currentMission.getVehicles()[jsonMessage.sid]) { + ipc.postLogMessages({ + type: 'failure', + message: `Received point of interest message from ${vehicleConfig.vehicleInfos[jsonMessage.sid]} while it is not assigned to mission`, + }); + return; + } + + this.vehicles[jsonMessage.sid].update(jsonMessage); + if (this.currentMission) this.currentMission.update(jsonMessage); + } + + Orchestrator.acknowledgeMessage(jsonMessage); + } + + /** + * Handles complete messages from the message handler. + * @param jsonMessage Message from vehicle. + */ + private handleCompleteMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + if (this.vehicles[jsonMessage.sid].getStatus() === 'disconnected') return; + + if (newMessage) { + if (!this.currentMission) { + ipc.postLogMessages({ + type: 'failure', + message: `Received complete message from ${vehicleConfig.vehicleInfos[jsonMessage.sid]} while no mission is running`, + }); + return; + } + + if (!this.currentMission.getVehicles()[jsonMessage.sid]) { + ipc.postLogMessages({ + type: 'failure', + message: `Received complete message from ${vehicleConfig.vehicleInfos[jsonMessage.sid]} while it is not assigned to the mission`, + }); + return; + } + + this.currentMission.update(jsonMessage); + } + + Orchestrator.acknowledgeMessage(jsonMessage); + } + + /** + * Checks if all missionName fields in each mission is valid, then runs the FIRST mission of + * the provided missions. + * + * @param missions Description for all missions. Includes name of mission and information + * required for mission. + */ + private startMissions( + missions: MissionInformation.Information[], + activeVehicleMapping: MissionInformation.ActiveVehicleMapping, + options: MissionInformation.MissionOptions, + requireConfirmation: boolean, + ): void { + if (this.running) { + Orchestrator.postOrchestratorError('Cannot start new missions while missions are already running'); + return; + } + + if (missions.length === 0) { + Orchestrator.postOrchestratorError('Cannot start new missions with no missions provided'); + return; + } + + this.running = true; + this.missions = missions; + this.activeVehicleMapping = activeVehicleMapping; + this.options = options; + this.requireConfirmation = requireConfirmation; + this.currentMissionIndex = 0; + + this.startMission(); + } + + /** + * Sends pause message to all vehicles (regardless if they're running a mission or not). + */ + private pauseMission(): void { + if (!this.running) return; + + Object.values(this.vehicles).forEach((vehicle): void => { + ipc.postSendMessage(vehicle.getVehicleId(), { + type: 'pause', + }); + }); + } + + /** + * Sends pause message to all vehicles (regardless if they're running a mission or not). + */ + private resumeMission(): void { + if (!this.running) return; + + Object.values(this.vehicles).forEach((vehicle): void => { + ipc.postSendMessage(vehicle.getVehicleId(), { + type: 'resume', + }); + }); + } + + /** + * Happens when a mission completes. Either starts the next mission automatically, + * ask user for confirmation before starting next mission, or ends missions. + * + * @param missionName Name of mission that has completed. Used for ensuring missions are + * being kept track of. + * @param completionParameters Paramters for next mission. + */ + private completeMission(missionName: string, completionParameters: Task.TaskParameters[]): void { + if (!this.running + || (this.currentMissionIndex >= 0 + && this.missions[this.currentMissionIndex].missionName !== missionName)) { + Orchestrator.postOrchestratorError('Invalid mission was completed'); + return; + } + + if (this.currentMissionIndex === this.missions.length - 1) { + ipc.postFinishMissions(completionParameters); + this.stopMissions(); + } else { + this.missions[this.currentMissionIndex + 1].parameters = completionParameters; + + if (this.requireConfirmation) { + ipc.postConfirmCompleteMission(); // Start next mission on "startNextMission" notification. + } else { + this.startNextMission(); + } + } + } + + /** + * Starts next mission. + */ + private startNextMission(): void { + if (!this.running) { + Orchestrator.postOrchestratorError('Tried to start next mission while no missions are running'); + return; + } + + this.currentMissionIndex += 1; + this.startMission(); + } + + /** + * Support function to start a mission. Uses currentMissionIndex to determine which + * mission to start. Ensure to set the currentMissionIndex properly before calling + * this function. + */ + private startMission(): void { + const missionObj = missionObject[this.missions[this.currentMissionIndex].missionName]; + + if (!this.options || !this.activeVehicleMapping) { + Orchestrator.postOrchestratorError('Tried to start mission while options/mapping are null'); + return; + } + + this.currentMission = new missionObj.Mission( + this.vehicles, + this.missions[this.currentMissionIndex], + this.activeVehicleMapping, + this.options, + ); + + this.currentMission.initialize(); + } + + /** + * Clears all missions in Orchestrator. Run after all missions are completed or + * mission is stopped. + */ + private stopMissions(): void { + this.currentMissionIndex = -1; + this.currentMission = null; + this.missions = []; + this.running = false; + this.requireConfirmation = true; + this.activeVehicleMapping = null; + this.options = null; + } +} + +/* + * This allows only one instance of the Orchestrator to be used. + * All requests are passed through an ipcRenderer notification. + */ +export default new Orchestrator(); diff --git a/src/common/Xbee.ts b/src/common/Xbee.ts new file mode 100644 index 00000000..2f52dcd7 --- /dev/null +++ b/src/common/Xbee.ts @@ -0,0 +1,100 @@ +import msgpack from 'msgpack-lite'; +import SerialPort from 'serialport'; +import { constants as C, Frame, XBeeAPI } from 'xbee-api'; + +import { VehicleInfo, vehicleConfig } from '../static/index'; + +import { JSONMessage } from '../types/message'; + +import ipc from '../util/ipc'; + +// TODO: Add a feature with Vehicle container to change this dynamically and reconnect. +const port: string | undefined = '/dev/tty.SLAB_USBtoUART'; + +const serialport = new SerialPort(port, { baudRate: 57600 }, (error): void => { + if (error) { + ipc.postLogMessages({ + type: 'failure', + message: `Failed to initialize Xbee: ${error.message}`, + }); + } +}); + +const xbeeAPI = new XBeeAPI(); + +/** + * Sends a message through the Xbee to a given vehicle. Will not execute if Xbee port + * is not open. + * + * @param vehicleId The vehicle id to send this message to. + * @param message The message to send to the vehicle. + */ +function sendMessage(message: JSONMessage): void { + if (!serialport.isOpen) return; + + const vehicleInfoObject = vehicleConfig.vehicleInfos[message.tid]; + if (!vehicleInfoObject) { + ipc.postLogMessages({ + type: 'failure', + message: `Failed to send message to vehicle with id ${message.tid}`, + }); + return; + } + + const { macAddress } = vehicleInfoObject as VehicleInfo; + + xbeeAPI.builder.write({ + type: C.FRAME_TYPE.ZIGBEE_TRANSMIT_REQUEST, + destination64: macAddress, + data: msgpack.encode(message), + }); +} + +// TODO: Open connection from MessageHandler. +function openConnection(): boolean { + if (serialport.isOpen) return false; + serialport.open(); + return true; +} + +// TODO: Close connection from MessageHandler. +function closeConnection(): boolean { + if (!serialport.isOpen) return false; + serialport.close(); + return true; +} + +serialport.pipe(xbeeAPI.parser); +xbeeAPI.builder.pipe(serialport as NodeJS.WritableStream); + +serialport.on('open', (): void => { + ipc.postLogMessages({ + type: 'success', + message: 'Xbee connection has opened', + }); +}); + +serialport.on('error', (error: Error): void => { + ipc.postLogMessages({ + type: 'failure', + message: `An error has occured with the Xbee connection: ${error.message}`, + }); +}); + +serialport.on('close', (): void => { + ipc.postLogMessages({ + type: 'failure', + message: 'Xbee connection has closed', + }); +}); + +xbeeAPI.parser.on('data', (frame: Frame): void => { + if (frame.type !== C.FRAME_TYPE.ZIGBEE_RECEIVE_PACKET || !frame.data) return; + ipc.postReceiveMessage(msgpack.decode(frame.data)); +}); + +export default { + closeConnection, + openConnection, + sendMessage, +}; diff --git a/src/common/missions/ISRSearch.ts b/src/common/missions/ISRSearch.ts new file mode 100644 index 00000000..7a45cae3 --- /dev/null +++ b/src/common/missions/ISRSearch.ts @@ -0,0 +1,111 @@ +import DictionaryList from '../struct/DictionaryList'; +import Mission from '../struct/Mission'; + +import { JobType, Location } from '../../static/index'; + +import * as Message from '../../types/message'; +import * as MissionInformation from '../../types/missionInformation'; +import { Task, TaskParameters } from '../../types/task'; + +import ipc from '../../util/ipc'; +import { getBoundingBox } from '../../util/util'; + +export const missionName: MissionInformation.MissionName = 'isrSearch'; + +export const jobTypes: JobType[] = ['isrSearch']; + +export class ISRSearch extends Mission { + protected missionName = missionName; + + protected jobTypes = new Set(jobTypes); + + protected addTaskCompare = {}; + + /** + * List of point of interests. + */ + private missionData: Location[] = []; + + protected generateTasks(): DictionaryList | undefined { + const information = this.information as MissionInformation.ISRSearchInformation; + const missionParameters = information.parameters; + const tasks = new DictionaryList(); + + if (!missionParameters) return undefined; + + if (!this.options.isrSearch.noTakeoff) { + tasks.push('isrSearch', { + taskType: 'takeoff', + ...missionParameters.takeoff, + }); + } + + tasks.push('isrSearch', { + taskType: 'isrSearch', + ...missionParameters.isrSearch, + }); + + if (!this.options.isrSearch.noLand) { + tasks.push('isrSearch', { + taskType: 'land', + ...missionParameters.land, + }); + } + + return tasks; + } + + public update(jsonMessage: Message.JSONMessage): void { + super.update(jsonMessage); + + if (Message.TypeGuard.isPOIMessage(jsonMessage) + && this.activeVehicleMapping[this.missionName][jsonMessage.sid] === 'isrSearch') { + const poiMessage = jsonMessage as Message.POIMessage; + this.missionData.push({ + lat: poiMessage.lat, + lng: poiMessage.lng, + }); + } + } + + protected generateCompletionParameters(): { [key: string]: TaskParameters } | undefined { + if (this.missionData.length === 0) { + ipc.postLogMessages({ + type: 'failure', + message: 'No points of interests were found in ISR search', + }); + return undefined; // Mission will stop from this, not complete. + } + + const boundingBox = getBoundingBox(this.missionData, 15); + + return { + quickScan: { + waypoints: [ + { + lat: boundingBox.top, + lng: boundingBox.left, + }, + { + lat: boundingBox.top, + lng: boundingBox.right, + }, + { + lat: boundingBox.bottom, + lng: boundingBox.left, + }, + { + lat: boundingBox.bottom, + lng: boundingBox.right, + }, + ], + }, + }; + } +} + +export default { + missionName, + jobTypes, + Mission: ISRSearch, +}; diff --git a/src/common/missions/PayloadDrop.ts b/src/common/missions/PayloadDrop.ts new file mode 100644 index 00000000..4866cef3 --- /dev/null +++ b/src/common/missions/PayloadDrop.ts @@ -0,0 +1,59 @@ +import DictionaryList from '../struct/DictionaryList'; +import Mission from '../struct/Mission'; + +import { JobType } from '../../static/index'; + +import * as MissionInformation from '../../types/missionInformation'; +import { Task, TaskParameters } from '../../types/task'; + +export const missionName: MissionInformation.MissionName = 'payloadDrop'; + +export const jobTypes: JobType[] = ['payloadDrop']; + +export class PayloadDrop extends Mission { + protected missionName = missionName; + + protected jobTypes = new Set(jobTypes); + + protected addTaskCompare = {}; + + protected generateTasks(): DictionaryList | undefined { + const information = this.information as MissionInformation.PayloadDropInformation; + const missionParameters = information.parameters; + const tasks = new DictionaryList(); + + if (!missionParameters) return undefined; + + if (!this.options.payloadDrop.noTakeoff) { + tasks.push('payloadDrop', { + taskType: 'takeoff', + ...missionParameters.takeoff, + }); + } + + tasks.push('payloadDrop', { + taskType: 'payloadDrop', + ...missionParameters.payloadDrop, + }); + + if (!this.options.payloadDrop.noLand) { + tasks.push('payloadDrop', { + taskType: 'land', + ...missionParameters.land, + }); + } + + return tasks; + } + + // eslint-disable-next-line class-methods-use-this + protected generateCompletionParameters(): { [key: string]: TaskParameters } | undefined { + return {}; + } +} + +export default { + missionName, + jobTypes, + Mission: PayloadDrop, +}; diff --git a/src/common/missions/UGVRescue.ts b/src/common/missions/UGVRescue.ts new file mode 100644 index 00000000..4f214654 --- /dev/null +++ b/src/common/missions/UGVRescue.ts @@ -0,0 +1,50 @@ +import DictionaryList from '../struct/DictionaryList'; +import Mission from '../struct/Mission'; + +import { JobType } from '../../static/index'; + +import * as MissionInformation from '../../types/missionInformation'; +import { Task, TaskParameters } from '../../types/task'; + +export const missionName: MissionInformation.MissionName = 'ugvRescue'; + +export const jobTypes: JobType[] = ['ugvRescue']; + +export class UGVRescue extends Mission { + protected missionName = missionName; + + protected jobTypes = new Set(jobTypes); + + protected addTaskCompare = {}; + + protected generateTasks(): DictionaryList | undefined { + const information = this.information as MissionInformation.UGVRescueInformation; + const missionParameters = information.parameters; + const tasks = new DictionaryList(); + + if (!missionParameters) return undefined; + + tasks.push('ugvRescue', { + taskType: 'retrieveTarget', + ...missionParameters.retrieveTarget, + }); + + tasks.push('ugvRescue', { + taskType: 'deliverTarget', + ...missionParameters.deliverTarget, + }); + + return tasks; + } + + // eslint-disable-next-line class-methods-use-this + protected generateCompletionParameters(): { [key: string]: TaskParameters } | undefined { + return {}; + } +} + +export default { + missionName, + jobTypes, + Mission: UGVRescue, +}; diff --git a/src/common/missions/UUVRescue.ts b/src/common/missions/UUVRescue.ts new file mode 100644 index 00000000..c920d85a --- /dev/null +++ b/src/common/missions/UUVRescue.ts @@ -0,0 +1,45 @@ +import DictionaryList from '../struct/DictionaryList'; +import Mission from '../struct/Mission'; + +import { JobType } from '../../static/index'; + +import * as MissionInformation from '../../types/missionInformation'; +import { Task, TaskParameters } from '../../types/task'; + +export const missionName: MissionInformation.MissionName = 'uuvRescue'; + +export const jobTypes: JobType[] = ['uuvRescue']; + +export class UUVRescue extends Mission { + protected missionName = missionName; + + protected jobTypes = new Set(jobTypes); + + protected addTaskCompare = {}; + + // eslint-disable-next-line class-methods-use-this + protected generateTasks(): DictionaryList | undefined { + const information = this.information as MissionInformation.UUVRescueInformation; + const missionParameters = information.parameters; + const tasks = new DictionaryList(); + + if (!missionParameters) return undefined; + + tasks.push('uuvRescue', { + taskType: 'retrieveTarget', + }); + + return tasks; + } + + // eslint-disable-next-line class-methods-use-this + protected generateCompletionParameters(): { [key: string]: TaskParameters } | undefined { + return {}; + } +} + +export default { + missionName, + jobTypes, + Mission: UUVRescue, +}; diff --git a/src/common/missions/VTOLSearch.ts b/src/common/missions/VTOLSearch.ts new file mode 100644 index 00000000..1b7f89a3 --- /dev/null +++ b/src/common/missions/VTOLSearch.ts @@ -0,0 +1,163 @@ +import DictionaryList from '../struct/DictionaryList'; +import Mission from '../struct/Mission'; + +import { JobType, Location } from '../../static/index'; + +import * as Message from '../../types/message'; +import * as MissionInformation from '../../types/missionInformation'; +import * as Task from '../../types/task'; + +import ipc from '../../util/ipc'; +import { getDistance } from '../../util/util'; + +import Vehicle from '../struct/Vehicle'; + +export const missionName: MissionInformation.MissionName = 'vtolSearch'; + +export const jobTypes: JobType[] = ['quickScan', 'detailedSearch']; + +interface POITracker { + location: Location; + valid: boolean; +} + +export class VTOLSearch extends Mission { + protected missionName = missionName; + + protected jobTypes = new Set(jobTypes); + + protected addTaskCompare = { + detailedSearch: (a: Task.Task, b: Task.Task): number => { + const vehicle = this.getDetailedSearchVehicle(); + if (!vehicle) return 1; + + const xa = a as Task.DetailedSearchTask; + const xb = b as Task.DetailedSearchTask; + + return getDistance({ lat: vehicle.getLat(), lng: vehicle.getLng() }, xa) + - getDistance({ lat: vehicle.getLat(), lng: vehicle.getLng() }, xb); + }, + }; + + /** + * Point of interest. + */ + private missionData: Location | null = null; + + /** + * Keeps track of the current detailed search task to see if the POI is valid or not. + */ + private currentDetailedSearchTask: { + [vehicleId: number]: POITracker | undefined; + } = {}; + + private getDetailedSearchVehicle(): Vehicle | undefined { + const vehicleIdString = Object.keys(this.activeVehicleMapping) + .find((idString): boolean => { + const id = parseInt(idString, 10); + return this.activeVehicleMapping[this.missionName][id] === 'detailedSearch'; + }); + if (!vehicleIdString) return undefined; + + const vehicleId = parseInt(vehicleIdString, 10); + return this.vehicles[vehicleId]; + } + + protected generateTasks(): DictionaryList | undefined { + const information = this.information as MissionInformation.VTOLSearchInformation; + const missionParameters = information.parameters; + const tasks = new DictionaryList(); + + if (!missionParameters) return undefined; + + tasks.push('quickScan', { + taskType: 'quickScan', + ...missionParameters.quickScan, + }); + + return tasks; + } + + protected assignTask(vehicle: Vehicle, task: Task.Task): boolean { + if (Task.TypeGuard.isDetailedSearchTask(task)) { + const dtask = task as Task.DetailedSearchTask; + this.currentDetailedSearchTask[vehicle.getVehicleId()] = { + location: { lat: dtask.lat, lng: dtask.lng }, + valid: false, + }; + } + + return super.assignTask(vehicle, task); + } + + public update(jsonMessage: Message.JSONMessage): void { + super.update(jsonMessage); + + if (Message.TypeGuard.isPOIMessage(jsonMessage)) { + const poiMessage = jsonMessage as Message.POIMessage; + + if (this.activeVehicleMapping[this.missionName][jsonMessage.sid] === 'quickScan') { + const location = { lat: poiMessage.lat, lng: poiMessage.lng }; + this.addTask('detailedSearch', { + taskType: 'detailedSearch', + ...location, + }); + + ipc.postUpdatePOIs({ + location, + type: 'unknown', + }); + + return; + } + + if (this.activeVehicleMapping[this.missionName][jsonMessage.sid] === 'detailedSearch') { + this.missionData = { lat: poiMessage.lat, lng: poiMessage.lng }; + + // Sets current task POI as valid, with a check if there is a task right now or not. + const tracker = this.currentDetailedSearchTask[jsonMessage.sid]; + if (tracker) tracker.valid = true; + } + return; + } + + if (Message.TypeGuard.isCompleteMessage(jsonMessage)) { + if (this.activeVehicleMapping[this.missionName][jsonMessage.sid] === 'detailedSearch') { + const tracker = this.currentDetailedSearchTask[jsonMessage.sid]; + if (tracker) { + ipc.postUpdatePOIs({ + location: tracker.location, + type: tracker.valid ? 'valid' : 'invalid', + }); + } + } + } + } + + protected generateCompletionParameters(): { [key: string]: Task.TaskParameters } | undefined { + if (!this.missionData) { + ipc.postLogMessages({ + type: 'failure', + message: 'No points of interest was found in detailed search', + }); + return undefined; // Mission will stop from this, not complete. + } + + return { + payloadDrop: { + lat: this.missionData.lat, + lng: this.missionData.lng, + }, + /* + * TODO: ensure this data passes on correctly to payload drop, cannnot do automatically + * w/o calculations? Will require mission to be run separately for now. + */ + }; + } +} + +export default { + missionName, + jobTypes, + Mission: VTOLSearch, +}; diff --git a/src/common/missions/index.ts b/src/common/missions/index.ts new file mode 100644 index 00000000..686fe80d --- /dev/null +++ b/src/common/missions/index.ts @@ -0,0 +1,48 @@ +/* eslint-disable import/no-named-as-default */ + +import { JobType } from '../../static/index'; + +import { MissionName } from '../../types/missionInformation'; + +import Mission from '../struct/Mission'; + +import ISRSearch from './ISRSearch'; +import PayloadDrop from './PayloadDrop'; +import UGVRescue from './UGVRescue'; +import UUVRescue from './UUVRescue'; +import VTOLSearch from './VTOLSearch'; + +type MissionType = typeof Mission; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface MissionWrapper extends MissionType { } + +/** + * Representation of a Mission subclass in this directory. + */ +export interface MissionObject { + /** + * Name of mission. + */ + missionName: MissionName; + + /** + * Jobs related to mission. + */ + jobTypes: JobType[]; + + /** + * Mission class itself. + */ + Mission: MissionWrapper; +} + +const missionObject: { [missionName in MissionName]: MissionObject } = { + isrSearch: ISRSearch, + vtolSearch: VTOLSearch, + payloadDrop: PayloadDrop, + ugvRescue: UGVRescue, + uuvRescue: UUVRescue, +}; + +export default missionObject; diff --git a/src/common/struct/DictionaryList.ts b/src/common/struct/DictionaryList.ts new file mode 100644 index 00000000..677b16fa --- /dev/null +++ b/src/common/struct/DictionaryList.ts @@ -0,0 +1,278 @@ +export type Callback = (value: T, index: number, array: T[]) => boolean; +export type CompareFunction = (a: T, b: T) => number; + +/** + * Structure that can store lists in a dictionary. Lists can be obtained with a key string, + * and can be modified with custom functions. + */ +export default class DictionaryList { + /** + * Object of string keys to generic type arrays for the class to use. + */ + private dictionary: { [key: string]: T[] | undefined } = {}; + + /** + * Total number of items between all lists in the dictionary. + */ + private numItems = 0; + + /** + * Appends new elements to an array, and returns the new size of the dictionary. + * + * @param key The key to access the list in the dictionary. + */ + public push(key: string, ...values: T[]): number { + if (!this.dictionary[key]) { + this.dictionary[key] = []; + } + + const list = this.dictionary[key] as T[]; + + this.numItems += values.length; + list.push(...values); + + return this.numItems; + } + + /** + * Appends new elements to the front of the array, and returns the new size of + * the dictionary. + * + * @param key The key to access the list in the dictionary. + */ + public unshift(key: string, ...values: T[]): number { + if (!this.dictionary[key]) { + this.dictionary[key] = []; + } + + const list = this.dictionary[key] as T[]; + + this.numItems += values.length; + list.unshift(...values); + + return this.numItems; + } + + /** + * Gets the list stored at the dictionary with the given key. + * + * @param key The key to access the list in the dictionary. + */ + public get(key: string): T[] | undefined { + return this.dictionary[key]; + } + + /** + * Sets the list stored at the dictionary with the given key to the provided list. + * + * @param key The key to access the list in the dictionary. + * @param list The list to change the dictionary's list to. + */ + public set(key: string, list: T[]): void { + const currentLength = this.dictionary[key] ? this.size(key) : 0; + this.dictionary[key] = list; + + this.numItems += list.length - currentLength; + } + + /** + * Gets the oldest element from dictionary's list with the given key + * where the callback is true. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, from oldest to newest. First element to pass + * will be returned. + */ + public find(key: string, callback: Callback): T | undefined { + const list = this.dictionary[key]; + if (!list) return undefined; + + return list.find(callback); + } + + /** + * Removes the oldest element from dictionary's list with the given key + * and returns it. Will return undefined if there is no list at the given key + * or if the list at that key is empty. + * + * @param key The key to access the list in the dictionary. + */ + public shift(key: string): T | undefined { + const list = this.dictionary[key]; + if (!list || list.length === 0) return undefined; + + this.numItems -= 1; + return list.shift(); + } + + /** + * Removes the oldest element from dictionary's list with the given key + * where the callback is true. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, from oldest to newest. First element to pass + * will be removed and returned. + */ + public remove(key: string, callback: Callback): T | undefined { + const list = this.dictionary[key]; + if (!list) return undefined; + + const index = list.findIndex(callback); + if (index === -1) return undefined; + + // Removes the element at that index and returns it. + this.numItems -= 1; + return list.splice(index, 1)[0]; + } + + /** + * Inserts elements to dictionary's list with the given key at an index. If the index + * is larger than the list's size, the element will be added to the back of the list. + * + * @param key The key to access the list in the dictionary + * @param index The index to insert the element at. + * @param values The value to add to the list. + */ + public insert(key: string, index: number, ...values: T[]): number { + const list = this.dictionary[key]; + if (!list || index > list.length) return this.push(key, ...values); + + this.numItems += values.length; + list.splice(index, 0, ...values); + + return this.numItems; + } + + /** + * Removes all elements of an array of a specified key that meets the condition + * specified in a callback function. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, from oldest to newest. All elements to pass + * will be removed and returned in an array. + */ + public removeAll(key: string, callback: Callback): T[] | undefined { + const list = this.dictionary[key]; + if (!list) return undefined; + + const removed: T[] = []; + const kept: T[] = []; + for (let i = 0; i < list.length; i += 1) { + if (callback(list[i], i, list)) { + removed.push(list[i]); + } else { + kept.push(list[i]); + } + } + + this.numItems -= removed.length; + this.dictionary[key] = kept; + return removed; + } + + /** + * Returns the elements of an array of a specified key that meet the condition + * specified in a callback function. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, from oldest to newest. All elements to pass + * will be returned in an array. + */ + public filter(key: string, callback: Callback): T[] | undefined { + const list = this.dictionary[key]; + if (!list) return undefined; + + return list.filter(callback); + } + + /** + * Performs the specified action for each element in an array of a specified key. + */ + public forEach(key: string, callback: (value: T, index: number, array: T[]) => void): void { + const list = this.dictionary[key]; + if (!list) return; + + list.forEach(callback); + } + + /** + * Determines whether the specified callback function returns true for any element of an array. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, and return true if an element passes the callback. + */ + public some(key: string, callback: Callback): boolean { + const list = this.dictionary[key]; + if (!list) return true; + + return list.some(callback); + } + + /** + * Determines whether all the members of an array satisfy the specified test. + * + * @param key The key to access the list in the dictionary. + * @param callback Test each element, and return true if an element passes the callback. + */ + public every(key: string, callback: Callback): boolean { + const list = this.dictionary[key]; + if (!list) return true; + + return list.every(callback); + } + + /** + * Sorts a list for a given key. + * + * @param key The key to access the list in the dictionary. + * @param compareFunction The function to compare the elements in the array. + */ + public sort(key: string, compareFunction: CompareFunction): T[] { + const list = this.dictionary[key]; + if (!list) return []; + + return list.sort(compareFunction); + } + + /** + * Gets all keys of the object whose list value has an element or more. + */ + public keys(): string[] { + return Object.keys(this.dictionary).filter((key): boolean => { + const list = this.dictionary[key]; + if (!list) return false; + + return list.length > 0; + }); + } + + /** + * Gets the total number of items in the lists in the dictionary, or a certain list if + * a key is provided. + * + * @param key The key to access the list in the dictionary. + */ + public size(key?: string): number { + if (key) { + const list = this.dictionary[key]; + if (!list) return 0; + + return list.length; + } + + return this.numItems; + } + + /** + * Clears all items in the dictionary, or a certain list if a key is provided. + * + * @param key The key to access the list in the dictionary. + */ + public clear(key?: string): void { + if (key && this.dictionary[key]) { + this.dictionary[key] = []; + } + + this.dictionary = {}; + } +} diff --git a/src/common/struct/Mission.ts b/src/common/struct/Mission.ts new file mode 100644 index 00000000..10db02a6 --- /dev/null +++ b/src/common/struct/Mission.ts @@ -0,0 +1,563 @@ +import { JobType, vehicleConfig, VehicleInfo } from '../../static/index'; + +import * as Message from '../../types/message'; +import * as MissionInformation from '../../types/missionInformation'; +import { Task, TaskParameters } from '../../types/task'; + +import ipc from '../../util/ipc'; +import { searchIndex } from '../../util/util'; + +import DictionaryList from './DictionaryList'; +import Vehicle from './Vehicle'; +import UpdateHandler from './UpdateHandler'; + +export type MissionStatus = 'ready' | 'initializing' | 'waiting' | 'running'; + +export type CompareTaskFunction = (a: Task, b: Task) => number; + +/** + * Mission backend for the GCS. Automatically creates and assigns tasks. No provided value should + * be undefined (specifically the vehicle mapping). + */ +export default abstract class Mission { + /** + * Name of mission. + */ + protected abstract missionName: MissionInformation.MissionName; + + /** + * Related job types to the mission. + */ + protected abstract jobTypes: Set; + + /** + * All connected vehicles (at least when this class was created). + */ + protected vehicles: { [vehicleId: number]: Vehicle }; + + /** + * Current status of mission. + */ + private status: MissionStatus = 'ready'; + + /** + * Information for this mission, has three fields: + * 1. The mission's name. + * 2. Options for the mission. Generated from the constructor's parameters variable. + * This can determine which tasks are generated as well as other potential things. + * Subclasses implement which tasks get generated so this variable is protected. + * 3. Parameters for the mission. Used to generate tasks. + * Subclasses implement which tasks get generated so this variable is protected. + */ + protected information: MissionInformation.Information; + + /** + * Map of the id of the vehicle that is currently performing all tasks + * to the job they're for. + * + * There will be no job types in this mapping that are irrelevant to the mission. + */ + protected activeVehicleMapping: MissionInformation.ActiveVehicleMapping; + + /** + * Options to generate tasks for the mission. + */ + protected options: MissionInformation.MissionOptions; + + /** + * Comparison to how tasks are added to the waiting task list. If there is no + * comparison function specified for the jobType, then jobs will be added and taken + * on a FILO order (queue). + */ + protected abstract addTaskCompare: + { [jobType: string]: CompareTaskFunction | undefined }; + + /** + * Map of the id of the vehicle to the task it is performing. + */ + private activeTasks = new Map(); + + /** + * All tasks waiting to be executed, mapped by their job type. + * + * Note that these tasks will be set in the Mission superclass. Subclasses are in charge + * of generating the tasks from the generateTasks() function. Subclasses can also add more + * tasks as the mission goes through the update function. + */ + private waitingTasks = new DictionaryList(); + + /** + * All vehicles waiting to be assigned a task, mapped by their job type. + */ + private waitingVehicles = new DictionaryList(); + + /** + * Automates the mission by handling its different states. + */ + private statusEventHandler = new UpdateHandler(); + + public constructor( + vehicles: { [vehicleId: number]: Vehicle }, + information: MissionInformation.Information, + activeVehicleMapping: MissionInformation.ActiveVehicleMapping, + options: MissionInformation.MissionOptions, + ) { + this.vehicles = vehicles; + this.information = information; + this.options = options; + this.activeVehicleMapping = activeVehicleMapping; + + /* + * Create event handler that will handle all changes of mission status: + * 1. Runs assignJobs() when status changes to "initializing". + * 2. Runs startMission() when status changs to "waiting". + * 3. Removes this handler when status goes back to "ready", which means: + * - mission was terminated + * - mission was completed + */ + this.statusEventHandler.addHandler('status', (status): boolean => { + this.status = status; + + if (status === 'initializing') { + this.assignJobs(); + } else if (status === 'waiting') { + this.startMission(); + } + + return status === 'ready'; + }); + } + + /** + * Gets current status of mission. + */ + public getStatus(): MissionStatus { + return this.status; + } + + /** + * Gets vehicles passed on to mission when mission started. + */ + public getVehicles(): { [vehicleId: number]: Vehicle } { + return this.vehicles; + } + + /** + * Initializes the mission. Called from the Message's constructor. + * Will set status to "initializing" if mission successfully initializes. + */ + public initialize(): void { + if (this.status !== 'ready') { + this.stop(false, `Something wrong happened in ${this.missionName} mission`); + return; + } + + // Fails to initialize if provided job types do not match up to this mission's job types. + if (!this.checkActiveVehicleMapping()) { + this.stop(false, `Provided active vehicle mapping for ${this.missionName} is incomplete`); + return; + } + + this.statusEventHandler.event('status', 'initializing'); + } + + /** + * Assigns vehicles the jobs to complete this mission. Called by statusEventHandler + * after initialize(). Will set status to "waiting" if mission successfully + * assigns jobs to vehicles. + */ + private assignJobs(): void { + if (this.status !== 'initializing') { + this.stop(false, `Something wrong happened in ${this.missionName} mission`); + return; + } + + const pendingAssignVehicleIds = Object.keys(this.activeVehicleMapping[this.missionName]) + .map((vehicleIdString): number => parseInt(vehicleIdString, 10)); + + const allVehiclesReady = pendingAssignVehicleIds.every((vehicleId): boolean => this.vehicles[vehicleId].getStatus() === 'ready'); + + if (!allVehiclesReady) { + this.stop(false, `Cannot assign jobs to all vehicles in ${this.missionName} as some are not in a ready state`); + return; + } + + /** + * Callback when a vehicle successfully receives the job assignment. + * Go to "waiting" state when all vehicles have been assigned a job. + */ + const onSuccess = (vehicleId: number): void => { + pendingAssignVehicleIds.splice(pendingAssignVehicleIds.indexOf(vehicleId), 1); + + if (pendingAssignVehicleIds.length === 0) { + ipc.postLogMessages({ + type: 'success', + message: `Assigned jobs to all vehicles for ${this.missionName} mission`, + }); + + this.statusEventHandler.event('status', 'waiting'); + } + }; + + /** + * Callback when a vehicle fails to acknowledge the job assignment. + */ + const onDisconnect = (vehicleId: number): void => { + ipc.postStopSendingMessages(); + this.stop(false, `Failed to assign job to ${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name}, as it has disconnected`); + }; + + /** + * Callback when a vehicle performing a job enters an error state. + */ + const onError = (vehicleId: number, message?: string): void => { + ipc.postLogMessages({ + type: 'failure', + message: `${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name} has entered an error state in ${this.missionName}: ${message || 'No error message specified'}`, + }); + + this.handleUnresponsiveVehicle(vehicleId); + }; + + pendingAssignVehicleIds.forEach((vehicleId): void => { + const jobType = this.activeVehicleMapping[this.missionName][vehicleId]; + + this.vehicles[vehicleId].assignJob(jobType, + (): void => onSuccess(vehicleId), + (): void => onDisconnect(vehicleId), + (message): void => onError(vehicleId, message)); + }); + } + + /** + * Gets all the tasks and starts assigning them to vehicles. Called by statusEventHandler + * after assignJobs(). Will set status to "running" if mission successfully + * assign tasks to vehicles. + */ + private startMission(): void { + if (this.status !== 'waiting') { + this.stop(false, `Something wrong happened in ${this.missionName} mission`); + return; + } + + const jobTasks = this.generateTasks(); + if (!jobTasks) { + this.stop(false, `No tasks were generated from ${this.missionName}`); + return; + } + + const allValidTasksForJobs = jobTasks.keys() + .every((jobType): boolean => vehicleConfig.isValidJobType(jobType as JobType) + || jobTasks.every(jobType, (task): boolean => !vehicleConfig.isValidTaskTypeForJob( + task.taskType, + jobType as JobType, + ))); + + if (!allValidTasksForJobs) { + this.stop(false, `Generated tasks in ${this.missionName} are invalid for their respective jobs`); + return; + } + + jobTasks.keys().forEach((jobType): void => { + if (this.jobTypes.has(jobType as JobType)) { + // Adds tasks in custom order if there is a compare provided for that job type. + if (this.addTaskCompare[jobType]) { + jobTasks.forEach(jobType, (task): void => { + const index = searchIndex( + this.waitingTasks.get(jobType) || [], + task, + this.addTaskCompare[jobType] as CompareTaskFunction, + ); + + this.waitingTasks.insert(jobType, index, task); + }); + } else { + this.waitingTasks.push(jobType, ...(jobTasks.get(jobType) || [])); + } + } + }); + + const pendingAssignVehicleIds = Object.keys(this.activeVehicleMapping[this.missionName]).map( + (vehicleIdString): number => parseInt(vehicleIdString, 10), + ); + + // Puts vehicles in activeVehicleMapping to waitingVehicles. + pendingAssignVehicleIds.forEach((vehicleId): void => { + const jobType = this.activeVehicleMapping[this.missionName][vehicleId]; + const vehicle = this.vehicles[vehicleId]; + + this.waitingVehicles.push(jobType, vehicle); + }); + + const allVehiclesWaiting = pendingAssignVehicleIds.every( + (vehicleId): boolean => this.vehicles[vehicleId].getStatus() === 'waiting', + ); + + if (!allVehiclesWaiting) { + this.stop(false, `Cannot assign tasks to all vehicles in ${this.missionName} as some are not in a waiting state`); + return; + } + + /* + * Assign tasks to vehicles. Will keep assigning tasks to vehicles until there is either + * no more available waiting vehicles or no more available waiting tasks. + * + * The mission is done when there are no more tasks inside activeTasks, as well as no more + * tasks in waitingTasks. + */ + this.waitingVehicles.keys().forEach((jobType): void => { + while (this.waitingTasks.size(jobType) > 0 && this.waitingVehicles.size(jobType) > 0) { + const task = this.waitingTasks.shift(jobType) as Task; + const vehicle = this.waitingVehicles.shift(jobType) as Vehicle; + + this.assignTask(vehicle, task); + this.activeTasks.set(vehicle.getVehicleId(), task); + } + }); + + this.statusEventHandler.event('status', 'running'); + } + + /** + * This will be called from the Orchestrator. The Orchestrator should make sure that the + * vehicleId being provided is a valid vehicleId of a vehicle that was connected when the + * mission was initialized. + * + * Subclasses can override this to check for even more types of messages. + */ + public update(jsonMessage: Message.JSONMessage): void { + if (Message.TypeGuard.isUpdateMessage(jsonMessage)) { + this.vehicles[jsonMessage.sid].update(jsonMessage); + } + + if (Message.TypeGuard.isCompleteMessage(jsonMessage)) { + const jobType = this.activeVehicleMapping[this.missionName][jsonMessage.sid]; + + ipc.postLogMessages({ message: `Finished a task for ${this.missionName}` }); + + /* + * Mission is not yet finished, continue to assign tasks. One of the following will happen: + * 1. Assigns next task to vehicle. + * 2. Puts vehicle to waitingVehicles until new task for that job arrives. + */ + if (this.waitingTasks.size(jobType) > 0) { + const newTask = this.waitingTasks.shift(jobType) as Task; + + if (!this.assignTask(this.vehicles[jsonMessage.sid], newTask)) return; + this.activeTasks.set(jsonMessage.sid, newTask); + } else { + this.waitingVehicles.push(jobType, this.vehicles[jsonMessage.sid]); + this.activeTasks.delete(jsonMessage.sid); + } + + // Mission is finished. + if (this.activeTasks.size === 0 && this.waitingTasks.size() === 0) { + this.stop(true); + } else { + this.stop(false, `${this.missionName} is not able to complete`); // Should never happen. + } + } + } + + /** + * Support function to a new task to the mission. Should only be called on its subclasses + * to add a task. + * + * @param jobType The job to add the task to. + * @param task The task object itself. + * @param addToFront True if the task should be added to the front of the queue + * instead of the back. + */ + protected addTask(jobType: JobType, task: Task, addToFront?: boolean): void { + if (this.status !== 'running') { + this.stop(false, `Tried to add ${task.taskType} task while ${this.missionName} is not running`); + return; + } + + /* + * One of the four will happen: + * 1. Assign task to vehicle right away, if a vehicle is in waitingVehicles. + * 2. Add task to front of waitingTasks if user wants it in the front. + * 3. Add tasks in custom order if there is a compare provided for that job type. + * 4. Add task to the back of waitingTasks (default). + */ + const vehicle = this.waitingVehicles.shift(jobType); + if (vehicle) { + if (!this.assignTask(vehicle, task)) return; + this.activeTasks.set(vehicle.getVehicleId(), task); + } else if (addToFront) { + this.waitingTasks.unshift(jobType, task); + } else if (this.addTaskCompare[jobType] && this.waitingTasks.get(jobType)) { + const index = searchIndex( + this.waitingTasks.get(jobType) as Task[], + task, + this.addTaskCompare[jobType] as CompareTaskFunction, + ); + this.waitingTasks.insert(jobType, index, task); + } else { + this.waitingTasks.push(jobType, task); + } + } + + /** + * Support function to assign a task to a vehicle. Stops mission if mission + * fails to assign task to vehicle. + * + * @param vehicle Vehicle to assign task to. + * @param task Task to assign vehicle. + */ + protected assignTask(vehicle: Vehicle, task: Task): boolean { + const success = vehicle.assignTask(task); + + if (!success) { + ipc.postStopSendingMessages(); + this.stop(false, `Failed to assign task to ${(vehicleConfig.vehicleInfos[vehicle.getVehicleId()] as VehicleInfo).name} as it was not in a waiting state`); + } + + return success; + } + + /** + * Support function to stop the mission. Mission is stopped when an error occurs and it needs + * to be terminated, or when all tasks are successfully finished. + * + * @param success True if the mission was completed, false if the mission was stopped + * manually/through error. + * @param error The error message (if success === false). + */ + private stop(success: boolean, error?: string): void { + if (this.status !== 'ready') { + if (this.status === 'initializing') ipc.postStopSendingMessages(); + + Object.keys(this.activeVehicleMapping[this.missionName]).forEach((vehicleIdString): void => { + const vehicleId = parseInt(vehicleIdString, 10); + this.vehicles[vehicleId].stop(); + }); + } + + this.statusEventHandler.event('status', 'ready'); + + /* + * Generates and prints out parameters. Completion parameters are parameters used + * for the next mission, and termination parameters are the parameters used for this + * mission. + */ + if (success) { + const completionParameters = this.generateCompletionParameters(); + if (!completionParameters) { + this.stop(false, `No parameters were generated from ${this.missionName}`); + return; + } + + ipc.postCompleteMission(this.missionName, completionParameters); + ipc.postLogMessages({ + type: 'success', + message: `Completion parameters for ${this.missionName}: ${JSON.stringify(completionParameters)}`, + }); + } else { + ipc.postStopMissions(); + ipc.postLogMessages({ + type: 'failure', + message: `Stopped ${this.missionName} mission: ${error}`, + }, { + message: `Terminated parameters for ${this.missionName}: ${JSON.stringify(this.information.parameters)}`, + }); + } + } + + /** + * Support function to check that the provided activeVehicleMapping variable is properly set. + * Performs check after the mission class has filtered out all irrelevant jobs from the + * activeVehicleMapping. + * + * Ensures the following: + * 1. All job types provided cover the required job types for this mission. + * 2. There is a vehicle assigned to each job type. + * + * @param activeVehicleMapping User provided map of vehicle to their job type. + */ + private checkActiveVehicleMapping(): boolean { + if (Object.keys(this.activeVehicleMapping[this.missionName]).length === 0) return false; + + const providedJobTypes = new Set( + Object.values(this.activeVehicleMapping[this.missionName]), + ); + + const hasRequiredJobTypes = Array.from(this.jobTypes).every( + (requiredJobType): boolean => providedJobTypes.has(requiredJobType), + ); + + if (!hasRequiredJobTypes) return false; + + const hasValidVehicleAssigned = Object.keys(this.activeVehicleMapping[this.missionName]).map( + (vehicleIdString): number => parseInt(vehicleIdString, 10), + ).every((vehicleId): boolean => vehicleConfig.isValidVehicleId(vehicleId) + && this.vehicles[vehicleId] + && this.vehicles[vehicleId] + .getJobs() + .includes(this.activeVehicleMapping[this.missionName][vehicleId])); + + if (!hasValidVehicleAssigned) return false; + + return true; + } + + /** + * Handles a vehicle that is assigned a job when it goes to an error state. Tries to assign + * another vehicle that can perform the same task and remove that vehicle. If not possible, + * simply stops the mission. + */ + private handleUnresponsiveVehicle(vehicleId: number): void { + // Ignore vehicles that are not related to the mission. + if (!this.activeVehicleMapping[this.missionName][vehicleId]) return; + + if (this.status === 'waiting') { + this.stop(false, `Take manual control over ${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name}`); + } else if (this.status === 'initializing') { + ipc.postStopSendingMessages(); + this.stop(false, `Take manual control over ${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name}`); + } else { + const jobType = this.activeVehicleMapping[this.missionName][vehicleId]; + + delete this.activeVehicleMapping[this.missionName][vehicleId]; + + // Put the task vehicle is performing back to the front of waitingTasks. + if (this.activeTasks.has(vehicleId)) { + const task = this.activeTasks.get(vehicleId) as Task; + this.activeTasks.delete(vehicleId); + this.addTask(jobType, task, true); + } + + // Get the next vehicle that can perform the job to start doing that task. + const newVehicle = this.waitingVehicles.shift(jobType); + const task = this.waitingTasks.shift(jobType); + if (newVehicle && task) { + this.activeVehicleMapping[this.missionName][newVehicle.getVehicleId()] = jobType; + + if (!this.assignTask(newVehicle, task)) return; + this.activeTasks.set(newVehicle.getVehicleId(), task); + + ipc.postLogMessages({ + type: 'success', + message: `Reassigned ${jobType} job to ${(vehicleConfig.vehicleInfos[newVehicle.getVehicleId()] as VehicleInfo).name}`, + }); + } else { + this.stop(false, `Failed to assign manual control over ${(vehicleConfig.vehicleInfos[vehicleId] as VehicleInfo).name}`); + } + } + } + + /** + * Generates all tasks to perform, given this.options and this.parameters. + * Returns undefined if not able to generate tasks. + */ + protected abstract generateTasks(): DictionaryList | undefined; + + /** + * Generates new mission parameters after the mission has completed, either to be given to the + * user or to the next mission. For example, data produced here for an ISR Search mission should + * be data for the VTOL Search mission (of type VTOLSearchMissionParameters). + * Returns undefined if not able to generate parameters. + */ + protected abstract generateCompletionParameters(): { [key: string]: TaskParameters } | undefined; +} diff --git a/src/common/struct/UpdateHandler.ts b/src/common/struct/UpdateHandler.ts new file mode 100644 index 00000000..0b38b7d6 --- /dev/null +++ b/src/common/struct/UpdateHandler.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +type HandlerCheck = (value: T, events?: { [key: string]: any }) => boolean; + +/** + * Options for the timeout for a handler. + */ +interface HandlerTimeoutOptions { + /** + * Function that will execute once the function times out. + */ + callback: () => void; + + /** + * Amount of time (in milliseconds) for the function to time out. + */ + time: number; +} + +interface Handler { + /** + * Function that checks whether or not a given event value passes this handler's standards. + */ + handlerCheck: HandlerCheck; + + /** + * Reference to the setTimeout that is created. This allows us to call clearTimeout when + * deleting the handler while the timeout has not executed. + */ + expiry?: NodeJS.Timeout; + + /** + * Function that will remove the handler. + */ + removeHandler: () => void; +} + +/** + * Class that can create handlers for certain events which will perform callbacks + * when the handler receives such event. Basically it is something that can handle processing + * different types of events with different values. + * + * For example, a handler can be created for an event which will close once it receives + * an event with a certain response. + */ +export default class UpdateHandler { + private eventDictionary: { [key: string]: Handler[] | undefined } = {}; + + /** + * Adds a new handler. + * + * @param name The name of the event. + * @param handlerCheck The function to check whether or not the handler has been handled. + * @param timeout The timeout function and time for the event to expire. + */ + public addHandler( + name: string, + handlerCheck: HandlerCheck, + timeout?: HandlerTimeoutOptions, + ): Handler { + const handler: Handler = { + handlerCheck, + removeHandler: (): void => this.removeHandler(name, handler), + }; + + /* + * If a timeout is provided, the handler will remove itself and run its callback function + * when the timeout runs out. If we need to remove the handler manually and it has a timeout, + * we will need to call the clearTimeout function on the handler's expiry. + */ + if (timeout) { + handler.expiry = setTimeout((): void => { + // Deletes the handler. + if (this.eventDictionary[name]) { + this.eventDictionary[name] = (this.eventDictionary[name] as Handler[]) + .filter((lis): boolean => lis !== handler); + } + + timeout.callback(); + }, timeout.time); + } + + // Adds the handler to the list with the proper event name. + if (!this.eventDictionary[name]) { + this.eventDictionary[name] = []; + } + (this.eventDictionary[name] as Handler[]).push(handler); + + return handler; + } + + /** + * Processes the event based on the handlers stored. + * + * @param name The name of the event that occured. + * @param value The value being passed to the handlers of that event. + * @param events All other events that happened at that time. + */ + public event(name: string, value: T, events?: { [key: string]: any }): void { + if (!this.eventDictionary[name]) return; + + this.eventDictionary[name] = (this.eventDictionary[name] as Handler[]) + .filter((h): boolean => { + const handled = h.handlerCheck(value, events); + + /* + * Removes handler if it has not been triggered yet. Will also clearTimeout + * the expiry if the handler has one so that the timeout will not trigger afterwards. + */ + if (handled) { + if (h.expiry) clearTimeout(h.expiry); + } + return !handled; + }); + } + + /** + * Process all the given events. + * + * @param events Key/value pair of the events that happened and their corresponding values. + */ + public events(events: { [key: string]: any }): void { + Object.keys(events).forEach((key): void => { + this.event(key, events[key], events); + }); + } + + /** + * Remove the handler. The provided name and handler must be the same + * as the ones used when creating the handler. + * + * @param name The name of the event the handler is in. + * @param handler The handler itself. + */ + private removeHandler(name: string, handler: Handler): void { + // Will clearTimeout the handler if it has an expiry. + if (handler.expiry) clearTimeout(handler.expiry); + + // Removes the handler from the event's list of handlers. + if (!this.eventDictionary[name]) return; + this.eventDictionary[name] = (this.eventDictionary[name] as Handler[]) + .filter((h): boolean => h !== handler); + } +} diff --git a/src/common/struct/Vehicle.ts b/src/common/struct/Vehicle.ts new file mode 100644 index 00000000..9de2ddb6 --- /dev/null +++ b/src/common/struct/Vehicle.ts @@ -0,0 +1,307 @@ +import { JobType, vehicleConfig } from '../../static/index'; + +import { + JSONMessage, + Message, +} from '../../types/message'; +import * as Task from '../../types/task'; +import { VehicleObject, VehicleStatus } from '../../types/vehicle'; + +import ipc from '../../util/ipc'; + +import UpdateHandler from './UpdateHandler'; + +type ErrorCallback = (message?: string) => void; + +/** + * Options to initialize a vehicle. + */ +export interface VehicleOptions { + /** + * ID of vehicle coming from connect message. + */ + sid: number; + + /** + * Jobs vehicle can handle. + */ + jobs: JobType[]; + + /** + * Status of vehicle. + */ + status: VehicleStatus; +} + +/** + * Contains data about a specific physical vehicle that the GCS will need to keep track + * of it (missions, information, etc). + * + * Also has functions that allows the GCS to command the physical vehicle by sending it + * tasks. + */ +export default class Vehicle { + /** + * ID of the vehicle. + */ + private vehicleId: number; + + /** + * Current assigned job. + */ + private assignedJob: JobType | '' = ''; + + /** + * Current status of the vehicle. + */ + private status: VehicleStatus; + + /** + * Jobs the vehicle has. These define the tasks the vehicle is capable of performing. + */ + private jobs: JobType[]; + + /** + * Current latitude of the vehicle. Starts at 0. + */ + private lat = 0; + + /** + * Current longitude of the vehicle. Starts at 0. + */ + private lng = 0; + + /** + * Current altitude of the vehicle. + */ + private alt?: number; + + /** + * Current battery of the vehicle, expressed as a decimal. Will vary from 0 to 1. + */ + private battery?: number; + + /** + * Current vehicle heading. Value is in degrees. + */ + private heading?: number; + + /** + * Callback to when the vehicle enters the error state. + */ + private errorCallback: ErrorCallback = (): void => {}; + + /** + * Handler that listens for different events from the vehicle connected. + */ + private updateEventHandler = new UpdateHandler(); + + /** + * Last time that GCS received any message from the vehicle. + * Time is in number of milliseconds since Epoch. + */ + private lastConnectionTime = Date.now(); + + public constructor(options: VehicleOptions) { + this.vehicleId = options.sid; + this.jobs = options.jobs; + this.status = options.status; + + this.updateEventHandler.addHandler('status', (status, message): boolean => { + this.status = status; + if (status === 'error') { + this.errorCallback(message && message.errorMessage); + } + return false; + }); + + this.updateEventHandler.addHandler('time', (): boolean => { + this.lastConnectionTime = Date.now(); + return false; + }); + + this.updateEventHandler.addHandler('lat', (lat): boolean => { + this.lat = lat; + return false; + }); + + this.updateEventHandler.addHandler('lng', (lng): boolean => { + this.lng = lng; + return false; + }); + + this.updateEventHandler.addHandler('alt', (alt): boolean => { + this.alt = alt; + return false; + }); + + this.updateEventHandler.addHandler('battery', (battery, message): boolean => { + if (battery > 1 || battery < 0) { + const vehicleInfo = message && message.sid && vehicleConfig.vehicleInfos[message.sid]; + ipc.postLogMessages({ + type: 'failure', + message: `Received an invalid battery status (${battery * 100}%) from ${(vehicleInfo && vehicleInfo.name) || 'an unknown vehicle'}`, + }); + } else { + this.battery = battery; + } + return false; + }); + + this.updateEventHandler.addHandler('heading', (heading): boolean => { + this.heading = heading; + return false; + }); + } + + public getVehicleId(): number { return this.vehicleId; } + + public getStatus(): VehicleStatus { return this.status; } + + public getJobs(): JobType[] { return this.jobs; } + + public getLat(): number { return this.lat; } + + public getLng(): number { return this.lng; } + + public getAlt(): number | undefined { return this.alt; } + + public getBattery(): number | undefined { return this.battery; } + + public getHeading(): number | undefined { return this.heading; } + + public getLastConnectionTime(): number { return this.lastConnectionTime; } + + public getUpdateEventHandler(): UpdateHandler { return this.updateEventHandler; } + + /** + * Converts vehicle to a plain object so that its private variables can be read + * when it is sent through ipcRenderer. + */ + public toObject(): VehicleObject { + return { + vehicleId: this.vehicleId, + status: this.status, + jobs: this.jobs, + lat: this.lat, + lng: this.lng, + alt: this.alt, + battery: this.battery, + heading: this.heading, + }; + } + + /** + * Updates all variables in this vehicle to the variables in the message. Called + * by the Orchestrator when the GCS receives a message from the vehicle. + * + * @param message The message from the vehicle itself. + */ + public update(jsonMessage: JSONMessage): void { + const updateMessage = jsonMessage; + this.updateEventHandler.events(updateMessage); + } + + /** + * Sets the vehicle to connected. It will go back to "ready" status and + * its update messages will bring it to its real status. + */ + public connect(): void { + this.updateEventHandler.events({ + status: 'ready', + time: Date.now(), + }); + } + + /** + * Sets the vehicle as disconnected by changing it status to "disconnected". + * The Orchestrator is in charge of preventing any more updates to the vehicle + * if it is disconnected. + */ + public disconnect(): void { + this.updateEventHandler.event('status', 'disconnected'); + } + + /** + * Forwards message to MessageHandler to send through Xbee. + * + * @param message Message to send. + */ + private sendMessage(message: Message): void { + ipc.postSendMessage(this.vehicleId, message); + } + + /** + * Notifies the vehicle that it will be performing a certain mission. We let the vehicle know of + * the job type too, so that it knows which tasks to expect from us and discard any other + * tasks that do not support their job. + * + * Will return true if the mission was assigned successfully. + * + * @param jobType The job that will be used to accomplish the mission. + * @param completionCallback Optional callback when vehicle finishes/terminates the mission. + * @param disconnectionCallback Optional callback when vehicle disconnects. + * @param errorCallback Optional callback when vehicle goes in an error state. + * @param options Optional information vehicle will need before performing any tasks. + */ + public assignJob( + jobType: JobType, + completionCallback?: () => void, + disconnectionCallback?: () => void, + errorCallback?: ErrorCallback, + ): boolean { + if (this.status !== 'ready') { + return false; + } + + this.assignedJob = jobType; + if (errorCallback) this.errorCallback = errorCallback; + + this.sendMessage({ + type: 'start', + jobType, + }); + + this.updateEventHandler.addHandler('status', (value): boolean => { + if (value === 'waiting') { + if (completionCallback) completionCallback(); + } else if (value === 'disconnected') { + if (disconnectionCallback) disconnectionCallback(); + } + return value === 'waiting' || value === 'disconnected'; + }); + + return true; + } + + /** + * Gives the vehicle a task to perform (the task must be able to be done by the vehicle's job). + * Will return true if the task was assigned successfully. The only way the task would not + * be assigned successfully is if the task is not supported by the vehicle's job. + * + * @param task The task for the vehicle to perform. Must support the vehicle's job. + */ + public assignTask(task: Task.Task): boolean { + if (this.status !== 'waiting' || !this.assignedJob || !vehicleConfig.isValidTaskTypeForJob(task.taskType, this.assignedJob)) { + return false; + } + + this.sendMessage({ + type: 'addMission', + missionInfo: task, + }); + + return true; + } + + /** + * Sends stop message to vehicle. + */ + public stop(): void { + this.assignedJob = ''; + + this.sendMessage({ + type: 'stop', + }); + } +} diff --git a/src/main/index.js b/src/main/index.js deleted file mode 100644 index 341aea3e..00000000 --- a/src/main/index.js +++ /dev/null @@ -1,253 +0,0 @@ -import { // eslint-disable-line import/no-extraneous-dependencies - app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, shell, Tray, -} from 'electron'; -import fs from 'fs'; -import moment from 'moment'; -import path from 'path'; -import { format as formatUrl } from 'url'; - -import { images, locations } from '../../resources/index'; - -let quitting = false; -let window; - -const FILTER = { name: 'GCS Configuration', extensions: ['json'] }; -const quitRole = { - label: 'Quit', - accelerator: 'CommandOrControl+Q', - click() { - quitting = true; - app.quit(); - }, -}; - -process.env.GOOGLE_API_KEY = 'AIzaSyB1gepR_EONqgEcxuADmEZjizTuOU_cfnU'; - -const darwinMenu = { - label: 'NGCP Ground Control System', - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { - role: 'services', - submenu: [], - }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - quitRole, - ], -}; -const icon = nativeImage.createFromDataURL(images.icon); -const isDevelopment = process.env.NODE_ENV !== 'production'; - -function saveConfig() { - const fileName = moment().format('[GCS Configuration] YYYY-MM-DD [at] h.mm.ss A'); - const filePath = dialog.showSaveDialog(window, { - title: 'Save Configuration', - filters: [FILTER], - defaultPath: `./${fileName}.${FILTER.extensions[0]}`, - }); - - if (!filePath) return; - - const data = {}; - window.webContents.send('saveConfig', { - filePath, - data, - }); -} - -function loadConfig() { - const filePaths = dialog.showOpenDialog(window, { - title: 'Open Configuration', - filters: [FILTER], - properties: ['openFile', 'createDirectory'], - }); - - if (!filePaths || filePaths.length === 0) return; - - const data = JSON.parse(fs.readFileSync(filePaths[0]), 'utf8'); - - if (!data) return; - - window.webContents.send('loadConfig', data); -} - -const menu = [ - { - label: 'File', - submenu: [ - { - label: 'Open File...', - accelerator: 'CommandOrControl+O', - click() { loadConfig(); }, - }, - { type: 'separator' }, - { role: 'close' }, - { type: 'separator' }, - { - label: 'Save As...', - accelerator: 'CommandOrControl+S', - click() { saveConfig(); }, - }, - ], - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'pasteandmatchstyle' }, - { role: 'selectall' }, - ], - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - { type: 'separator' }, - { role: 'toggledevtools' }, - ], - }, - { - label: 'Locations', - submenu: [ - { - label: 'My Location', - click() { window.webContents.send('setMapToUserLocation'); }, - }, - { type: 'separator' }, - ], - }, - { - role: 'window', - submenu: [ - { role: 'minimize' }, - ], - }, - { - role: 'help', - submenu: [ - { - label: 'Help', - click() { shell.openExternal('https://github.com/NGCP/missioncontrol'); }, - }, - ], - }, -]; -const trayMenu = [ - { - label: 'NGCP Ground Control Station', - click() { window.show(); }, - }, - { type: 'separator' }, - quitRole, -]; - -let tray; - -function setLocationMenu() { - const locationMenu = menu.find(m => m.label === 'Locations').submenu; - - if (!locations || locations.length === 0) { - locationMenu.push({ - label: 'No locations defined', - enabled: false, - }); - return; - } - - Object.keys(locations).forEach((label) => { - const { latitude, longitude, zoom } = locations[label]; - locationMenu.push({ - label, - data: { - latitude, - longitude, - zoom, - }, - click(menuItem) { window.webContents.send('updateMapLocation', menuItem.data); }, - }); - }); -} - -function createMainWindow() { - window = new BrowserWindow({ - title: 'NGCP Ground Control Station', - icon, - show: false, - width: 1024, - minWidth: 1024, - height: 576, - minHeight: 576, - }); - - if (isDevelopment) { - window.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`); - } else { - window.loadURL(formatUrl({ - pathname: path.resolve(__dirname, 'index.html'), - protocol: 'file', - slashes: true, - })); - } - - window.on('ready-to-show', () => { - window.show(); - window.focus(); - }); - - window.on('close', (event) => { - if (!quitting) { - event.preventDefault(); - window.hide(); - } - }); - - return window; -} - -function createMenu() { - setLocationMenu(); - - if (process.platform === 'darwin') { - menu.unshift(darwinMenu); - } else { - tray = new Tray(icon); - tray.setContextMenu(Menu.buildFromTemplate(trayMenu)); - tray.on('click', () => window.show()); - - menu.push(quitRole); - } - - Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); -} - - -app.on('activate', () => { - if (window === null) { - createMainWindow(); - } else { - window.show(); - } -}); - -app.on('ready', () => { - createMainWindow(); - createMenu(); -}); - -app.on('before-quit', () => { - quitting = true; -}); - -ipcMain.on('post', (event, notification, data) => window.webContents.send(notification, data)); diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 00000000..27147421 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,478 @@ +import { + app, + BrowserWindow, + dialog, + Event, + ipcMain, + Menu, + MenuItemConstructorOptions, + nativeImage, + shell, + Tray, +} from 'electron'; +import fs from 'fs'; +import moment from 'moment'; + +import { imageConfig, Location, locationConfig } from '../static/index'; + +import { FileSaveOptions } from '../types/fileOption'; + +import ipc from '../util/ipc'; + +/** + * This key is required to enable geolocation in the application. + * Others cannot use this key outside of geolocation access so no need to hide it. + */ +process.env.GOOGLE_API_KEY = 'AIzaSyB1gepR_EONqgEcxuADmEZjizTuOU_cfnU'; + +/** + * Filter constant for all configuration files. + */ +const FILTER = { name: 'GCS Configuration', extensions: ['json'] }; + +/** + * Width of the application. Main window will have this width while mission window + * will have 1/3rd of it. + */ +const WIDTH = 1024; + +/** + * Height of the application. Both main and mission windows will have this height. + */ +const HEIGHT = 576; + +/** + * Returns true if running as development (npm start) but false if running in build + * (the app that has come from npm build). + */ +const isDevelopment = process.env.NODE_ENV !== 'production'; + +// TODO: Put icon tray back to macOS but resize it so that its not huge on macOS's menu. +const icon = nativeImage.createFromDataURL(imageConfig.icon as string); + +/** + * Variable to keep track when the app will quit, which is different from hiding the app. + */ +let quitting = false; + +/** + * Reference to the main window of the application. + */ +let mainWindow: BrowserWindow | null; + +/** + * Reference to the mission window of the application. + */ +let missionWindow: BrowserWindow | null; + +/** + * Reference to the tray object of the application. + */ +let tray: Tray; + +/** + * Role added to menus to allow the user to quit the app. Shortcut is Ctrl/Cmd + Q. + */ +const quitRole: MenuItemConstructorOptions = { + label: 'Quit', + accelerator: 'CommandOrControl+Q', + click: (): void => { + app.quit(); + }, +}; + +/** + * Menu prepended to menu if application is running on a Darwin-based OS. + */ +const darwinMenu: MenuItemConstructorOptions = { + label: 'NGCP Ground Control System', + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { + role: 'services', + submenu: [], + }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + quitRole, + ], +}; + +/** + * Runs when the user wants to save a configuration of the GCS. + * The configuration currently includes the map location loaded. + */ +function postSaveConfig(): void { + if (!mainWindow) return; + + const fileName = moment().format('[GCS Configuration] YYYY-MM-DD [at] h.mm.ss A'); + + // Loads a window that allows the user to choose the file path for the file to be saved. + const filePath = dialog.showSaveDialog(mainWindow, { + title: 'Save Configuration', + filters: [FILTER], + defaultPath: `./${fileName}.${FILTER.extensions[0]}`, + }); + + // Returns if the user chooses to close the window instead of choosing a file path. + if (!filePath) return; + + // Loads data up with information returned from main and mission windows. + const saveOptions: FileSaveOptions = { + filePath, + data: { + map: { + lat: 0, + lng: 0, + zoom: 18, + }, + }, + }; + + ipc.postSaveConfig(saveOptions, mainWindow, missionWindow); +} + +/** + * Runs when the user wants to load a configuration of the GCS. + * The configuration currently includes the map location loaded. + */ +function postLoadConfig(): void { + if (!mainWindow) return; + + // Loads a window that allows the user to choose the filePath of the file to be loaded. + const filePaths = dialog.showOpenDialog(mainWindow, { + title: 'Open Configuration', + filters: [FILTER], + properties: ['openFile', 'createDirectory'], + }); + + // Returns if the user chooses to close the window instead of choosing a file path. + if (!filePaths || filePaths.length === 0) return; + + // TODO: Add type for data. + const data = JSON.parse(fs.readFileSync(filePaths[0]).toString()); + + if (!data) return; + + ipc.postLoadConfig(data, mainWindow, missionWindow); +} + +/** + * Hides the mission window. + */ +function hideMissionWindow(): void { + if (missionWindow) { + missionWindow.hide(); + } +} + +/** + * Creates the main window. This window's hash is #main. + */ +function createMainWindow(): void { + mainWindow = new BrowserWindow({ + title: 'NGCP Ground Control Station', + icon, + show: false, + width: WIDTH, + minWidth: WIDTH, + height: HEIGHT, + minHeight: HEIGHT, + }); + + if (isDevelopment) { + mainWindow.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}#main`); + } else { + mainWindow.loadURL(`file:///${__dirname}/index.html#main`); + } + + mainWindow.on('ready-to-show', (): void => { + if (mainWindow) mainWindow.show(); + }); + + mainWindow.on('close', (event): void => { + if (!quitting) { + event.preventDefault(); + if (mainWindow) { + mainWindow.hide(); + } + if (missionWindow) { + hideMissionWindow(); + } + } else { + mainWindow = null; + } + }); +} + +/** + * Creates the mission window. This window's hash is #mission. + * Does not show up once app is loaded (will be hidden) and is shown only when it is opened from + * the main window. + */ +function createMissionWindow(): void { + missionWindow = new BrowserWindow({ + title: 'NGCP Mission User Interface', + icon, + show: false, + width: Math.floor(WIDTH * 4 / 3), + minWidth: Math.floor(WIDTH * 4 / 3), + height: Math.floor(HEIGHT * 4 / 3), + autoHideMenuBar: true, + minHeight: Math.floor(HEIGHT * 4 / 3), + }); + + if (isDevelopment) { + missionWindow.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}#mission`); + } else { + missionWindow.loadURL(`file:///${__dirname}/index.html#mission`); + + /* + * Generally we should not have a menu on the mission window, but the menu helps us when + * developing the mission window (mainly gives us access to developer console, which then + * allows us to see which elements are loaded, as well as the browser's console log). + */ + missionWindow.setMenu(null); + } + + + missionWindow.on('close', (event): void => { + if (!quitting) { + event.preventDefault(); + // This allows the mission container to update to closed mission window. + hideMissionWindow(); + } else { + missionWindow = null; + } + }); +} + +/** + * Shows the main window. + */ +function showMainWindow(): void { + if (!mainWindow) { + createMainWindow(); + } else { + mainWindow.show(); + } +} + +/** + * Shows the mission window. + */ +function showMissionWindow(): void { + if (!missionWindow) { + createMissionWindow(); + } else { + missionWindow.show(); + } +} + + +/** + * Reference to the menu displayed on main window. + */ +const menu: MenuItemConstructorOptions[] = [ + { + label: 'File', + submenu: [ + { + label: 'Open File...', + accelerator: 'CommandOrControl+O', + click: (): void => { postLoadConfig(); }, + }, + { type: 'separator' }, + { role: 'close' }, + { type: 'separator' }, + { + label: 'Save As...', + accelerator: 'CommandOrControl+S', + click: (): void => { postSaveConfig(); }, + }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteandmatchstyle' }, + { role: 'selectall' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + { type: 'separator' }, + { role: 'toggledevtools' }, + ], + }, + { + label: 'Locations', + submenu: [ + { + label: 'My Location', + click: (): void => { + if (mainWindow) { + ipc.postSetMapToUserLocation(mainWindow, missionWindow); + } + }, + }, + { type: 'separator' }, + ], + }, + { + role: 'window', + submenu: [ + { role: 'minimize' }, + { + label: 'Mission', + click: (): void => { + if (mainWindow) { + showMissionWindow(); + } + }, + }, + ], + }, + { + role: 'help', + submenu: [ + { + label: 'Help', + click: (): void => { shell.openExternal('https://github.com/NGCP/missioncontrol'); }, + }, + ], + }, +]; + +/** + * Adds a list of locations on the menu to allow user to pan to specific location in the map. + * The list of locations comes from static/location.json. + */ +function setLocationMenu(): void { + const location = menu.find((m): boolean => m.label === 'Locations'); + if (!location) return; + + const { submenu } = location; + if (!submenu) return; + + // Cast the submenu variable to locationMenu as a MenuItemContsturctorOptions array. + const locationMenu: MenuItemConstructorOptions[] = submenu as MenuItemConstructorOptions[]; + + if (!locationConfig.locations || Object.keys(locationConfig.locations).length === 0) { + locationMenu.push({ + label: 'No locations defined', + enabled: false, + }); + return; + } + + Object.keys(locationConfig.locations).forEach((label): void => { + locationMenu.push({ + label, + click: (menuItem): void => { + ipc.postUpdateMapLocation( + locationConfig.locations[menuItem.label] as Location, + mainWindow, + missionWindow, + ); + }, + }); + }); +} + +/** + * Small menu displayed on the bottom-right corner of windows, or upper-right corner of macOS. + */ +const trayMenu: MenuItemConstructorOptions[] = [ + { + label: 'NGCP Ground Control Station', + click: (): void => { showMainWindow(); }, + }, + { type: 'separator' }, + quitRole, +]; + +/** + * Creates the menu object by adding locations and other platform specific menu to it. + */ +function createMenu(): void { + setLocationMenu(); + + if (process.platform === 'darwin') { + menu.unshift(darwinMenu); + } else { + menu.push(quitRole); + } + + Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); +} + +/** + * Creates the tray object. + */ +function createTray(): void { + tray = new Tray(icon); + + tray.setContextMenu(Menu.buildFromTemplate(trayMenu)); + + tray.on('click', (): void => { showMainWindow(); }); +} + +app.on('activate', showMainWindow); + +app.on('ready', (): void => { + /* + * Prevents the app from starting if MapBox token is not set up. + * This is necessary to run the map container. + */ + if (!process.env.MAPBOX_TOKEN) { + throw new Error('Set the MapBox token in .env before launching the application'); + } + + createMainWindow(); + createMenu(); + + createMissionWindow(); + createTray(); +}); + +app.on('before-quit', (): void => { + quitting = true; +}); + +ipcMain.on('post', (_: Event, notification: string, ...data: any[]): void => { // eslint-disable-line @typescript-eslint/no-explicit-any + if (notification === 'showMissionWindow') { + showMissionWindow(); + return; + } + + if (notification === 'hideMissionWindow') { + hideMissionWindow(); + return; + } + + /* + * We must forward notifications to both windows or else some notifications will not + * be picked up. + */ + if (mainWindow) { + mainWindow.webContents.send(notification, ...data); + } + if (missionWindow) { + missionWindow.webContents.send(notification, ...data); + } +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 00000000..3d7d1ee8 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,60 @@ +import { ipcRenderer } from 'electron'; +import React, { Component, ReactNode } from 'react'; + +import { ThemeProps } from '../types/componentStyle'; + +import MainWindow from './mainWindow/MainWindow'; +import MissionWindow from './missionWindow/MissionWindow'; + +import '@fortawesome/fontawesome-free/css/all.css'; +import 'leaflet/dist/leaflet.css'; +import 'react-virtualized/styles.css'; +import 'rc-slider/assets/index.css'; + +import './app.css'; + +const windows: { [hash: string]: React.ElementType } = { + '#main': MainWindow as React.ElementType, + '#mission': MissionWindow, +}; + +type State = ThemeProps; + +export default class App extends Component<{}, State> { + public constructor(props: {}) { + super(props); + + this.state = { + theme: 'light', + }; + + this.toggleTheme = this.toggleTheme.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('toggleTheme', this.toggleTheme); + } + + public toggleTheme(): void { + const { theme } = this.state; + + this.setState({ theme: theme === 'light' ? 'dark' : 'light' }); + } + + public render(): ReactNode { + const { theme } = this.state; + + // In the case another hash was somehow loaded. + if (window.location.hash !== '#main' && window.location.hash !== '#mission') { + return ( +
+

404 Not Found

+
+ ); + } + + const Window = windows[window.location.hash]; + + return ; + } +} diff --git a/src/renderer/global.css b/src/renderer/app.css similarity index 74% rename from src/renderer/global.css rename to src/renderer/app.css index cd29a598..ef34f39b 100644 --- a/src/renderer/global.css +++ b/src/renderer/app.css @@ -1,4 +1,15 @@ -/* Defined styles for the whole application */ +/* Styles for the whole application */ + +html, +body, +#app { + height: 100%; + margin: 0; + overflow-x: hidden; + overflow-y: hidden; + padding: 0; + width: 100%; +} html { font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif; @@ -22,6 +33,15 @@ div:focus { color: #c62828; } +.progress { + color: #a7ad00; +} + +.ReactVirtualized__Table, +.ReactVirtualized__Tabl_dark { + font-size: 1em; +} + .ReactVirtualized__Table__row:hover .success, .ReactVirtualized__Table__row_dark:hover .success { color: #43a047; @@ -31,7 +51,8 @@ div:focus { color: #76d275; } -.ReactVirtualized__Table__row:hover .failure { +.ReactVirtualized__Table__row:hover .failure, +.ReactVirtualized__Table__row_dark:hover .failure { color: #f44336; } @@ -39,6 +60,15 @@ div:focus { color: #ff7961; } +.ReactVirtualized__Table__row:hover .progress, +.ReactVirtualized__Table__row_dark:hover .progress { + color: #bfc600; +} + +.ReactVirtualized__Table__row:active .progress { + color: #d3da00; +} + /* Light Theme */ .ReactVirtualized__Table { diff --git a/src/renderer/common/Select.tsx b/src/renderer/common/Select.tsx new file mode 100644 index 00000000..574e00e2 --- /dev/null +++ b/src/renderer/common/Select.tsx @@ -0,0 +1,32 @@ +import React, { PureComponent, ReactNode } from 'react'; + +export interface SelectProps { + defaultOptionValue: { value: string; title?: ReactNode }; + optionValues: { value: string; title?: ReactNode }[]; + onChange: (event: React.ChangeEvent) => void; +} + +export default class Select extends PureComponent { + public constructor(props: SelectProps) { + super(props); + } + + public render(): ReactNode { + const { defaultOptionValue, onChange, optionValues } = this.props; + + return ( + + ); + } +} diff --git a/src/renderer/fixtures/index.js b/src/renderer/fixtures/index.js deleted file mode 100644 index fe596687..00000000 --- a/src/renderer/fixtures/index.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Import all fixtures in here. Feel free to enable/disable fixtures by commenting them out. - * - * Note we do not use fs in here since building the app will not properly link the items. - * We shouldn't have fixtures enabled when building in the first place, but we are preventing - * errors from happening in the first place. - */ - -import './updateMessagesFixtures'; -import './updateVehiclesFixtures'; diff --git a/src/renderer/fixtures/index.ts b/src/renderer/fixtures/index.ts new file mode 100644 index 00000000..c04c5e2c --- /dev/null +++ b/src/renderer/fixtures/index.ts @@ -0,0 +1,8 @@ +// Import all fixtures in here. Feel free to enable/disable fixtures by commenting them out. + +/* eslint-disable multiline-comment-style */ + +// import './logMessagesFixtures'; +// import './updateBoundingBoxesFixtures'; +import './updateVehiclesFixtures'; +// import './updateWaypointsFixtures'; diff --git a/src/renderer/fixtures/logMessagesFixtures.ts b/src/renderer/fixtures/logMessagesFixtures.ts new file mode 100644 index 00000000..e5867779 --- /dev/null +++ b/src/renderer/fixtures/logMessagesFixtures.ts @@ -0,0 +1,31 @@ +import { LogMessage } from '../../types/componentStyle'; + +import ipc from '../../util/ipc'; + +const fixtures: LogMessage[] = [ + { + type: 'failure', + message: 'Test failure message', + }, + { + type: 'success', + message: 'Test success message', + }, + { + message: 'Random message', + }, + { + type: 'progress', + message: 'Test progress message', + }, +]; + +/** + * Sends an updateMessages notification with random message fixtures. + */ +function updateMessages(): void { + const fixture = fixtures[Math.floor(Math.random() * fixtures.length)]; + ipc.postLogMessages(fixture); +} + +setInterval((): void => { updateMessages(); }, 1000); diff --git a/src/renderer/fixtures/updateBoundingBoxesFixtures.ts b/src/renderer/fixtures/updateBoundingBoxesFixtures.ts new file mode 100644 index 00000000..cf783fc7 --- /dev/null +++ b/src/renderer/fixtures/updateBoundingBoxesFixtures.ts @@ -0,0 +1,18 @@ +import { locationConfig } from '../../static/index'; + +import ipc from '../../util/ipc'; + +const { lat, lng } = locationConfig.startLocation; + +const boundingBox = { + name: 'Test Bounding Box', + color: 'blue', + bounds: { + top: lat, + bottom: lat - 0.001, + left: lng - 0.001, + right: lng, + }, +}; + +ipc.postCreateBoundingBoxes(boundingBox); diff --git a/src/renderer/fixtures/updateMessagesFixtures.js b/src/renderer/fixtures/updateMessagesFixtures.js deleted file mode 100644 index 9cac1058..00000000 --- a/src/renderer/fixtures/updateMessagesFixtures.js +++ /dev/null @@ -1,28 +0,0 @@ -import { ipcRenderer } from 'electron'; // eslint-disable-line import/no-extraneous-dependencies - -const fixtures = [ - { - type: 'failure', - message: 'swb fnijnfineifnioen fineig rejnlj qvjevefijn eiofqoifn jnl,a dnv kjveqjnffn jalvlkavhuevenfije eve ve j fjnfuiefn ijcads fhuf a', - }, - { - type: 'failure', - message: 'Test failure message', - }, - { - type: 'success', - message: 'zxvdfn uvoewnfnekjnan oieunqo nfrcvdfsvnad dfionfkldasklaj nio niqdnc djacnkl adsniojnjkla dvlkjvienvio anklvdnkven inkv adnklv ndanvoi', - }, - { - type: 'success', - message: 'Test success message', - }, - { - message: 'Random message', - }, -]; - -setInterval(() => { - const fixture = fixtures[Math.floor(Math.random() * 5)]; - ipcRenderer.send('post', 'updateMessages', [fixture]); -}, 1000); diff --git a/src/renderer/fixtures/updateVehiclesFixtures.js b/src/renderer/fixtures/updateVehiclesFixtures.js deleted file mode 100644 index 6f6c254e..00000000 --- a/src/renderer/fixtures/updateVehiclesFixtures.js +++ /dev/null @@ -1,124 +0,0 @@ -import { ipcRenderer } from 'electron'; // eslint-disable-line import/no-extraneous-dependencies - -let fixtures = [ - { - id: 1, - latitude: 34.056482, - longitude: -117.823912, - type: 'uav', - name: 'Valiant', - }, - { - id: 2, - latitude: 34.053095, - longitude: -117.821970, - type: 'uav', - name: 'Multirotor', - }, - { - id: 3, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 4, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 5, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 6, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 7, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 8, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 9, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 10, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 11, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 12, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 13, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, - { - id: 14, - latitude: 34.053509, - longitude: -117.818452, - type: 'ugv', - name: 'UGV', - }, -]; -const status = [ - { - type: 'success', - message: 'Connected', - }, - { - type: 'failure', - message: 'Disconnected', - }, -]; - -setInterval(() => { - const newFixtures = fixtures.map(fixture => ({ - ...fixture, - latitude: fixture.latitude + (Math.random() / 5000) - 0.0001, - longitude: fixture.longitude + (Math.random() / 5000) - 0.0001, - status: status[Math.floor(Math.random() * 2)], - })); - - fixtures = newFixtures; - ipcRenderer.send('post', 'updateVehicles', fixtures); -}, 1000); diff --git a/src/renderer/fixtures/updateVehiclesFixtures.ts b/src/renderer/fixtures/updateVehiclesFixtures.ts new file mode 100644 index 00000000..5e14413f --- /dev/null +++ b/src/renderer/fixtures/updateVehiclesFixtures.ts @@ -0,0 +1,104 @@ +/* + * This fixture bypasses the MessageHandler, and interacts directly with the Orchestrator + * to add vehicles and post update messages about them. + * + * In a real field test, all messages are sent through the MessageHandler (as it is received + * on an Xbee). + */ + +import { JobType, locationConfig } from '../../static/index'; + +import * as Message from '../../types/message'; + +import ipc from '../../util/ipc'; + +interface Fixture { + sid: number; + jobs: JobType[]; + lat: number; + lng: number; +} + +const fixtureOptions: { sid: number; jobs: JobType[] }[] = [ + { + sid: 100, + jobs: ['isrSearch', 'payloadDrop'], + }, + { + sid: 200, + jobs: ['ugvRescue'], + }, + { + sid: 300, + jobs: ['uuvRescue'], + }, + { + sid: 400, + jobs: ['quickScan'], + }, + { + sid: 401, + jobs: ['detailedSearch'], + }, + { + sid: 500, + jobs: [], + }, + { + sid: 600, + jobs: [], + }, +]; + +let fixtures: Fixture[] = fixtureOptions.map( + (fixtureOption): Fixture => ({ ...fixtureOption, ...locationConfig.startLocation }), +); + +let messageId = 0; + +function generateJSONMessage(vehicleId: number, message: Message.Message): Message.JSONMessage { + const jsonMessage = { + id: messageId, + sid: vehicleId, + tid: 0, + time: Date.now(), + ...message, + }; + + messageId += 1; + return jsonMessage; +} + +function randomCoordinate(location: { lat: number; lng: number }): { lat: number; lng: number } { + return { + lat: location.lat + (Math.random() / 5000) - 0.0001, + lng: location.lng + (Math.random() / 5000) - 0.0001, + }; +} + +function connectVehicles(): void { + fixtures.forEach((fixture): void => { + ipc.postConnectToVehicle(generateJSONMessage(fixture.sid, { + type: 'connect', + jobsAvailable: fixture.jobs, + }), true, true); + }); +} + +function updateVehicles(): void { + fixtures = fixtures.map( + (fixture): Fixture => ({ ...fixture, ...randomCoordinate(fixture) }), + ); + + fixtures.forEach((fixture): void => { + ipc.postHandleUpdateMessage(generateJSONMessage(fixture.sid, { + type: 'update', + lat: fixture.lat, + lng: fixture.lng, + status: 'ready', + }), true); + }); +} + +connectVehicles(); +setInterval((): void => { updateVehicles(); }, 1000); // Call updateVehicles once for disconnection. diff --git a/src/renderer/fixtures/updateWaypointsFixtures.ts b/src/renderer/fixtures/updateWaypointsFixtures.ts new file mode 100644 index 00000000..54b1d6d3 --- /dev/null +++ b/src/renderer/fixtures/updateWaypointsFixtures.ts @@ -0,0 +1,7 @@ +import ipc from '../../util/ipc'; + +const waypoint = { + name: 'Test Marker', +}; + +ipc.postCreateWaypoints(waypoint); diff --git a/src/renderer/index.js b/src/renderer/index.js deleted file mode 100644 index 8358d5e4..00000000 --- a/src/renderer/index.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable react/jsx-filename-extension */ - -import { ipcRenderer } from 'electron'; // eslint-disable-line import/no-extraneous-dependencies -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; - -import { fixtures, geolocation } from '../../resources/index'; - -import LogContainer from './log/LogContainer'; -import MapContainer from './map/MapContainer'; -import MissionContainer from './mission/MissionContainer'; -import VehicleContainer from './vehicle/VehicleContainer'; - -import 'leaflet/dist/leaflet.css'; -import 'react-virtualized/styles.css'; - -import './global.css'; -import './index.css'; - -const isDevelopment = process.env.NODE_ENV !== 'production'; - -class Index extends Component { - constructor(props) { - super(props); - - this.state = { - theme: 'light', - }; - - this.toggleTheme = this.toggleTheme.bind(this); - } - - componentDidMount() { - ipcRenderer.on('toggleTheme', this.toggleTheme); - } - - toggleTheme() { - const { theme } = this.state; - - this.setState({ theme: theme === 'light' ? 'dark' : 'light' }); - } - - render() { - const { theme } = this.state; - - return ( -
- - - - -
- ); - } -} - -ReactDOM.render(, document.getElementById('app'), () => { - if (geolocation) ipcRenderer.send('post', 'setMapToUserLocation'); - - if (isDevelopment && fixtures) { - require('./fixtures/index.js'); // eslint-disable-line global-require - } -}); diff --git a/src/renderer/index.ts b/src/renderer/index.ts new file mode 100644 index 00000000..6a605150 --- /dev/null +++ b/src/renderer/index.ts @@ -0,0 +1,36 @@ +/* eslint-disable global-require */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { config } from '../static/index'; + +import ipc from '../util/ipc'; + +import App from './App'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +/** + * Function runs only once, when it is loaded through the main window. + * Running this function more than once (through both main and mission windows) causes + * the information below to be run twice, and we do not want that to happen. + */ +function runOnce(): void { + if (window.location.hash !== '#main') return; + + // Set up Orchestrator. + require('../common/Orchestrator'); + + // Set up geolocation if geolocation is enabled in config. + if (config.geolocation) { + ipc.postSetMapToUserLocation(); + } + + // Sets up fixtures if in development and fixtures are enabled in config. + if (isDevelopment && config.fixtures) { + require('./fixtures/index'); + } +} + +ReactDOM.render(React.createElement(App), document.getElementById('app'), runOnce); diff --git a/src/renderer/log/LogContainer.jsx b/src/renderer/log/LogContainer.jsx deleted file mode 100644 index edd2ca5c..00000000 --- a/src/renderer/log/LogContainer.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import PropTypes from 'prop-types'; -import { ipcRenderer } from 'electron'; // eslint-disable-line import/no-extraneous-dependencies -import moment from 'moment'; -import React, { Component } from 'react'; -import { - AutoSizer, CellMeasurerCache, CellMeasurer, List, -} from 'react-virtualized'; - -import './log.css'; - -const propTypes = { - theme: PropTypes.oneOf(['light', 'dark']).isRequired, -}; - -export default class LogContainer extends Component { - constructor(props) { - super(props); - - this.state = { - filter: '', - messages: [], - filteredMessages: [], - }; - - this.heightCache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: 20, - }); - - this.rowRenderer = this.rowRenderer.bind(this); - this.clearMessages = this.clearMessages.bind(this); - this.updateFilter = this.updateFilter.bind(this); - this.updateMessages = this.updateMessages.bind(this); - } - - componentDidMount() { - ipcRenderer.on('updateMessages', (event, data) => this.updateMessages(data)); - } - - rowRenderer({ - index, key, parent, style, - }) { - const { filteredMessages } = this.state; - const message = filteredMessages[index]; - - return ( - -
-
{message.time.format('HH:mm:ss.SSS')}
-
{message.message}
-
-
- ); - } - - clearMessages() { - this.heightCache.clearAll(); - this.setState({ filter: '', messages: [], filteredMessages: [] }); - } - - updateFilter(event) { - const { messages } = this.state; - - this.heightCache.clearAll(); - const newFilter = event.target.value; - - this.setState({ - filter: newFilter, - filteredMessages: newFilter === '' ? messages.slice(0) : messages.filter(message => message.type === newFilter), - }); - } - - updateMessages(messages) { - const { filteredMessages, messages: thisMessages, filter } = this.state; - const currentMessages = thisMessages; - const currentFilteredMessages = filteredMessages; - - messages.forEach((message) => { - const msg = { - type: '', - time: moment(), - ...message, - }; - - if (filter === '' || msg.type === filter) { - currentFilteredMessages.push(msg); - } - currentMessages.push(msg); - }); - - this.setState({ - messages: currentMessages, - filteredMessages: currentFilteredMessages, - }); - } - - render() { - const { theme } = this.props; - const { filter, filteredMessages } = this.state; - - return ( -
-
- - {({ height, width }) => ( - - )} - -
-
- - -
-
- ); - } -} - -LogContainer.propTypes = propTypes; diff --git a/src/renderer/log/log.css b/src/renderer/log/log.css deleted file mode 100644 index 0f8fc709..00000000 --- a/src/renderer/log/log.css +++ /dev/null @@ -1,30 +0,0 @@ -/* Defined styles for Log.js */ - -.logContainer .row { - display: flex; - margin: 0; -} - -.logContainer .messages { - background-color: #fff; - color: #000; - font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif; - font-size: 14px; - width: 100%; - height: calc(100% - 25px); -} - -.logContainer .time { - margin-right: 2em; -} - -/* Dark theme */ - -.logContainer .messages_dark { - background-color: #141414; - color: #fff; - font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif; - font-size: 14px; - width: 100%; - height: calc(100% - 25px); -} diff --git a/src/renderer/mainWindow/MainWindow.tsx b/src/renderer/mainWindow/MainWindow.tsx new file mode 100644 index 00000000..4d96f4d2 --- /dev/null +++ b/src/renderer/mainWindow/MainWindow.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react'; + +import { ThemeProps } from '../../types/componentStyle'; + +import LogContainer from './log/LogContainer'; +import MapContainer from './map/MapContainer'; +import VehicleContainer from './vehicle/VehicleContainer'; + +import './main.css'; + +/** + * Main window component. + */ +export default function MainWindow(props: ThemeProps): ReactNode { + const { theme } = props; + + return ( +
+ + + +
+ ); +} diff --git a/src/renderer/mainWindow/log/LogContainer.tsx b/src/renderer/mainWindow/log/LogContainer.tsx new file mode 100644 index 00000000..4ca0e340 --- /dev/null +++ b/src/renderer/mainWindow/log/LogContainer.tsx @@ -0,0 +1,277 @@ +import { Event, ipcRenderer } from 'electron'; +import moment, { Moment } from 'moment'; +import React, { + Component, + createRef, + ReactNode, + RefObject, +} from 'react'; +import { + AutoSizer, + CellMeasurerCache, + CellMeasurer, + List, + ListRowProps, +} from 'react-virtualized'; + +import * as ComponentStyle from '../../../types/componentStyle'; + +import Select from '../../common/Select'; + +import './log.css'; + +interface State { + /** + * The current filter being applied to messages. If the filter is not "", then only messages + * of the same type as the filter will be shown. + */ + filter: ComponentStyle.MessageType; + + /** + * All messages that have been logged. This includes message that are being hidden + * if a filter is being applied. + */ + messages: ComponentStyle.LogMessage[]; + + /** + * All messages that are being shown. If there's no filter, then this is the same + * as messages. We have this as it improves performance (prevents having to filter + * message every time the component is re-rendered) for a space (a duplicate array + * of messages). + */ + filteredMessages: ComponentStyle.LogMessage[]; + + /** + * Scroll to newest element or not. + */ + scrollToBottom: boolean; +} + +/** + * Container that displays messages regarding status, error, etc. + */ +export default class LogContainer extends Component { + /** + * Value to ensure the onScroll works as intended (at least in our case). + */ + private scrollFromUser = true; + + /** + * Timeout for scrollFromUser variable. + */ + private scrollFromUserTimer: NodeJS.Timeout = setTimeout((): void => {}, 200); + + /** + * Timeout that will scroll to bottom when it times out. + */ + private scrollTimer: NodeJS.Timeout; + + /** + * Cache that stores the height for all log messages. Allows the messages to have proper height. + */ + private heightCache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: 20, + }); + + /** + * Reference to log. + */ + private ref: RefObject = createRef(); + + public constructor(props: ComponentStyle.ThemeProps) { + super(props); + + this.state = { + filter: '', + messages: [], + filteredMessages: [], + scrollToBottom: true, + }; + + this.onScroll = this.onScroll.bind(this); + this.onRowsRenderered = this.onRowsRenderered.bind(this); + this.rowRenderer = this.rowRenderer.bind(this); + this.clearMessages = this.clearMessages.bind(this); + this.updateFilter = this.updateFilter.bind(this); + this.logMessages = this.logMessages.bind(this); + + this.scrollTimer = setTimeout((): void => { + this.setState({ scrollToBottom: true }); + this.onRowsRenderered(); + }, 5000); + } + + public componentDidMount(): void { + ipcRenderer.on('logMessages', (_: Event, ...messages: ComponentStyle.LogMessage[]): void => this.logMessages(...messages)); + } + + /** + * Resets scroll timer. + */ + private onScroll(): void { + if (!this.scrollFromUser) return; + + const { scrollToBottom } = this.state; + + if (scrollToBottom) this.setState({ scrollToBottom: false }); + + clearTimeout(this.scrollTimer); + this.scrollTimer = setTimeout((): void => { + this.setState({ scrollToBottom: true }); + this.onRowsRenderered(); + }, 3000); + } + + /** + * Checks whenever rows are rendered. + */ + private onRowsRenderered(): void { + const { filteredMessages, scrollToBottom } = this.state; + + if (scrollToBottom) { + const list = this.ref.current; + if (!list) return; + + this.scrollFromUser = false; + clearTimeout(this.scrollFromUserTimer); + this.scrollFromUserTimer = setTimeout((): void => { this.scrollFromUser = true; }, 150); + list.scrollToRow(filteredMessages.length - 1); + } + } + + /** + * Custom function to render a row in the list. + */ + private rowRenderer(props: ListRowProps): ReactNode { + const { filteredMessages } = this.state; + const { + index, key, parent, style, + } = props; + const message = filteredMessages[index]; + + return ( + +
+
{(message.time as Moment).format('HH:mm:ss.SSS')}
+
{message.message}
+
+
+ ); + } + + /** + * Clears all the messages in the log. + */ + private clearMessages(): void { + this.heightCache.clearAll(); + + this.setState({ + filter: '', + messages: [], + filteredMessages: [], + }); + } + + /** + * Changes the filter applied to the log. + */ + private updateFilter(event: React.ChangeEvent): void { + const { messages } = this.state; + + this.heightCache.clearAll(); + const newFilter = event.currentTarget.value; + + // Ensures our new value has a type of MessageType. + if ((!newFilter && newFilter !== '') || !ComponentStyle.isMessageType(newFilter)) return; + + this.setState({ + filter: newFilter as ComponentStyle.MessageType, + filteredMessages: newFilter === '' ? messages.slice(0) : messages.filter((message): boolean => message.type === newFilter), + }); + + this.onRowsRenderered(); + } + + /** + * Updates the messages in the log. Will update filtered messages accordingly. + */ + private logMessages(...messages: ComponentStyle.LogMessage[]): void { + const { filteredMessages, messages: thisMessages, filter } = this.state; + const currentMessages = thisMessages; + const currentFilteredMessages = filteredMessages; + + messages.forEach((message): void => { + const msg: ComponentStyle.LogMessage = { + type: message.type || '', + message: message.message, + time: message.time || moment(), + }; + + if (filter === '' || msg.type === filter) { + currentFilteredMessages.push(msg); + } + currentMessages.push(msg); + }); + + this.setState({ + messages: currentMessages, + filteredMessages: currentFilteredMessages, + }); + + this.onRowsRenderered(); + } + + public render(): ReactNode { + const { theme } = this.props; + const { filteredMessages } = this.state; + + return ( +
+
+ + {({ height, width }): ReactNode => ( + + )} + +
+
+ ): void => { + ipc.postUpdateActiveVehicleMapping( + missionName, + jobType, + parseInt(event.target.value, 10), + ); + }} + /> + {`: ${jobType} `} +
+ ); + }); + + return ( +
+

{title[missionName]}

+ {jobMappingComponents} +
+ ); + }); + + return {mappingComponents}; + } +} diff --git a/src/renderer/missionWindow/extra/Checkbox.tsx b/src/renderer/missionWindow/extra/Checkbox.tsx new file mode 100644 index 00000000..99d57f9c --- /dev/null +++ b/src/renderer/missionWindow/extra/Checkbox.tsx @@ -0,0 +1,24 @@ +import React, { PureComponent, ReactNode } from 'react'; + +import '../mission.css'; + +export interface CheckboxProps { + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + label: string; +} + +export default class Checkbox extends PureComponent { + public render(): ReactNode { + const { checked, label, onChange } = this.props; + + return ( +
+ +
+ ); + } +} diff --git a/src/renderer/missionWindow/extra/CreateBoundingBoxButton.tsx b/src/renderer/missionWindow/extra/CreateBoundingBoxButton.tsx new file mode 100644 index 00000000..529db228 --- /dev/null +++ b/src/renderer/missionWindow/extra/CreateBoundingBoxButton.tsx @@ -0,0 +1,43 @@ +import React, { PureComponent, ReactNode } from 'react'; + +import { ThemeProps } from '../../../types/componentStyle'; +import ipc from '../../../util/ipc'; + +export interface CreateBoundingBoxButtonProps extends ThemeProps{ + /** + * Identifier that distinguishes + */ + name: string; + + /** + * Name of the box itself, when it shows up on the map. + */ + value: string; +} + +export default class CreateBoundingBoxButton extends PureComponent { + public constructor(props: CreateBoundingBoxButtonProps) { + super(props); + + this.onClick = this.onClick.bind(this); + } + + private onClick(): void { + const { name, value } = this.props; + ipc.postUnlockParameterInputs(name); + ipc.postCreateBoundingBoxes({ name: value }); + } + + public render(): ReactNode { + const { theme } = this.props; + return ( + + ); + } +} diff --git a/src/renderer/missionWindow/extra/CreateWaypointButton.tsx b/src/renderer/missionWindow/extra/CreateWaypointButton.tsx new file mode 100644 index 00000000..eb330ea5 --- /dev/null +++ b/src/renderer/missionWindow/extra/CreateWaypointButton.tsx @@ -0,0 +1,49 @@ +import React, { PureComponent, ReactNode } from 'react'; + +import { ThemeProps } from '../../../types/componentStyle'; +import '../mission.css'; + +import ipc from '../../../util/ipc'; + +export interface CreateWaypointButtonProps extends ThemeProps { + /** + * Identifier that distinguishes this button from other buttons. + */ + name: string; + + /** + * Name of the waypoint itself, when it shows up on the map. + */ + value: string; +} + +/** + * One of the buttons that creates a waypoint on clicked. + */ +export default class CreateWaypointButton extends PureComponent { + public constructor(props: CreateWaypointButtonProps) { + super(props); + + this.onClick = this.onClick.bind(this); + } + + private onClick(): void { + const { name, value } = this.props; + + ipc.postUnlockParameterInputs(name); + ipc.postCreateWaypoints({ name: value }); + } + + public render(): ReactNode { + const { theme } = this.props; + return ( + + ); + } +} diff --git a/src/renderer/missionWindow/extra/MissionOptions.tsx b/src/renderer/missionWindow/extra/MissionOptions.tsx new file mode 100644 index 00000000..4ad27b66 --- /dev/null +++ b/src/renderer/missionWindow/extra/MissionOptions.tsx @@ -0,0 +1,88 @@ +import React, { PureComponent, Fragment, ReactNode } from 'react'; + +import * as MissionInformation from '../../../types/missionInformation'; + +import ipc from '../../../util/ipc'; + +import Checkbox from './Checkbox'; + +export interface MissionOptionsProps { + title: { [missionName in MissionInformation.MissionName]: string }; + missionNames: MissionInformation.MissionName[]; + options: MissionInformation.MissionOptions; +} + +export default class MissionOptions extends PureComponent { + private static onChangeISRSearchNoTakeoff(event: React.ChangeEvent): void { + ipc.postUpdateOptions('isrSearch', 'noTakeoff', event.target.checked); + } + + private static onChangeISRSearchNoLand(event: React.ChangeEvent): void { + ipc.postUpdateOptions('isrSearch', 'noLand', event.target.checked); + } + + private static onChangePayloadDropNoTakeoff(event: React.ChangeEvent): void { + ipc.postUpdateOptions('payloadDrop', 'noTakeoff', event.target.checked); + } + + private static onChangePayloadDropNoLand(event: React.ChangeEvent): void { + ipc.postUpdateOptions('payloadDrop', 'noLand', event.target.checked); + } + + public render(): ReactNode { + const { missionNames, options, title } = this.props; + + const optionComponents = missionNames.filter((missionName): boolean => missionName in options) + .map((missionName): ReactNode => { + let checkboxes: ReactNode; + + switch (missionName) { + case 'isrSearch': + checkboxes = ( + + + + + ); + break; + + case 'payloadDrop': + checkboxes = ( + + + + + ); + break; + + default: + throw new RangeError(`Tried to make mission option component for ${missionName}`); + } + + return ( +
+

{title[missionName]}

+ {checkboxes} +
+ ); + }); + + return {optionComponents}; + } +} diff --git a/src/renderer/missionWindow/mission.css b/src/renderer/missionWindow/mission.css new file mode 100644 index 00000000..c37dceed --- /dev/null +++ b/src/renderer/missionWindow/mission.css @@ -0,0 +1,120 @@ +/* Defined styles for MissionWindow.jsx */ + +.missionWrapper, +.missionWrapper_dark { + display: grid; + height: 100%; + width: 100; + grid-template-areas: + 'selector selector selector mapping' + 'parameter parameter parameter mapping' + 'parameter parameter parameter options' + 'button button button options'; + grid-template-rows: 25px 1fr 1fr 25px; + grid-template-columns: repeat(4, 1fr); +} + +.selectorContainer { + grid-area: selector; + margin-left: 60%; +} + +.parameterContainer { + grid-area: parameter; + padding-left: 20px; +} + +.mappingContainer { + grid-area: mapping; + padding-left: 20px; +} + +.optionsContainer { + grid-area: options; + padding-top: 25px; + padding-left: 20px; +} + +.buttonContainer { + grid-area: button; + margin: auto; +} + +/* Light theme */ + +.missionWrapper { + background-color: #eee; + color: #000; +} + +.container { + background-color: #cbcbcb; +} + +.selectorButton { + min-width: 150px; + margin-right: 20px; +} + +.waypointButton, +.boundingBoxButton, +.selectorButton { + color: #2a3d34; + background: transparent; + border: 2px; + border-style: solid; + border-color: #2a3d34; + border-radius: 5px; +} + +.waypointButton:hover, +.boundingBoxButton:hover, +.selectorButton:hover { + color: #000; + border-color: #000; + background-color: #fff; +} + +/* Dark theme */ + +.missionWrapper_dark { + background-color: #212121; + color: #fff; +} + +.container_dark { + background-color: #424242; +} + +.selectorButton_dark { + min-width: 150px; + margin-right: 20px; +} + +.selectorSlider, +.selectorSlider_dark { + width: 200px !important; + display: inline-block; +} + +.waypointButton_dark, +.boundingBoxButton_dark, +.selectorButton_dark { + color: #bcbcbc; + background: transparent; + border: 2px; + border-style: solid; + border-color: #bcbcbc; + border-radius: 5px; +} + +.waypointButton_dark:hover, +.boundingBoxButton_dark:hover, +.selectorButton_dark:hover { + color: #fff; + border-color: #fff; +} + +.title { + text-align: center; +} diff --git a/src/renderer/missionWindow/parameter/ISRSearch.tsx b/src/renderer/missionWindow/parameter/ISRSearch.tsx new file mode 100644 index 00000000..61d2ad30 --- /dev/null +++ b/src/renderer/missionWindow/parameter/ISRSearch.tsx @@ -0,0 +1,369 @@ +import { Event, ipcRenderer } from 'electron'; +import React, { Component, ReactNode } from 'react'; + +import './parameters.css'; + +import { ThemeProps } from '../../../types/componentStyle'; + +import { missionName } from '../../../common/missions/ISRSearch'; + +import { Location } from '../../../static/index'; + +import ipc from '../../../util/ipc'; +import { readyToStart } from '../../../util/parameter'; + +import CreateWaypointButton from '../extra/CreateWaypointButton'; + +type ISRChecklistType = 'isrTakeoffLat' | 'isrTakeoffLng' | 'isrTakeoffAlt' +| 'isrLoiterLat' | 'isrLoiterLng' | 'isrLoiterAlt' | 'isrLoiterRadius' | 'isrLoiterDirection' +| 'isrSearchAlt' +| 'isrSearchLat1' | 'isrSearchLng1' +| 'isrSearchLat2' | 'isrSearchLng2' +| 'isrSearchLat3' | 'isrSearchLng3' +| 'isrLandLat1' | 'isrLandLng1' | 'isrLandAlt1' +| 'isrLandLat2' | 'isrLandLng2' | 'isrLandAlt2'; + +const checklistCache: { [check in ISRChecklistType]: number | undefined } = { + isrTakeoffLat: undefined, + isrTakeoffLng: undefined, + isrTakeoffAlt: undefined, + isrLoiterLat: undefined, + isrLoiterLng: undefined, + isrLoiterAlt: undefined, + isrLoiterRadius: undefined, + isrLoiterDirection: undefined, + isrSearchAlt: undefined, + isrSearchLat1: undefined, + isrSearchLng1: undefined, + isrSearchLat2: undefined, + isrSearchLng2: undefined, + isrSearchLat3: undefined, + isrSearchLng3: undefined, + isrLandLat1: undefined, + isrLandLng1: undefined, + isrLandAlt1: undefined, + isrLandLat2: undefined, + isrLandLng2: undefined, + isrLandAlt2: undefined, +}; + +type ISRWaypointType = 'isrTakeoff' | 'isrLoiter' | 'isrSearch1' | 'isrSearch2' | 'isrSearch3' | 'isrLand1' | 'isrLand2'; + +type Locked = { [waypointType in ISRWaypointType]: boolean } & { + isrTakeoff: boolean; + isrLoiter: boolean; + isrSearch1: boolean; + isrSearch2: boolean; + isrSearch3: boolean; + isrLand1: boolean; + isrLand2: boolean; +} + +const lockedCache: Locked = { + isrTakeoff: true, + isrLoiter: true, + isrSearch1: true, + isrSearch2: true, + isrSearch3: true, + isrLand1: true, + isrLand2: true, +}; + +interface State { + /** + * Checklist of all required waypoints/coordinates. This is used to generate the + * parameters for the mission. + */ + checklist: { [check in ISRChecklistType]: number | undefined }; + + /** + * True once all checks in checklist are filled in properly. + */ + ready: boolean; + + /** + * True if the inputs for the waypoint type is disabled. Will become disabled + * once the create pin is clicked. + */ + locked: Locked; +} + +export class ISRSearch extends Component { + public constructor(props: ThemeProps) { + super(props); + + this.state = { + checklist: checklistCache, + ready: false, + locked: lockedCache, + }; + + this.onChange = this.onChange.bind(this); + this.updateWaypoints = this.updateWaypoints.bind(this); + this.updateChecklist = this.updateChecklist.bind(this); + this.unlockParameterInputs = this.unlockParameterInputs.bind(this); + this.readyToStart = this.readyToStart.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('updateWaypoints', (__: Event, _: boolean, ...waypoints: { name: string; location: Location }[]): void => this.updateWaypoints(...waypoints)); + ipcRenderer.on('unlockParameterInputs', (_: Event, waypointType: string): void => this.unlockParameterInputs(waypointType)); + } + + private onChange(event: React.ChangeEvent): void { + const { checklist } = this.state; + if (!(event.target.name in checklist)) return; + + const name = event.target.name as ISRChecklistType; + const value = parseInt(event.target.value, 10) || 0; + + switch (name) { + case 'isrTakeoffLat': + ipc.postUpdateWaypoints(true, { name: 'Takeoff', location: { lat: value, lng: checklist.isrTakeoffLng as number } }); + break; + + case 'isrTakeoffLng': + ipc.postUpdateWaypoints(true, { name: 'Takeoff', location: { lat: checklist.isrTakeoffLat as number, lng: value } }); + break; + + case 'isrLoiterLat': + ipc.postUpdateWaypoints(true, { name: 'Loiter', location: { lat: value, lng: checklist.isrLoiterLng as number } }); + break; + + case 'isrLoiterLng': + ipc.postUpdateWaypoints(true, { name: 'Loiter', location: { lat: checklist.isrLoiterLat as number, lng: value } }); + break; + + case 'isrSearchLat1': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 1', location: { lat: value, lng: checklist.isrSearchLng1 as number } }); + break; + + case 'isrSearchLng1': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 1', location: { lat: checklist.isrSearchLat1 as number, lng: value } }); + break; + + case 'isrSearchLat2': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 2', location: { lat: value, lng: checklist.isrSearchLng2 as number } }); + break; + + case 'isrSearchLng2': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 2', location: { lat: checklist.isrSearchLat2 as number, lng: value } }); + break; + + case 'isrSearchLat3': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 3', location: { lat: value, lng: checklist.isrSearchLng3 as number } }); + break; + + case 'isrSearchLng3': + ipc.postUpdateWaypoints(true, { name: 'ISR Search 3', location: { lat: checklist.isrSearchLat3 as number, lng: value } }); + break; + + case 'isrLandLat1': + ipc.postUpdateWaypoints(true, { name: 'Land 1', location: { lat: value, lng: checklist.isrLandLng1 as number } }); + break; + + case 'isrLandLng1': + ipc.postUpdateWaypoints(true, { name: 'Land 1', location: { lat: checklist.isrLandLat1 as number, lng: value } }); + break; + + case 'isrLandLat2': + ipc.postUpdateWaypoints(true, { name: 'Land 2', location: { lat: value, lng: checklist.isrLandLng2 as number } }); + break; + + case 'isrLandLng2': + ipc.postUpdateWaypoints(true, { name: 'Land 2', location: { lat: checklist.isrLandLat2 as number, lng: value } }); + break; + + default: + this.updateChecklist({ [name]: value }); + break; + } + } + + private updateWaypoints(...waypoints: { name: string; location: Location }[]): void { + const checks: { [checkName in ISRChecklistType]?: number } = {}; + + waypoints.forEach((waypoint): void => { + switch (waypoint.name) { + case 'Takeoff': + checks.isrTakeoffLat = waypoint.location.lat; + checks.isrTakeoffLng = waypoint.location.lng; + break; + + case 'Loiter': + checks.isrLoiterLat = waypoint.location.lat; + checks.isrLoiterLng = waypoint.location.lng; + break; + + case 'ISR Search 1': + checks.isrSearchLat1 = waypoint.location.lat; + checks.isrSearchLng1 = waypoint.location.lng; + break; + + case 'ISR Search 2': + checks.isrSearchLat2 = waypoint.location.lat; + checks.isrSearchLng2 = waypoint.location.lng; + break; + + case 'ISR Search 3': + checks.isrSearchLat3 = waypoint.location.lat; + checks.isrSearchLng3 = waypoint.location.lng; + break; + + case 'Land 1': + checks.isrLandLat1 = waypoint.location.lat; + checks.isrLandLng1 = waypoint.location.lng; + break; + + case 'Land 2': + checks.isrLandLat2 = waypoint.location.lat; + checks.isrLandLng2 = waypoint.location.lng; + break; + + default: break; + } + }); + + this.updateChecklist(checks); + } + + private updateChecklist(checks: { [checklistType in ISRChecklistType]?: number }): void { + const { checklist: newChecklist, ready } = this.state; + + Object.keys(checks).forEach((checklistTypeString): void => { + const checklistType = checklistTypeString as ISRChecklistType; + const value = checks[checklistType]; + + newChecklist[checklistType] = value; + }); + + if (ready || readyToStart(this)) { + ipc.postUpdateInformation({ + missionName: 'isrSearch', + parameters: { + takeoff: { + lat: newChecklist.isrTakeoffLat as number, + lng: newChecklist.isrTakeoffLng as number, + alt: newChecklist.isrTakeoffAlt as number, + loiter: { + lat: newChecklist.isrLoiterLat as number, + lng: newChecklist.isrLoiterLng as number, + alt: newChecklist.isrLoiterAlt as number, + radius: newChecklist.isrLoiterRadius as number, + direction: newChecklist.isrLoiterDirection as number, + }, + }, + isrSearch: { + alt: newChecklist.isrSearchAlt as number, + waypoints: [ + { + lat: newChecklist.isrSearchLat1 as number, + lng: newChecklist.isrSearchLng1 as number, + }, + { + lat: newChecklist.isrSearchLat2 as number, + lng: newChecklist.isrSearchLng2 as number, + }, + { + lat: newChecklist.isrSearchLat3 as number, + lng: newChecklist.isrSearchLng3 as number, + }, + ], + }, + land: { + waypoints: [ + { + lat: newChecklist.isrLandLat1 as number, + lng: newChecklist.isrLandLng1 as number, + alt: newChecklist.isrLandAlt1 as number, + }, + { + lat: newChecklist.isrLandLat2 as number, + lng: newChecklist.isrLandLng2 as number, + alt: newChecklist.isrLandAlt2 as number, + }, + ], + }, + }, + }); + } + + this.setState({ checklist: newChecklist }); + } + + private unlockParameterInputs(waypointType: string): void { + const { locked: newLocked } = this.state; + + if (waypointType in newLocked) { + newLocked[waypointType as ISRWaypointType] = false; + } + + this.setState({ locked: newLocked }); + } + + private readyToStart(): boolean { + const { checklist } = this.state; + + const ready = Object.values(checklist).every((value): boolean => value !== undefined); + + if (ready) this.setState({ ready }); + return ready; + } + + public render(): ReactNode { + const { checklist, locked } = this.state; + const { theme } = this.props; + + return ( +
+

Takeoff Coordinates

+ + + + + +

Loiter Coordinates

+ + + +
+ + + + +

ISR Search Waypoints

+ +
+ + + +
+ + + +
+ + + + +

Land Waypoints

+ + + + +
+
+ + + + +
+
+ ); + } +} + +export default { + missionName, + layout: ISRSearch, +}; diff --git a/src/renderer/missionWindow/parameter/PayloadDrop.tsx b/src/renderer/missionWindow/parameter/PayloadDrop.tsx new file mode 100644 index 00000000..620fbd26 --- /dev/null +++ b/src/renderer/missionWindow/parameter/PayloadDrop.tsx @@ -0,0 +1,339 @@ +import { Event, ipcRenderer } from 'electron'; +import React, { Component, ReactNode } from 'react'; + +import './parameters.css'; + +import { ThemeProps } from '../../../types/componentStyle'; + +import { missionName } from '../../../common/missions/PayloadDrop'; + +import { Location } from '../../../static/index'; + +import { VehicleObject } from '../../../types/vehicle'; + +import ipc from '../../../util/ipc'; +import { readyToStart } from '../../../util/parameter'; + +import CreateWaypointButton from '../extra/CreateWaypointButton'; + +type PayloadDropChecklistType = 'payloadDropTakoffLat' | 'payloadDropTakoffLng' | 'payloadDropTakoffAlt' +| 'payloadDropLoiterLat' | 'payloadDropLoiterLng' | 'payloadDropLoiterAlt' | 'payloadDropLoiterRadius' | 'payloadDropLoiterDirection' +| 'payloadDropLat1' | 'payloadDropLng1' | 'payloadDropAlt1' +| 'payloadDropLat2' | 'payloadDropLng2' | 'payloadDropAlt2' +| 'payloadDropLandLat1' | 'payloadDropLandLng1' | 'payloadDropLandAlt1' +| 'payloadDropLandLat2' | 'payloadDropLandLng2' | 'payloadDropLandAlt2'; + +const checklistCache: { [check in PayloadDropChecklistType ]: number | undefined } = { + payloadDropTakoffLat: undefined, + payloadDropTakoffLng: undefined, + payloadDropTakoffAlt: undefined, + payloadDropLoiterLat: undefined, + payloadDropLoiterLng: undefined, + payloadDropLoiterAlt: undefined, + payloadDropLoiterRadius: undefined, + payloadDropLoiterDirection: undefined, + payloadDropAlt1: undefined, + payloadDropLat1: undefined, + payloadDropLng1: undefined, + payloadDropAlt2: undefined, + payloadDropLat2: undefined, + payloadDropLng2: undefined, + payloadDropLandLat1: undefined, + payloadDropLandLng1: undefined, + payloadDropLandAlt1: undefined, + payloadDropLandLat2: undefined, + payloadDropLandLng2: undefined, + payloadDropLandAlt2: undefined, +}; + +type PayloadType = 'payloadDropTakoff' | 'payloadDropLoiter' | 'payloadDrop1'| 'payloadDrop2' | 'payloadDropLand1' | 'payloadDropLand2'; + +type Locked = { [type in PayloadType]: boolean} & { + payloadDropTakoff: boolean; + payloadDropLoiter: boolean; + payloadDrop1: boolean; + payloadDrop2: boolean; + payloadDropLand1: boolean; + payloadDropLand2: boolean; +} + +const lockedCache: Locked = { + payloadDropTakoff: true, + payloadDropLoiter: true, + payloadDrop1: true, + payloadDrop2: true, + payloadDropLand1: true, + payloadDropLand2: true, +}; + +// eslint-disable-next-line @typescript-eslint/interface-name-prefix +export interface PayloadDropProps extends ThemeProps { + vehicles: { [vehicleId: number]: VehicleObject }; +} + +interface State { + /** + * Checklist of all required waypoints/coordinates. This is used to generate the + * parameters for the mission. + */ + checklist: { [check in PayloadDropChecklistType]: number | undefined }; + + /** + * True once all checks are filled in properly. + */ + ready: boolean; + + /** + * True if the inputs for the waypoint type is disabled. Will become disabled + * once the create pin is clicked. + */ + locked: Locked; +} + +export class PayloadDrop extends Component { + public constructor(props: PayloadDropProps) { + super(props); + + this.state = { + checklist: checklistCache, + ready: false, + locked: lockedCache, + }; + + this.onChange = this.onChange.bind(this); + this.updateWaypoints = this.updateWaypoints.bind(this); + this.updateChecklist = this.updateChecklist.bind(this); + this.readyToStart = this.readyToStart.bind(this); + this.unlockParameterInputs = this.unlockParameterInputs.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('updateWaypoints', (__: Event, _: boolean, ...waypoints: { name: string; location: Location }[]): void => this.updateWaypoints(...waypoints)); + ipcRenderer.on('unlockParameterInputs', (_: Event, waypointType: string): void => this.unlockParameterInputs(waypointType)); + } + + private onChange(event: React.ChangeEvent): void { + const { checklist } = this.state; + if (!(event.target.name in checklist)) return; + + const name = event.target.name as PayloadDropChecklistType; + const value = parseInt(event.target.value, 10) || 0; + switch (name) { + case 'payloadDropTakoffLat': + ipc.postUpdateWaypoints(true, { name: 'Takeoff', location: { lat: value, lng: checklist.payloadDropTakoffLng as number } }); + break; + case 'payloadDropTakoffLng': + ipc.postUpdateWaypoints(true, { name: 'Takeoff', location: { lat: checklist.payloadDropTakoffLat as number, lng: value } }); + break; + + case 'payloadDropLat1': + ipc.postUpdateWaypoints(true, { name: 'Payload Drop 1', location: { lat: value, lng: checklist.payloadDropLng1 as number } }); + break; + + case 'payloadDropLng1': + ipc.postUpdateWaypoints(true, { name: 'Payload Drop 1', location: { lat: checklist.payloadDropLat1 as number, lng: value } }); + break; + + case 'payloadDropLat2': + ipc.postUpdateWaypoints(true, { name: 'Payload Drop 2', location: { lat: value, lng: checklist.payloadDropLng2 as number } }); + break; + + case 'payloadDropLng2': + ipc.postUpdateWaypoints(true, { name: 'Payload Drop 2', location: { lat: checklist.payloadDropLat2 as number, lng: value } }); + break; + + case 'payloadDropLandLat1': + ipc.postUpdateWaypoints(true, { name: 'Land 1', location: { lat: value, lng: checklist.payloadDropLandLng1 as number } }); + break; + + case 'payloadDropLandLng1': + ipc.postUpdateWaypoints(true, { name: 'Land 1', location: { lat: checklist.payloadDropLandLat1 as number, lng: value } }); + break; + + case 'payloadDropLandLat2': + ipc.postUpdateWaypoints(true, { name: 'Land 2', location: { lat: value, lng: checklist.payloadDropLandLng2 as number } }); + break; + + case 'payloadDropLandLng2': + ipc.postUpdateWaypoints(true, { name: 'Land 2', location: { lat: checklist.payloadDropLandLat2 as number, lng: value } }); + break; + + default: + this.updateChecklist({ [name]: value }); + break; + } + } + + private updateWaypoints(...waypoints: { name: string; location: Location }[]): void { + const checks: { [checkName in PayloadDropChecklistType]?: number} = {}; + + waypoints.forEach((waypoint): void => { + switch (waypoint.name) { + case 'Takeoff': + checks.payloadDropTakoffLat = waypoint.location.lat; + checks.payloadDropTakoffLng = waypoint.location.lng; + break; + + case 'Loiter': + checks.payloadDropLoiterLat = waypoint.location.lat; + checks.payloadDropLoiterLng = waypoint.location.lng; + break; + + case 'Payload Drop 1': + checks.payloadDropLat1 = waypoint.location.lat; + checks.payloadDropLng1 = waypoint.location.lng; + break; + + case 'Payload Drop 2': + checks.payloadDropLat2 = waypoint.location.lat; + checks.payloadDropLng2 = waypoint.location.lng; + break; + + case 'Land 1': + checks.payloadDropLandLat1 = waypoint.location.lat; + checks.payloadDropLandLng1 = waypoint.location.lng; + break; + + case 'Land 2': + checks.payloadDropLandLat2 = waypoint.location.lat; + checks.payloadDropLandLng2 = waypoint.location.lng; + break; + + default: break; + } + }); + + this.updateChecklist(checks); + } + + private updateChecklist(checks: { [checklistType in PayloadDropChecklistType]?: number }): void { + const { checklist: newChecklist, ready } = this.state; + + Object.keys(checks).forEach((checklistTypeString): void => { + const checklistType = checklistTypeString as PayloadDropChecklistType; + const value = checks[checklistType]; + + newChecklist[checklistType] = value; + }); + + if (ready || readyToStart(this)) { + ipc.postUpdateInformation({ + missionName: 'payloadDrop', + parameters: { + takeoff: { + lat: newChecklist.payloadDropTakoffLat as number, + lng: newChecklist.payloadDropTakoffLng as number, + alt: newChecklist.payloadDropTakoffAlt as number, + loiter: { + lat: newChecklist.payloadDropLoiterLat as number, + lng: newChecklist.payloadDropLoiterLng as number, + alt: newChecklist.payloadDropLoiterAlt as number, + radius: newChecklist.payloadDropLoiterRadius as number, + direction: newChecklist.payloadDropLoiterDirection as number, + }, + }, + payloadDrop: { + waypoints: [ + { + lat: newChecklist.payloadDropLat1 as number, + lng: newChecklist.payloadDropLng1 as number, + alt: newChecklist.payloadDropAlt1 as number, + }, + { + lat: newChecklist.payloadDropLat2 as number, + lng: newChecklist.payloadDropLng2 as number, + alt: newChecklist.payloadDropAlt2 as number, + }, + ], + }, + land: { + waypoints: [ + { + lat: newChecklist.payloadDropLandLat1 as number, + lng: newChecklist.payloadDropLandLng1 as number, + alt: newChecklist.payloadDropLandAlt1 as number, + }, + { + lat: newChecklist.payloadDropLandLat2 as number, + lng: newChecklist.payloadDropLandLng2 as number, + alt: newChecklist.payloadDropLandAlt2 as number, + }, + ], + }, + }, + }); + } + + this.setState({ checklist: newChecklist }); + } + + private unlockParameterInputs(waypointType: string): void { + const { locked: newLocked } = this.state; + + if (waypointType in newLocked) { + newLocked[waypointType as PayloadType] = false; + } + + this.setState({ locked: newLocked }); + } + + private readyToStart(): boolean { + const { checklist } = this.state; + const ready = Object.values(checklist).every((value): boolean => value !== undefined); + + if (ready) this.setState({ ready }); + return ready; + } + + public render(): ReactNode { + const { checklist, locked } = this.state; + const { theme } = this.props; + return ( +
+

Takeoff Coordinates

+ + + + + +

Loiter Coordinates

+ + + +
+ + + + +

Payload Drop Coordinates

+ + + + +
+ + + + + +

Land Waypoints

+ + + + +
+
+ + + + +
+
+ ); + } +} + +export default { + missionName, + layout: PayloadDrop, +}; diff --git a/src/renderer/missionWindow/parameter/UGVRescue.tsx b/src/renderer/missionWindow/parameter/UGVRescue.tsx new file mode 100644 index 00000000..d257710b --- /dev/null +++ b/src/renderer/missionWindow/parameter/UGVRescue.tsx @@ -0,0 +1,210 @@ +import { Event, ipcRenderer } from 'electron'; +import React, { Component, ReactNode } from 'react'; + +import './parameters.css'; + +import { ThemeProps } from '../../../types/componentStyle'; + +import { missionName } from '../../../common/missions/UGVRescue'; + +import { Location } from '../../../static/index'; + +import { VehicleObject } from '../../../types/vehicle'; + +import ipc from '../../../util/ipc'; +import { readyToStart } from '../../../util/parameter'; + +import CreateWaypointButton from '../extra/CreateWaypointButton'; + +type UGVChecklistType = 'ugvRetrieveTargetLat' | 'ugvRetrieveTargetLng' | 'ugvDeliverTargetLat' | 'ugvDeliverTargetLng'; + +const checklistCache: { [check in UGVChecklistType]: number | undefined } = { + ugvRetrieveTargetLat: undefined, + ugvRetrieveTargetLng: undefined, + ugvDeliverTargetLat: undefined, + ugvDeliverTargetLng: undefined, +}; + +type UGVWaypointType = 'retrieveTarget' | 'deliverTarget'; + +type Locked = { [waypointType in UGVWaypointType]: boolean } & { + retrieveTarget: boolean; + deliverTarget: boolean; +} + +const lockedCache: Locked = { + retrieveTarget: true, + deliverTarget: true, +}; + +export interface UGVRescueProps extends ThemeProps { + vehicles: { [vehicleId: number]: VehicleObject }; +} + +interface State { + /** + * Checklist of all required waypoints/coordinates. This is used to generate the + * parameters for the mission. + */ + checklist: { [check in UGVChecklistType]: number | undefined }; + + /** + * True once all checks in checklist are filled in properly. + */ + ready: boolean; + + /** + * True if the inputs for the waypoint type is disabled. Will become disabled + * once the create pin is clicked. + */ + locked: Locked; +} + +export class UGVRescue extends Component { + public constructor(props: UGVRescueProps) { + super(props); + + this.state = { + checklist: checklistCache, + ready: false, + locked: lockedCache, + }; + + this.onChange = this.onChange.bind(this); + this.updateWaypoints = this.updateWaypoints.bind(this); + this.updateChecklist = this.updateChecklist.bind(this); + this.unlockParameterInputs = this.unlockParameterInputs.bind(this); + this.readyToStart = this.readyToStart.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('updateWaypoints', (__: Event, _: boolean, ...waypoints: { name: string; location: Location }[]): void => this.updateWaypoints(...waypoints)); + ipcRenderer.on('unlockParameterInputs', (_: Event, waypointType: string): void => this.unlockParameterInputs(waypointType)); + } + + + private onChange(event: React.ChangeEvent): void { + const { checklist } = this.state; + + if (!(event.target.name in checklist)) return; + + const name = event.target.name as UGVChecklistType; + const value = parseInt(event.target.value, 10) || 0; + + switch (name) { + case 'ugvRetrieveTargetLat': + ipc.postUpdateWaypoints(true, { name: 'Retrieve Target', location: { lat: value, lng: checklist.ugvRetrieveTargetLng as number } }); + break; + + case 'ugvRetrieveTargetLng': + ipc.postUpdateWaypoints(true, { name: 'Retrieve Target', location: { lat: checklist.ugvRetrieveTargetLat as number, lng: value } }); + break; + + case 'ugvDeliverTargetLat': + ipc.postUpdateWaypoints(true, { name: 'Deliver Target', location: { lat: value, lng: checklist.ugvDeliverTargetLng as number } }); + break; + + case 'ugvDeliverTargetLng': + ipc.postUpdateWaypoints(true, { name: 'Deliver Target', location: { lat: checklist.ugvDeliverTargetLat as number, lng: value } }); + break; + + default: + this.updateChecklist({ [name]: value }); + break; + } + } + + private updateWaypoints(...waypoints: { name: string; location: Location }[]): void { + const checks: { [checkName in UGVChecklistType]?: number } = {}; + + waypoints.forEach((waypoint): void => { + switch (waypoint.name) { + case 'Retrieve Target': + checks.ugvRetrieveTargetLat = waypoint.location.lat; + checks.ugvRetrieveTargetLng = waypoint.location.lng; + break; + + case 'Deliver Target': + checks.ugvDeliverTargetLat = waypoint.location.lat; + checks.ugvDeliverTargetLng = waypoint.location.lng; + break; + + default: break; + } + }); + + this.updateChecklist(checks); + } + + private updateChecklist(checks: { [checklistType in UGVChecklistType]?: number }): void { + const { checklist: newChecklist, ready } = this.state; + + Object.keys(checks).forEach((checklistTypeString): void => { + const checklistType = checklistTypeString as UGVChecklistType; + const value = checks[checklistType]; + + newChecklist[checklistType] = value; + }); + + if (ready || readyToStart(this)) { + ipc.postUpdateInformation({ + missionName: 'ugvRescue', + parameters: { + retrieveTarget: { + lat: newChecklist.ugvRetrieveTargetLat as number, + lng: newChecklist.ugvRetrieveTargetLng as number, + }, + deliverTarget: { + lat: newChecklist.ugvDeliverTargetLat as number, + lng: newChecklist.ugvDeliverTargetLng as number, + }, + }, + }); + } + + this.setState({ checklist: newChecklist }); + } + + private unlockParameterInputs(waypointType: string): void { + const { locked: newLocked } = this.state; + + if (waypointType in newLocked) { + newLocked[waypointType as UGVWaypointType] = false; + } + + this.setState({ locked: newLocked }); + } + + private readyToStart(): boolean { + const { checklist } = this.state; + + const ready = Object.values(checklist).every((value): boolean => value !== undefined); + + if (ready) this.setState({ ready }); + return ready; + } + + public render(): ReactNode { + const { checklist, locked } = this.state; + const { theme } = this.props; + + return ( +
+

UGV Retrieve Target

+ + + + +

UGV Deliver Target

+ + + +
+ ); + } +} + +export default { + missionName, + layout: UGVRescue, +}; diff --git a/src/renderer/missionWindow/parameter/UUVRescue.tsx b/src/renderer/missionWindow/parameter/UUVRescue.tsx new file mode 100644 index 00000000..bc782a84 --- /dev/null +++ b/src/renderer/missionWindow/parameter/UUVRescue.tsx @@ -0,0 +1,16 @@ +import React, { ReactNode } from 'react'; + +import { missionName } from '../../../common/missions/UUVRescue'; + +import ipc from '../../../util/ipc'; + +export function UUVRescue(): ReactNode { + ipc.postUpdateInformation({ missionName: 'uuvRescue', parameters: { retrieveTarget: {} } }); + + return
; +} + +export default { + missionName, + layout: UUVRescue as React.ElementType, +}; diff --git a/src/renderer/missionWindow/parameter/VTOLSearch.tsx b/src/renderer/missionWindow/parameter/VTOLSearch.tsx new file mode 100644 index 00000000..71ea5c7b --- /dev/null +++ b/src/renderer/missionWindow/parameter/VTOLSearch.tsx @@ -0,0 +1,245 @@ +import { Event, ipcRenderer } from 'electron'; +import React, { Component, ReactNode } from 'react'; + +import './parameters.css'; + +import { BoundingBoxBounds, ThemeProps } from '../../../types/componentStyle'; + +import { missionName } from '../../../common/missions/VTOLSearch'; + +import { VehicleObject } from '../../../types/vehicle'; + +import ipc from '../../../util/ipc'; +import { readyToStart } from '../../../util/parameter'; + +import CreateBoundingBoxButton from '../extra/CreateBoundingBoxButton'; + +type VTOLSearchChecklistType = 'quickScanTop' | 'quickScanLeft' | 'quickScanRight' | 'quickScanBottom'; + +const checklistCache: { [check in VTOLSearchChecklistType ]: number | undefined} = { + quickScanTop: undefined, + quickScanLeft: undefined, + quickScanRight: undefined, + quickScanBottom: undefined, +}; + +type VTOLSearchType = 'quickScan'; + +type Locked = { [type in VTOLSearchType]: boolean} & { + quickScan: boolean; +} + +const lockedCache: Locked = { + quickScan: true, +}; + +// eslint-disable-next-line @typescript-eslint/interface-name-prefix +export interface VTOLSearchProps extends ThemeProps { + vehicles: { [vehicleId: number]: VehicleObject }; +} + +interface State { + /** + * Checklist of all required points for bounding box. This is used to generate the + * parameters for the mission. + */ + checklist: { [check in VTOLSearchChecklistType]: number | undefined }; + + /** + * True once all checks are filled in properly. + */ + ready: boolean; + + /** + * True if the inputs for the waypoint type is disabled. Will become disabled + * once the create pin is clicked. + */ + locked: Locked; +} + +export class VTOLSearch extends Component { + public constructor(props: VTOLSearchProps) { + super(props); + + this.state = { + checklist: checklistCache, + ready: false, + locked: lockedCache, + }; + + this.onChange = this.onChange.bind(this); + this.updateBoundingBoxes = this.updateBoundingBoxes.bind(this); + this.updateChecklist = this.updateChecklist.bind(this); + this.readyToStart = this.readyToStart.bind(this); + this.unlockParameterInputs = this.unlockParameterInputs.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('updateBoundingBoxes', (__: Event, _: boolean, ...boxPoints: { name: string; bounds: BoundingBoxBounds }[]): void => this.updateBoundingBoxes(...boxPoints)); + ipcRenderer.on('unlockParameterInputs', (_: Event, waypointType: string): void => this.unlockParameterInputs(waypointType)); + } + + private onChange(event: React.ChangeEvent): void { + const { checklist } = this.state; + if (!(event.target.name in checklist)) return; + + const name = event.target.name as VTOLSearchChecklistType; + const value = parseInt(event.target.value, 10) || 0; + switch (name) { + case 'quickScanTop': + ipc.postUpdateBoundingBoxes(true, { + name: 'Bounding Box', + bounds: { + top: value, + bottom: checklist.quickScanBottom as number, + left: checklist.quickScanLeft as number, + right: checklist.quickScanRight as number, + }, + }); + break; + + case 'quickScanLeft': + ipc.postUpdateBoundingBoxes(true, { + name: 'Bounding Box', + bounds: { + top: checklist.quickScanTop as number, + bottom: checklist.quickScanBottom as number, + left: value, + right: checklist.quickScanRight as number, + }, + }); + break; + + case 'quickScanRight': + ipc.postUpdateBoundingBoxes(true, { + name: 'Bounding Box', + bounds: { + top: checklist.quickScanTop as number, + bottom: checklist.quickScanBottom as number, + left: checklist.quickScanLeft as number, + right: value, + }, + }); + break; + + case 'quickScanBottom': + ipc.postUpdateBoundingBoxes(true, { + name: 'Bounding Box', + bounds: { + top: checklist.quickScanTop as number, + bottom: value, + left: checklist.quickScanLeft as number, + right: checklist.quickScanRight as number, + }, + }); + break; + + default: + this.updateChecklist({ [name]: value }); + break; + } + } + + private updateBoundingBoxes( + ...boundingBoxes: { name: string; bounds: BoundingBoxBounds }[] + ): void { + const checks: { [checkName in VTOLSearchChecklistType]?: number} = {}; + + boundingBoxes.forEach((boxpoint): void => { + switch (boxpoint.name) { + case 'Bounding Box': + checks.quickScanTop = boxpoint.bounds.top; + checks.quickScanRight = boxpoint.bounds.right; + checks.quickScanLeft = boxpoint.bounds.left; + checks.quickScanBottom = boxpoint.bounds.bottom; + break; + + default: break; + } + }); + + this.updateChecklist(checks); + } + + private updateChecklist(checks: { [checklistType in VTOLSearchChecklistType]?: number }): void { + const { checklist: newChecklist, ready } = this.state; + + Object.keys(checks).forEach((checklistTypeString): void => { + const checklistType = checklistTypeString as VTOLSearchChecklistType; + const value = checks[checklistType]; + + newChecklist[checklistType] = value; + + if (ready || readyToStart(this)) { + ipc.postUpdateInformation({ + missionName: 'vtolSearch', + parameters: { + quickScan: { + waypoints: [ + { + lat: newChecklist.quickScanTop as number, + lng: newChecklist.quickScanLeft as number, + }, + { + lat: newChecklist.quickScanTop as number, + lng: newChecklist.quickScanRight as number, + }, + { + lat: newChecklist.quickScanBottom as number, + lng: newChecklist.quickScanLeft as number, + }, + { + lat: newChecklist.quickScanBottom as number, + lng: newChecklist.quickScanRight as number, + }, + ], + }, + }, + }); + } + }); + + this.setState({ checklist: newChecklist }); + } + + private unlockParameterInputs(waypointType: string): void { + const { locked: newLocked } = this.state; + + if (waypointType in newLocked) { + newLocked[waypointType as VTOLSearchType] = false; + } + + this.setState({ locked: newLocked }); + } + + private readyToStart(): boolean { + const { checklist } = this.state; + const ready = Object.values(checklist).every((value): boolean => value !== undefined); + + if (ready) this.setState({ ready }); + return ready; + } + + public render(): ReactNode { + const { checklist, locked } = this.state; + const { theme } = this.props; + return ( +
+

Quick Scan

+ +
+ +
+ +
+ + +
+ ); + } +} + +export default { + missionName, + layout: VTOLSearch, +}; diff --git a/src/renderer/missionWindow/parameter/parameters.css b/src/renderer/missionWindow/parameter/parameters.css new file mode 100644 index 00000000..a2540896 --- /dev/null +++ b/src/renderer/missionWindow/parameter/parameters.css @@ -0,0 +1,8 @@ + +.inputFields { + background-color: #bcbcbc; + border: none; + margin: 2px; + width: 200px; + height: 20px; +} diff --git a/src/renderer/vehicle/VehicleContainer.jsx b/src/renderer/vehicle/VehicleContainer.jsx deleted file mode 100644 index 97e5ccfd..00000000 --- a/src/renderer/vehicle/VehicleContainer.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; - -import VehicleTable from './VehicleTable'; - -import './vehicle.css'; - -const propTypes = { - theme: PropTypes.oneOf(['light', 'dark']).isRequired, -}; - -export default class VehicleContainer extends PureComponent { - render() { - const { theme } = this.props; - - return ( -
- -
- ); - } -} - -VehicleContainer.propTypes = propTypes; diff --git a/src/renderer/vehicle/VehicleTable.jsx b/src/renderer/vehicle/VehicleTable.jsx deleted file mode 100644 index 9143e7d7..00000000 --- a/src/renderer/vehicle/VehicleTable.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import { ipcRenderer } from 'electron'; // eslint-disable-line import/no-extraneous-dependencies -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { AutoSizer, Table, Column } from 'react-virtualized'; - -const width = { - id: 0.15, - name: 0.4, - status: 0.45, -}; - -const propTypes = { - theme: PropTypes.oneOf(['light', 'dark']).isRequired, -}; - -export default class VehicleTable extends Component { - static statusRenderer({ rowData }) { - return {rowData.status.message}; - } - - static centerMapToVehicle(vehicle) { - ipcRenderer.send('post', 'centerMapToVehicle', vehicle); - } - - constructor(props) { - super(props); - - this.state = { - vehicles: {}, - }; - - this.width = width; - - this.onRowClick = this.onRowClick.bind(this); - this.updateVehicles = this.updateVehicles.bind(this); - this.rowGetter = this.rowGetter.bind(this); - this.rowClassName = this.rowClassName.bind(this); - } - - componentDidMount() { - ipcRenderer.on('updateVehicles', (event, data) => this.updateVehicles(data)); - } - - - onRowClick({ rowData }) { - const { vehicles } = this.state; - - VehicleTable.centerMapToVehicle(vehicles[rowData.id]); - } - - updateVehicles(vehicles) { - const { vehicles: thisVehicles } = this.state; - const currentVehicles = thisVehicles; - - vehicles.forEach((vehicle) => { - currentVehicles[vehicle.id] = vehicle; - }); - - this.setState({ vehicles: currentVehicles }); - } - - rowGetter({ index }) { - const { vehicles } = this.state; - - const v = Object.keys(vehicles).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); - return vehicles[v[index]]; - } - - rowClassName({ index }) { - const { theme } = this.props; - - if (theme === 'dark' && index === -1) { - return 'ReactVirtualized__Table__headerRow_dark'; - } if (theme === 'dark') { - return 'ReactVirtualized__Table__row_dark'; - } - return ''; - } - - render() { - const { theme } = this.props; - const { vehicles } = this.state; - - return ( - - {({ height, width: tableWidth }) => ( - - - - -
- )} -
- ); - } -} - -VehicleTable.propTypes = propTypes; diff --git a/src/renderer/vehicle/XbeeConnectContainer.jsx b/src/renderer/vehicle/XbeeConnectContainer.jsx deleted file mode 100644 index da3b60e4..00000000 --- a/src/renderer/vehicle/XbeeConnectContainer.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function XbeeConnectContainer() { - return
; -} diff --git a/src/renderer/vehicle/vehicle.css b/src/renderer/vehicle/vehicle.css deleted file mode 100644 index 6fd756e6..00000000 --- a/src/renderer/vehicle/vehicle.css +++ /dev/null @@ -1 +0,0 @@ -/* Defined styles for Vehicle.js */ diff --git a/src/static/config.json b/src/static/config.json new file mode 100644 index 00000000..41da663b --- /dev/null +++ b/src/static/config.json @@ -0,0 +1,7 @@ +{ + "fixtures": false, + "geolocation": true, + + "vehicleDisconnectionTime": 20, + "messageSendRate": 10 +} diff --git a/resources/images/arrow.png b/src/static/images/arrow.png old mode 100755 new mode 100644 similarity index 100% rename from resources/images/arrow.png rename to src/static/images/arrow.png diff --git a/resources/images/icon.png b/src/static/images/icon.png old mode 100755 new mode 100644 similarity index 100% rename from resources/images/icon.png rename to src/static/images/icon.png diff --git a/resources/images/logo/ngcp_calpoly.png b/src/static/images/logo/ngcp_calpoly.png similarity index 100% rename from resources/images/logo/ngcp_calpoly.png rename to src/static/images/logo/ngcp_calpoly.png diff --git a/resources/images/logo/ngcp_pomona.png b/src/static/images/logo/ngcp_pomona.png similarity index 100% rename from resources/images/logo/ngcp_pomona.png rename to src/static/images/logo/ngcp_pomona.png diff --git a/src/static/images/markers/draggable_selector.png b/src/static/images/markers/draggable_selector.png new file mode 100644 index 00000000..2b94c672 Binary files /dev/null and b/src/static/images/markers/draggable_selector.png differ diff --git a/resources/images/markers/poi_unkwn.png b/src/static/images/markers/poi/invalid.png similarity index 100% rename from resources/images/markers/poi_unkwn.png rename to src/static/images/markers/poi/invalid.png diff --git a/resources/images/markers/poi_fp.png b/src/static/images/markers/poi/unknown.png similarity index 100% rename from resources/images/markers/poi_fp.png rename to src/static/images/markers/poi/unknown.png diff --git a/resources/images/markers/poi_vld.png b/src/static/images/markers/poi/valid.png similarity index 100% rename from resources/images/markers/poi_vld.png rename to src/static/images/markers/poi/valid.png diff --git a/resources/images/markers/vehicles/uav.png b/src/static/images/markers/vehicles/plane.png similarity index 100% rename from resources/images/markers/vehicles/uav.png rename to src/static/images/markers/vehicles/plane.png diff --git a/resources/images/markers/vehicles/uav_red.png b/src/static/images/markers/vehicles/plane_red.png similarity index 100% rename from resources/images/markers/vehicles/uav_red.png rename to src/static/images/markers/vehicles/plane_red.png diff --git a/resources/images/markers/vehicles/ugv.png b/src/static/images/markers/vehicles/rover.png similarity index 100% rename from resources/images/markers/vehicles/ugv.png rename to src/static/images/markers/vehicles/rover.png diff --git a/resources/images/markers/vehicles/ugv_red.png b/src/static/images/markers/vehicles/rover_red.png similarity index 100% rename from resources/images/markers/vehicles/ugv_red.png rename to src/static/images/markers/vehicles/rover_red.png diff --git a/src/static/index.ts b/src/static/index.ts new file mode 100644 index 00000000..1f6c6f00 --- /dev/null +++ b/src/static/index.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/camelcase */ + +import ngcp_calpoly from './images/logo/ngcp_calpoly.png'; +import ngcp_pomona from './images/logo/ngcp_pomona.png'; + +import poi_invalid from './images/markers/poi/invalid.png'; +import poi_unknown from './images/markers/poi/unknown.png'; +import poi_valid from './images/markers/poi/valid.png'; + +import plane_red from './images/markers/vehicles/plane_red.png'; +import plane from './images/markers/vehicles/plane.png'; +import rover_red from './images/markers/vehicles/rover_red.png'; +import rover from './images/markers/vehicles/rover.png'; + +import draggable_selector from './images/markers/draggable_selector.png'; + +import arrow from './images/arrow.png'; +import icon from './images/icon.png'; + +import configStatic from './config.json'; +import { startLocation as startLocationString, locations as locationsObject } from './location.json'; +import { + vehicleIds as vehicleIdsObject, + vehicleInfos as vehicleInfosObject, + vehicleJobs as vehicleJobsObject, + vehicleStatuses as vehicleStatusesObject, +} from './vehicle.json'; + +/** + * Object contained in vehicleInfos in vehicle.json. + */ +export interface VehicleInfo { + macAddress: string; + name: string; + 'type': string; +} + +/** + * Latitude and longitude. Optional zoom and radius, used for different + * parts of the application. + */ +export interface Location { + lat: number; + lng: number; + zoom?: number; + radius?: number; +} + +// Add signature to json objects to allow us to access it with TypeScript. +const locations: { + [name: string]: Location | undefined; +} = locationsObject; + +const vehicleIds: { + [name: string]: number | undefined; +} = vehicleIdsObject; + +/** + * All valid job types. This should always match up to the job types in vehicle.json. + */ +export type JobType = 'isrSearch' | 'payloadDrop' | 'ugvRescue' | 'uuvRescue' +| 'quickScan' | 'detailedSearch' | 'guide'; + +const vehicleInfos: { + [vehicleId: number]: VehicleInfo | undefined; +} = vehicleInfosObject; + +const vehicleJobs: { + [jobType: string]: string[] | undefined; +} = vehicleJobsObject; + +/** + * Object contained in vehicleStatuses in vehicle.json. + */ +export interface VehicleStatusStyle { + 'type': string; + message: string; +} + +const vehicleStatuses: { + [status: string]: VehicleStatusStyle | undefined; +} = vehicleStatusesObject; + +// Add logic to set startLocation. +const startLocation: Location = startLocationString && locations[startLocationString] + ? locations[startLocationString] as Location : { + lat: 0, + lng: 0, + zoom: 18, + }; + +/** + * Checks if a number is a valid vehicle id. + */ +function isValidVehicleId(vehicleId: number): boolean { + return vehicleId !== 0 && vehicleInfos[vehicleId] !== undefined; +} + +/** + * Checks if a string is a valid job type. + */ +function isValidJobType(jobType: string): boolean { + return jobType === 'isrSearch' + || jobType === 'payloadDrop' + || jobType === 'ugvRescue' + || jobType === 'uuvRescue' + || jobType === 'quickScan' + || jobType === 'detailedSearch' + || jobType === 'guide'; +} + +/** + * Checks if a task is a valid task type for that job. + */ +function isValidTaskTypeForJob(taskType: string, jobType: JobType): boolean { + return (vehicleJobs[jobType] as string[]).includes(taskType); +} + +export const config = configStatic; + +export const locationConfig = { + locations, + startLocation, +}; + +export const vehicleConfig = { + isValidJobType, + isValidTaskTypeForJob, + isValidVehicleId, + vehicleIds, + vehicleInfos, + vehicleStatuses, + vehicleJobs, +}; + +/** + * A given key for an image will give either a string or an object with similar structure. + */ +export interface RecursiveImageSignature { + [key: string]: string | RecursiveImageSignature; +} + +export const imageConfig: RecursiveImageSignature = { + arrow, + icon, + logo: { ngcp_calpoly, ngcp_pomona }, + markers: { + draggable_selector, + poi: { + invalid: poi_invalid, + unknown: poi_unknown, + valid: poi_valid, + }, + vehicles: { + plane_red, + plane, + rover_red, + rover, + }, + }, +}; + +export default { + config, + imageConfig, + locationConfig, + vehicleConfig, +}; diff --git a/src/static/location.json b/src/static/location.json new file mode 100644 index 00000000..552128db --- /dev/null +++ b/src/static/location.json @@ -0,0 +1,25 @@ +{ + "startLocation": "Educational Flight Range", + "locations": { + "Cal Poly SLO": { + "lat": 35.306205, + "lng": -120.662227, + "zoom": 18 + }, + "Cal Poly Pomona": { + "lat": 34.055869, + "lng": -117.819964, + "zoom": 18 + }, + "Prado Airpark": { + "lat": 33.9325635, + "lng": -117.6288792, + "zoom": 18 + }, + "Educational Flight Range": { + "lat": 35.328571, + "lng": -120.7521664, + "zoom": 18 + } + } +} diff --git a/src/static/vehicle.json b/src/static/vehicle.json new file mode 100644 index 00000000..b2e8ac34 --- /dev/null +++ b/src/static/vehicle.json @@ -0,0 +1,93 @@ +{ + "vehicleIds": { + "UAV": 100, + "UGV": 200, + "UUV": 300, + "VTOL 1": 400, + "VTOL 2": 401, + "ROV": 500, + "Blimp": 600 + }, + "vehicleInfos": { + "0": { + "macAddress": "", + "name": "GCS", + "type": "station" + }, + "100": { + "macAddress": "0013A2004194783A", + "name": "Skywalker", + "type": "plane" + }, + "101": { + "macAddress": "0013A200419477B6", + "name": "Piper Cub", + "type": "plane" + }, + "200": { + "macAddress": "0013A200419475BB", + "name": "UGV", + "type": "rover" + }, + "300": { + "macAddress": "", + "name": "UUV", + "type": "rover" + }, + "400": { + "macAddress": "", + "name": "VTOL 1", + "type": "plane" + }, + "401": { + "macAddress": "", + "name": "VTOL 2", + "type": "plane" + }, + "500": { + "macAddress": "", + "name": "ROV", + "type": "plane" + }, + "600": { + "macAddress": "", + "name": "Blimp", + "type": "plane" + } + }, + "vehicleJobs": { + "isrSearch": ["takeoff", "loiter", "isrSearch", "land"], + "payloadDrop": ["takeoff", "loiter", "payloadDrop", "land"], + "ugvRescue": ["retrieveTarget", "deliverTarget"], + "uuvRescue": ["retrieveTarget"], + "quickScan": ["quickScan"], + "detailedSearch": ["detailedSearch"], + "guide": [] + }, + "vehicleStatuses": { + "ready": { + "type": "success", + "message": "Ready" + }, + "error": { + "type": "failure", + "message": "Error" + }, + "waiting": { + "type": "progress", + "message": "Waiting" + }, + "running": { + "type": "progress", + "message": "Running" + }, + "paused": { + "type": "progress", + "message": "Paused" + }, + "disconnected": { + "type": "failure", + "message": "Disconnected" + } + } +} diff --git a/src/types/componentStyle.ts b/src/types/componentStyle.ts new file mode 100644 index 00000000..7fd77a3a --- /dev/null +++ b/src/types/componentStyle.ts @@ -0,0 +1,60 @@ +import { Moment } from 'moment'; + +/** + * Props with theme as its child. Feel free to extend this prop. + */ +export interface ThemeProps { + /** + * "Light" for light theme, "dark" for dark theme. + */ + theme: 'light' | 'dark'; +} + +/** + * Type guard for ThemeProps. + */ +export function isThemeProps(props: { theme: string }): boolean { + return props.theme === 'light' || props.theme === 'dark'; +} + +/** + * Types for all messages. Consists of "success", "failure", "progress", or "". + */ +export type MessageType = '' | 'success' | 'failure' | 'progress'; + +/** + * Type guard for MessageType. + */ +export function isMessageType(type: string): boolean { + return type === '' || type === 'success' || type === 'failure' || type === 'progress'; +} + +/** + * Structure for a message displayed in log container. + */ +export interface LogMessage { + /** + * The type of the message. Defines the color the message will be printed in. + */ + type?: MessageType; + + /** + * The content of the message. + */ + message: string; + + /** + * The time was received and logged. + */ + time?: Moment; +} + +/** + * Bounding box used in a map container as well as in missions. + */ +export interface BoundingBoxBounds { + top: number; + bottom: number; + left: number; + right: number; +} diff --git a/src/types/fileOption.ts b/src/types/fileOption.ts new file mode 100644 index 00000000..8b6a2cd4 --- /dev/null +++ b/src/types/fileOption.ts @@ -0,0 +1,26 @@ +import { Location } from '../static/index'; + +/** + * Data contents for information that is loaded from a configuration file. + */ +export interface FileLoadOptions { + /** + * Information related to the map. + */ + map: Location; +} + +/** + * Object structure for information stored into a configuration file. + */ +export interface FileSaveOptions { + /** + * Filepath of the configuration file being saved. + */ + filePath: string; + + /** + * Data contents. Will be modified by classes through the "loadConfig" notification. + */ + data: FileLoadOptions; +} diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 00000000..ae4a26ec --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,338 @@ +/* + * Definitions and typeguards for all messages that are sent between GCS and vehicles. + * https://ground-control-station.readthedocs.io/en/latest/communications/messages.html + * + * Includes definitions for tasks too, as those are part of a message. + * https://ground-control-station.readthedocs.io/en/latest/communications/jobs.html + */ + +import { JobType, vehicleConfig } from '../static/index'; + +import { isVehicleStatus, VehicleStatus } from './vehicle'; + +import * as Task from './task'; + +interface MessageBase { + /** + * Type of message. + */ + type: string; +} + +// Definitions for all messages from GCS to vehicles. + +export interface StartMessage extends MessageBase { + type: 'start'; + + /** + * Name of job to perform. + */ + jobType: JobType; +} + +/** + * Type guard for Start Message. + */ +function isStartMessage(message: Message): boolean { + return message.type === 'start' + && vehicleConfig.isValidJobType(message.jobType); +} + +export interface AddMissionMessage extends MessageBase { + type: 'addMission'; + + /** + * Information related to accomplishing specific job. + */ + missionInfo: Task.Task; +} + +/** + * Type guard for AddMission Message. + */ +function isAddMissionMessage(message: Message): boolean { + return message.type === 'addMission' + && message.missionInfo && Task.TypeGuard.isTask(message.missionInfo); +} + +export interface PauseMessage extends MessageBase { + type: 'pause'; +} + +/** + * Type guard for Pause Message. + */ +function isPauseMessage(message: Message): boolean { + return message.type === 'pause'; +} + +export interface ResumeMessage extends MessageBase { + type: 'resume'; +} + +/** + * Type guard for Resume Message. + */ +function isResumeMessage(message: Message): boolean { + return message.type === 'resume'; +} + +export interface StopMessage extends MessageBase { + type: 'stop'; +} + +/** + * Type guard for Stop Message. + */ +function isStopMessage(message: Message): boolean { + return message.type === 'stop'; +} + +export interface ConnectionAckMessage extends MessageBase { + type: 'connectionAck'; +} + +/** + * Type guard for Connection Acknowledge Message. + */ +function isConnectionAcknowledgementMessage(message: Message): boolean { + return message.type === 'connectionAck'; +} + +// Definitions for all messages from vehicles to GCS. + +export interface UpdateMessage extends MessageBase { + type: 'update'; + + /** + * Latitude of the vehicle. + */ + lat: number; + + /** + * Longitude of the vehicle. + */ + lng: number; + + /** + * Altitude of the vehicle. + */ + alt?: number; + + /** + * Current vehicle heading. Value is in degrees. + */ + heading?: number; + + /** + * Current battery of the vehicle, expressed as a decimal. Will vary from 0 to 1. + */ + battery?: number; + + /** + * Status of the vehicle. + */ + status: VehicleStatus; + + /** + * Message generated if vehicle is in an error state. + * + * It is best practice to provide this value to be able to see why the the vehicle's + * status is error. + */ + errorMessage?: string; +} + +/** + * Type guard for Update Message. + */ +function isUpdateMessage(message: Message): boolean { + const mandatoryCheck = message.type === 'update' + && Number.isFinite(message.lat) + && Number.isFinite(message.lng) + && isVehicleStatus(message.status); + + if (!mandatoryCheck) return false; + + const updateMessage = message as UpdateMessage; + + if (updateMessage.alt && !Number.isFinite(updateMessage.alt)) return false; + if (updateMessage.heading && !Number.isFinite(updateMessage.heading)) return false; + if (updateMessage.battery && !Number.isFinite(updateMessage.battery)) return false; + + return true; +} + +export interface POIMessage extends MessageBase { + type: 'poi'; + + /** + * Latitude of the point of interest. + */ + lat: number; + + /** + * Longitude of the point of interest. + */ + lng: number; +} + +/** + * Type guard for Point of Interest Message. + */ +function isPOIMessage(message: Message): boolean { + return message.type === 'poi' + && Number.isFinite(message.lat) + && Number.isFinite(message.lng); +} + +export interface CompleteMessage extends MessageBase { + type: 'complete'; +} + +/** + * Type guard for Complete Message. + */ +function isCompleteMessage(message: Message): boolean { + return message.type === 'complete'; +} + +export interface ConnectMessage extends MessageBase { + type: 'connect'; + + /** + * List of all the different jobs the vehicle is capable of doing. + */ + jobsAvailable: JobType[]; +} + + +/** + * Type guard for Connect Message. + */ +export function isConnectMessage(message: Message): boolean { + return message.type === 'connect' + + // Check if jobsAvailable is a string array of valid job types. + && message.jobsAvailable.every(vehicleConfig.isValidJobType); +} + +// Definitions for all other message types. + +export interface AcknowledgementMessage extends MessageBase { + type: 'ack'; + + /** + * ID of the message that is being acknowledged. + */ + ackid: number; +} + +/** + * Type guard for Acknowledgement Message. + */ +export function isAcknowledgementMessage(message: Message): boolean { + return message.type === 'ack' + && Number.isInteger(message.ackid); +} + +export interface BadMessage extends MessageBase { + type: 'badMessage'; + + /** + * Description of why message was bad. + * + * It is best practice to provide this value to be able to see why the message received + * is bad. + */ + error?: string; +} + +/** + * Type guard for Bad Message. + */ +export function isBadMessage(message: Message): boolean { + return message.type === 'badMessage'; +} + +/** + * All types of messages sent to and from the GCS. + */ +export type Message = StartMessage | AddMissionMessage | PauseMessage | ResumeMessage | StopMessage +| ConnectionAckMessage | UpdateMessage | POIMessage | CompleteMessage | ConnectMessage +| AcknowledgementMessage | BadMessage; + +/** + * Simply checks if the message has a valid type field. This is different from the type + * guards from types/missionInformation and types/task. + * + * Use the more specific checks for each message to see which message it its. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isMessage(message: { [key: string]: any }): boolean { + return message.type && typeof message.type === 'string' && [ + 'start', + 'addMission', + 'pause', + 'resume', + 'stop', + 'connectionAck', + 'update', + 'poi', + 'complete', + 'connect', + 'ack', + 'badMessage', + ].includes(message.type); +} + +/** + * Same as a message, but has the required id, tid, sid, time fields. + */ +export type JSONMessage = Message & { + id: number; + tid: number; + sid: number; + time: number; +}; + +/** + * Type guard for a JSON Message. Check the type guard for a Message for information. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isJSONMessage(message: { [key: string]: any }): boolean { + if (!isMessage(message)) return false; + + // Check if id is a number. + const idCheck = Number.isInteger(message.id); + + // Check if tid is a number and is valid. + const tidCheck = Number.isInteger(message.tid) + && vehicleConfig.vehicleInfos[message.tid] !== undefined; + + // Check if sid is a number and is valid. + const sidCheck = Number.isInteger(message.sid) + && vehicleConfig.vehicleInfos[message.sid] !== undefined; + + return idCheck && tidCheck && sidCheck; +} + +/** + * Type guards for a message. + */ +export const TypeGuard = { + isStartMessage, + isAddMissionMessage, + isPauseMessage, + isResumeMessage, + isStopMessage, + isConnectionAcknowledgementMessage, + isUpdateMessage, + isPOIMessage, + isCompleteMessage, + isConnectMessage, + isAcknowledgementMessage, + isBadMessage, + isMessage, + isJSONMessage, +}; diff --git a/src/types/missionInformation.ts b/src/types/missionInformation.ts new file mode 100644 index 00000000..3a3f17b6 --- /dev/null +++ b/src/types/missionInformation.ts @@ -0,0 +1,167 @@ +import { JobType } from '../static/index'; + +import * as Task from './task'; + +export type MissionName = 'isrSearch' | 'vtolSearch' | 'payloadDrop' | 'ugvRescue' | 'uuvRescue'; + +type ActiveVehicleMappingSignature = { + [missionName in MissionName]: { [vehicleId: number]: JobType }; +} + +/** + * Mapping of vehicle to job to perform for all missions. + */ +export interface ActiveVehicleMapping extends ActiveVehicleMappingSignature { + isrSearch: { [vehicleId: number]: JobType }; + vtolSearch: { [vehicleId: number]: JobType }; + payloadDrop: { [vehicleId: number]: JobType }; + ugvRescue: { [vehicleId: number]: JobType }; + uuvRescue: { [vehicleId: number]: JobType }; +} + +/** + * Options for all missions run. + */ +export interface MissionOptions { + isrSearch: { + /** + * Will not require takeoff task if true. + */ + noTakeoff: boolean; + + /** + * Will not require land task if true. + */ + noLand: boolean; + }; + payloadDrop: { + /** + * Will not require takeoff task if true. + */ + noTakeoff: boolean; + + /** + * Will not require land task if true. + */ + noLand: boolean; + }; +} + +interface InformationBase { + /** + * Name of mission. + */ + missionName: MissionName; + + /** + * Parameters for the mission. Both user and mission generated. + * Optional, but user must put parameters for first mission. Orchestrator + * will attach parameters provided from mission completion to the next mission + * automatically (if more than one mission is run). + */ + parameters?: {}; +} + +// eslint-disable-next-line @typescript-eslint/interface-name-prefix +export interface ISRSearchInformation extends InformationBase { + missionName: 'isrSearch'; + parameters?: { + takeoff: Task.TakeoffTaskParameters; + isrSearch: Task.ISRSearchTaskParameters; + land: Task.LandTaskParameters; + }; +} + +/** + * Type guard for ISR Search mission information. + */ +function isISRSearchInformation(information: Information): boolean { + return information.missionName === 'isrSearch'; +} + +export interface VTOLSearchInformation extends InformationBase { + missionName: 'vtolSearch'; + parameters?: { + quickScan: Task.QuickScanTaskParameters; + }; +} + +/** + * Type guard for VTOL Search mission information. + */ +function isVTOLSearchInformation(information: Information): boolean { + return information.missionName === 'vtolSearch'; +} + +/** + * Type guard for Payload Drop mission information. + */ +export interface PayloadDropInformation extends InformationBase { + missionName: 'payloadDrop'; + parameters?: { + takeoff: Task.TakeoffTaskParameters; + payloadDrop: Task.PayloadDropTaskParameters; + land: Task.LandTaskParameters; + }; +} + +function isPayloadDropInformation(information: Information): boolean { + return information.missionName === 'payloadDrop'; +} + +export interface UGVRescueInformation extends InformationBase { + missionName: 'ugvRescue'; + parameters?: { + retrieveTarget: Task.UGVRetrieveTargetTaskParameters; + deliverTarget: Task.DeliverTargetTaskParameters; + }; +} + +function isUGVRetreiveInformation(information: Information): boolean { + return information.missionName === 'ugvRescue'; +} + +export interface UUVRescueInformation extends InformationBase { + missionName: 'uuvRescue'; + parameters?: {}; +} + +function isUUVRetrieveInformation(information: Information): boolean { + return information.missionName === 'uuvRescue'; +} + +/** + * All types of information that can be provided to a mission. + */ +export type Information = ISRSearchInformation | VTOLSearchInformation +| PayloadDropInformation | UGVRescueInformation | UUVRescueInformation; + + +/** + * Checks if an object is a mission information. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isMissionInformation(object: { [key: string]: any }): boolean { + if (!object.missionName) return false; + + const information = object as Information; + + return isISRSearchInformation(information) + || isVTOLSearchInformation(information) + || isPayloadDropInformation(information) + || isUGVRetreiveInformation(information) + || isUUVRetrieveInformation(information) + || isMissionInformation(information); +} + +/** + * Type guards for a mission information. + */ +export const TypeGuard = { + isISRSearchInformation, + isVTOLSearchInformation, + isPayloadDropInformation, + isUGVRetreiveInformation, + isUUVRetrieveInformation, + isMissionInformation, +}; diff --git a/src/types/task.ts b/src/types/task.ts new file mode 100644 index 00000000..5bf41b4e --- /dev/null +++ b/src/types/task.ts @@ -0,0 +1,309 @@ +interface TaskBase { + /** + * Type of task. + */ + taskType: string; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface TakeoffTaskParameters { + lat: number; + lng: number; + alt: number; + loiter: { + lat: number; + lng: number; + alt: number; + radius: number; + direction: number; + }; +} + +export interface TakeoffTask extends TaskBase, TakeoffTaskParameters { + taskType: 'takeoff'; +} + +/** + * Type guard for Takeoff task. + */ +function isTakeoffTask(task: Task): boolean { + return task.taskType === 'takeoff'; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface LoiterTaskParameters { + lat: number; + lng: number; + alt: number; + radius: number; + direction: number; +} + +export interface LoiterTask extends TaskBase, LoiterTaskParameters { + taskType: 'loiter'; +} + +/** + * Type guard for Loiter task. + */ +export function isLoiterTask(task: Task): boolean { + return task.taskType === 'loiter'; +} + +/** + * Do not import this outside of types/missionInformation. + */ +// eslint-disable-next-line @typescript-eslint/interface-name-prefix +export interface ISRSearchTaskParameters { + alt: number; + waypoints: [ + { + lat: number; + lng: number; + }, + { + lat: number; + lng: number; + }, + { + lat: number; + lng: number; + } + ]; +} + +// eslint-disable-next-line @typescript-eslint/interface-name-prefix +export interface ISRSearchTask extends TaskBase, ISRSearchTaskParameters { + taskType: 'isrSearch'; +} + +/** + * Type guard for ISR Search task. + */ +function isISRSearchTask(task: Task): boolean { + return task.taskType === 'isrSearch' + && task.waypoints && task.waypoints.length === 3; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface PayloadDropTaskParameters { + waypoints: [ + { + lat: number; + lng: number; + alt: number; + }, + { + lat: number; + lng: number; + alt: number; + } + ]; +} + +export interface PayloadDropTask extends TaskBase, PayloadDropTaskParameters { + taskType: 'payloadDrop'; +} + +/** + * Type guard for PayloadDrop task. + */ +function isPayloadDropTask(task: Task): boolean { + return task.taskType === 'payloadDrop'; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface LandTaskParameters { + waypoints: [ + { + lat: number; + lng: number; + alt: number; + }, + { + lat: number; + lng: number; + alt: number; + } + ]; +} + +export interface LandTask extends TaskBase, LandTaskParameters { + taskType: 'land'; +} + +/** + * Type guard for Land task. + */ +function isLandTask(task: Task): boolean { + return task.taskType === 'land'; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface UGVRetrieveTargetTaskParameters { + lat: number; + lng: number; +} + +export interface UGVRetrieveTargetTask extends TaskBase, UGVRetrieveTargetTaskParameters { + taskType: 'retrieveTarget'; +} + +/** + * Type guard for UGV's RetriveTarget task. + */ +function isUGVRetrieveTargetTask(task: Task): boolean { + if (task.taskType !== 'retrieveTarget') return false; + return Object.keys(task).length === 3; // Keys are taskType, lat, lng. +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface DeliverTargetTaskParameters { + lat: number; + lng: number; +} + +export interface DeliverTargetTask extends TaskBase, DeliverTargetTaskParameters { + taskType: 'deliverTarget'; +} + +/** + * Type guard for DeliverTarget task. + */ +function isDeliverTargetTask(task: Task): boolean { + return task.taskType === 'deliverTarget'; +} + +export interface UUVRetrieveTargetTask extends TaskBase { + taskType: 'retrieveTarget'; +} + +/** + * Type guard for UUV's RetrieveTarget task. + */ +function isUUVRetrieveTargetTask(task: Task): boolean { + if (task.taskType !== 'retrieveTarget') return false; + return Object.keys(task).length === 1; // Only key is taskType. +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface QuickScanTaskParameters { + waypoints: [ + { + lat: number; + lng: number; + }, + { + lat: number; + lng: number; + }, + { + lat: number; + lng: number; + }, + { + lat: number; + lng: number; + } + ]; +} + +export interface QuickScanTask extends TaskBase, QuickScanTaskParameters { + taskType: 'quickScan'; +} + +/** + * Type guard for QuickScan task. + */ +function isQuickScanTask(task: Task): boolean { + return task.taskType === 'quickScan'; +} + +/** + * Do not import this outside of types/missionInformation. + */ +export interface DetailedSearchParameters { + lat: number; + lng: number; +} + + +export interface DetailedSearchTask extends TaskBase, DetailedSearchParameters { + taskType: 'detailedSearch'; +} + +/** + * Type guard for quickScan task. + */ +function isDetailedSearchTask(task: Task): boolean { + return task.taskType === 'detailedSearch'; +} + +/** + * A task for a vehicle to perform. Check which specific task it is by + * checking through the type guards. + */ +export type Task = TakeoffTask | LoiterTask | ISRSearchTask | PayloadDropTask | LandTask +| UGVRetrieveTargetTask | DeliverTargetTask | UUVRetrieveTargetTask | QuickScanTask +| DetailedSearchTask; + +/** + * Special type for missions only to use. This is to ensure that missions can properly + * pass in tasks. + */ +export type TaskParameters = TakeoffTaskParameters | LoiterTaskParameters | ISRSearchTaskParameters +| PayloadDropTaskParameters | LandTaskParameters | UGVRetrieveTargetTaskParameters +| DeliverTargetTaskParameters | UUVRetrieveTargetTask | QuickScanTaskParameters +| DetailedSearchParameters; + +/** + * Checks if an object is a task. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isTask(object: { [key: string]: any }): boolean { + if (!object.taskType) return false; + + const task = object as Task; + + return isTakeoffTask(task) + || isLoiterTask(task) + || isISRSearchTask(task) + || isPayloadDropTask(task) + || isLandTask(task) + || isUGVRetrieveTargetTask(task) + || isDeliverTargetTask(task) + || isUUVRetrieveTargetTask(task) + || isQuickScanTask(task) + || isDetailedSearchTask(task); +} + +/** + * Type guards for a task. + */ +export const TypeGuard = { + isTakeoffTask, + isLoiterTask, + isISRSearchTask, + isPayloadDropTask, + isLandTask, + isUGVRetrieveTargetTask, + isDeliverTargetTask, + isUUVRetrieveTargetTask, + isQuickScanTask, + isDetailedSearchTask, + isTask, +}; diff --git a/src/types/vehicle.ts b/src/types/vehicle.ts new file mode 100644 index 00000000..2245a362 --- /dev/null +++ b/src/types/vehicle.ts @@ -0,0 +1,56 @@ +import { JobType } from '../static/index'; + +/** + * Status of the vehicle, lets us know of where the vehicle is in its mission. + */ +export type VehicleStatus = 'ready' | 'error' | 'disconnected' | 'waiting' | 'running' | 'paused'; + +export function isVehicleStatus(status: string): boolean { + return status === 'ready' || status === 'error' || status === 'disconnected' || status === 'waiting' || status === 'running' || status === 'paused'; +} + +/** + * Vehicle object for all classes to use. This is necessary because Vehicle class will + * be uncasted when being sent through ipcRenderer. + */ +export interface VehicleObject { + /** + * ID of the vehicle. + */ + vehicleId: number; + + /** + * Current status of the vehicle. + */ + status: VehicleStatus; + + /** + * Jobs of the vehicle. + */ + jobs: JobType[]; + + /** + * Current latitude of the vehicle. Starts at 0. + */ + lat: number; + + /** + * Current longitude of the vehicle. Starts at 0. + */ + lng: number; + + /** + * Current altitude of the vehicle. + */ + alt?: number; + /** + * + * Current battery of the vehicle, expressed as a decimal. Will vary from 0 to 1. + */ + battery?: number; + + /** + * Current vehicle heading. Value is in degrees. + */ + heading?: number; +} diff --git a/src/util/TileLayer.CachedTileLayer.js b/src/util/TileLayer.CachedTileLayer.js deleted file mode 100644 index 50ebefc6..00000000 --- a/src/util/TileLayer.CachedTileLayer.js +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Credits: - * MazeMap's Leaflet PouchDBCached: https://github.com/MazeMap/Leaflet.TileLayer.PouchDBCached/blob/master/L.TileLayer.PouchDBCached.js - * Leaflet's TileLayer: https://github.com/Leaflet/Leaflet/blob/master/src/layer/tile/TileLayer.js - * - * No linting here as code is not written by us. - */ - -/* eslint-disable */ - -import L from 'leaflet'; -import PouchDB from 'pouchdb'; - -export default L.TileLayer.CachedTileLayer = L.TileLayer.extend({ - options: { - useCache: false, - saveToCache: true, - useOnlyCache: false, - cacheMaxAge: 24 * 3600 * 1000, - }, - - /** - * Adds custom options to support options with caching to normal TileLayer. - * @override - */ - initialize: function initialize(url, options) { - this._url = url; - options = L.Util.setOptions(this, { ...this.options, ...options, cacheFormat: 'image/png' }); - - if (!options.useCache) { - this._db = null; - return; - } - - this._db = new PouchDB('offline-tiles'); - - if (options.detectRetina && L.Browser.retina && options.maxZoom > 0) { - options.tileSize = Math.floor(options.tileSize / 2); - if (!options.zoomReverse) { - options.zoomOffset++; - options.maxZoom--; - } else { - options.zoomOffset--; - options.minZoom++; - } - } - - if (typeof options.subdomains === 'string') { - options.subdomains = options.subdomains.split(''); - } - - if (!L.Browser.android) { - this.on('tileunload', this._onTileRemove); - } - }, - - /** - * Adds support to create cached tiles to a normal TileLayer. - * @override - */ - createTile: function createTile(coords, done) { - const tile = document.createElement('img'); - - tile.onerror = L.bind(this._tileOnError, this, done, tile); - - if (this.options.crossOrigin) { - tile.crossOrigin = ''; - } - - tile.alt = ''; - - tile.setAttribute('role', 'presentation'); - - const tileUrl = this.getTileUrl(coords); - if (this.options.useCache) { - this._db.get(tileUrl, { revs_info: true }, this._onCacheLookup(tile, tileUrl, done)); - } else { - tile.onload = L.bind(this._tileOnLoad, this, done, tile); - tile.src = tileUrl; - } - - return tile; - }, - - /** - * Returns a callback to be run when the database is finished with a fetch operation. - * @param {Object} tile tile description - * @param {string} tileUrl tile url - * @param {bool} done status - * @returns {Function} - */ - _onCacheLookup: function _onCacheLookup(tile, tileUrl, done) { - return (err, data) => { - if (err) {} // eslint-disable-line no-empty - - if (data) { - return this._onCacheHit(tile, tileUrl, data, done); - } - - return this._onCacheMiss(tile, tileUrl, done); - }; - }, - - _onCacheHit: function _onCacheHit(tile, tileUrl, data, done) { - this.fire('tilecachehit', { - tile, - url: tileUrl, - }); - - this._db.getAttachment(tileUrl, 'tile').then((blob) => { - const url = URL.createObjectURL(blob); - - if (Date.now() > data.timestamp + this.options.cacheMaxAge && !this.options.useOnlyCache) { - if (this.options.saveToCache) { - tile.onload = L.bind(this._saveTile, this, tile, tileUrl, data._revs_info[0].rev, done); - } - - tile.crossOrigin = 'Anonymous'; - tile.src = tileUrl; - tile.onerror = () => { this.src = url; }; - } else { - tile.onload = L.bind(this._tileOnLoad, this, done, tile); - tile.src = url; - } - }); - }, - - _onCacheMiss: function _onCacheMiss(tile, tileUrl, done) { - this.fire('tilecachemiss', { - tile, - url: tileUrl, - }); - - if (this.options.useOnlyCache) { - tile.onload = L.Util.falseFn; - tile.src = L.Util.emptyImageUrl; - } else { - if (this.options.saveToCache) { - tile.onload = L.bind(this._saveTile, this, tile, tileUrl, undefined, done); - } else { - tile.onload = L.bind(this._tileOnLoad, this, done, tile); - } - - tile.crossOrigin = 'Anonymous'; - tile.src = tileUrl; - } - }, - - /** - * Returns an event handler that runs when the tile is ready. - * The handler will delete the document from PouchDB if an existing revision is found, keeping - * latest valid copy of the image in the cache. - * @param {Object} tile tile description - * @param {string} tileUrl tile url - * @param {bool} existingRevision check in cache if tile is there - * @param {bool} done status - */ - _saveTile: function _saveTile(tile, tileUrl, existingRevision, done) { - if (!this.options.saveToCache) return; - - const canvas = document.createElement('canvas'); - canvas.width = tile.naturalWidth || tile.width; - canvas.height = tile.naturalHeight || tile.height; - - const context = canvas.getContext('2d'); - context.drawImage(tile, 0, 0); - - const format = this.options.cacheFormat; - - canvas.toBlob((blob) => { - this._db.put({ - _id: tileUrl, - _rev: existingRevision, - timestamp: Date.now(), - }) - .then(status => this._db.putAttachment(tileUrl, 'tile', status.rev, blob, format)) - .then(() => { - if (done) done(); - }) - .catch(() => { - if (done) done(); - }); - }); - }, - - /** - * Starts seeding the cache given a bounding box and a min/max zoom level. - * @param {L.LatLngBounds} bbox bounds - * @param {number} minZoom min zoom level - * @param {number} maxZoom max zoom level - */ - seed: function seed(bbox, minZoom, maxZoom) { - if (!this.options.useCache) return; - if (minZoom > maxZoom) return; - if (!this._map) return; - - const queue = []; - - for (let z = minZoom; z <= maxZoom; z++) { - const northEastPoint = this._map.project(bbox.getNorthEast(), z); - const southWestPoint = this._map.project(bbox.getSouthWest(), z); - - const tileBounds = this._pxBoundsToTileRange(L.bounds([northEastPoint, southWestPoint])); - - for (let j = tileBounds.min.y; j <= tileBounds.max.y; j++) { - for (let i = tileBounds.min.x; i <= tileBounds.max.x; i++) { - const point = new L.point(i, j); - point.z = z; - queue.push(this._getTileUrl(point)); - } - } - } - - const seedData = { - bbox, - minZoom, - maxZoom, - queueLength: queue.length, - }; - - this.fire('seedstart', seedData); - const tile = this._createTile(); - tile._layer = this; - this._seedOneTile(tile, queue, seedData); - }, - - _createTile: function _createTile() { - return document.createElement('img'); - }, - - /** - * Custom getTileUrl function that uses coords instead of the maps current zoomlevel - * @param {Object} coords map coords - * @returns {string} - */ - _getTileUrl: function _getTileUrl(coords) { - let zoom = coords.z; - if (this.options.zoomReverse) { - zoom = this.options.maxZoom - zoom; - } - zoom += this.options.zoomOffset; - - return L.Util.template(this._url, L.extend({ - r: this.options.detectRetina && L.Browser.retina && this.optiona.maxZoom > 0 ? '@2x' : '', - s: this._getSubdomain(coords), - x: coords.x, - y: this.options.tms ? this._globalTileRange.max.y - coords.y : coords.y, - z: this.options.maxNativeZoom ? Math.min(zoom, this.options.maxNativeZoom) : zoom, - }, this.options)); - }, - - /** - * Uses a defined tile to eat through one item in the queue and asynchrounously recursively call - * itself when the tile has finished loading. - * @param {Object} tile the tile to load - * @param {Array} remaining remaining tiles to load - * @param {Object} seedData data about tile to seed - */ - _seedOneTile: function _seedOneTile(tile, remaining, seedData) { - if (!remaining.length) { - this.fire('seedend', seedData); - return; - } - this.fire('seedprogress', { - bbox: seedData.bbox, - minZoom: seedData.minZoom, - maxZoom: seedData.maxZoom, - queueLength: seedData.queueLength, - remainingLength: remaining.length, - }); - - const url = remaining.shift(); - - this._db.get(url, (err, data) => { - if (err) {} // eslint-disable-line no-empty - - if (!data) { - tile.onload = () => { - this._saveTile(tile, url, null); - this._seedOneTile(tile, remaining, seedData); - }; - - tile.crossOrigin = 'Anonymous'; - tile.src = url; - } else { - this._seedOneTile(tile, remaining, seedData); - } - }); - }, -}); diff --git a/src/util/ipc.ts b/src/util/ipc.ts new file mode 100644 index 00000000..cdd8e7a8 --- /dev/null +++ b/src/util/ipc.ts @@ -0,0 +1,527 @@ +/* + * Write all ipcRenderer post functions here to ensure that all developers know the type + * of what are included into these messages. When writing the function, write where + * these notifications are received. Write the functions in ALPHABETICAL ORDER (of notification). + * A simple note on which file (assume going from src directory) will help developers out a lot. + * Do not forget to include the function in the default export! + * + * When writing the receiving side of ipcRenderer, the function called by the callback + * function must be the EXACT SAME NAME as the notification message and have parameters with + * SAME EXACT NAMES as the parameters of the ipc function below. + * + * Look at all code for examples. Do not forget that the first parameter of the callback function + * of the receiving side of ipcRenderer will have an Event object passed to it. Name this object "_" + * unless you plan on using the object, then name it "event". + */ + +import { BrowserWindow, ipcRenderer } from 'electron'; + +import { JobType, Location } from '../static/index'; + +import { BoundingBoxBounds, LogMessage } from '../types/componentStyle'; +import * as FileOptions from '../types/fileOption'; +import * as Message from '../types/message'; +import * as MissionInformation from '../types/missionInformation'; +import * as Task from '../types/task'; +import { VehicleObject } from '../types/vehicle'; + +/** + * Post "centerMapToVehicle" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postCenterMapToVehicle(vehicle: VehicleObject): void { + ipcRenderer.send('post', 'centerMapToVehicle', vehicle); +} + +/** + * Post "competeMission" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postCompleteMission( + missionName: string, + completionParameters: { [key: string]: Task.TaskParameters }, +): void { + ipcRenderer.send('post', 'completeMission', missionName, completionParameters); +} + +/** + * Post "confirmCompleteMission" notification. + * + * Files that take this notification: + * - renderer/missionWindow/MissionWindow + */ +function postConfirmCompleteMission(): void { + ipcRenderer.send('post', 'confirmCompleteMission'); +} + +/** + * Post "connectToVehicle" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postConnectToVehicle( + jsonMessage: Message.JSONMessage, + newMessage: boolean, + shouldAcknowledge: boolean, +): void { + ipcRenderer.send('post', 'connectToVehicle', jsonMessage, newMessage, shouldAcknowledge); +} + +/** + * Post "createBoundingBoxes" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postCreateBoundingBoxes( + ...boundingBoxes: { name: string; color?: string; bounds?: BoundingBoxBounds}[] +): void { + ipcRenderer.send('post', 'createBoundingBoxes', ...boundingBoxes); +} + +/** + * Post "createWaypoints" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postCreateWaypoints(...waypoints: { name: string; location?: Location }[]): void { + ipcRenderer.send('post', 'createWaypoints', ...waypoints); +} + +/** + * Post "disconnectFromVehicle" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postDisconnectFromVehicle(vehicleId: number): void { + ipcRenderer.send('post', 'disconnectFromVehicle', vehicleId); +} + +/** + * Post "finishMissions" notification. This is different from the "completeMission" + * notification in that this is sent once all missions to run are completed. + * + * Files that take this notification: + * - renderer/missionWindow/MissionWindow + */ +function postFinishMissions(completionParameters: Task.TaskParameters[]): void { + ipcRenderer.send('post', 'finishMissions', completionParameters); +} + +/** + * Post "handleAcknowledgementMessage" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postHandleAcknowledgementMessage( + jsonMessage: Message.JSONMessage, + newMessage: boolean, +): void { + ipcRenderer.send('post', 'handleAcknowledgementMessage', jsonMessage, newMessage); +} + +/** + * Post "handleBadMessage" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postHandleBadMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + ipcRenderer.send('post', 'handleBadMessage', jsonMessage, newMessage); +} + +/** + * Post "handleCompleteMessage" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postHandleCompleteMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + ipcRenderer.send('post', 'handleCompleteMessage', jsonMessage, newMessage); +} + +/** + * Post "handlePOIMessage" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postHandlePOIMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + ipcRenderer.send('post', 'handlePOIMessage', jsonMessage, newMessage); +} + +/** + * Post "handleUpdateMessage" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postHandleUpdateMessage(jsonMessage: Message.JSONMessage, newMessage: boolean): void { + ipcRenderer.send('post', 'handleUpdateMessage', jsonMessage, newMessage); +} + +/** + * Post "hideMissionWindow" notification. + * + * Files that take this notification: + * - main/index + */ +function postHideMissionWindow(): void { + ipcRenderer.send('post', 'hideMissionWindow'); +} + +/** + * Post "loadConfig" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postLoadConfig( + loadOptions: FileOptions.FileLoadOptions, + mainWindow?: BrowserWindow | null, + missionWindow?: BrowserWindow | null, +): void { + if (mainWindow !== undefined) { + if (mainWindow) mainWindow.webContents.send('loadConfig', loadOptions); + if (missionWindow) missionWindow.webContents.send('loadConfig', loadOptions); + } else { + ipcRenderer.send('post', 'loadConfig', loadOptions); + } +} + +/** + * Post "logMessages" notification. Please never end the messages to log with a period. + * + * Files that take this notification: + * - renderer/mainWindow/log/LogContainer + */ +function postLogMessages(...messages: LogMessage[]): void { + ipcRenderer.send('post', 'logMessages', ...messages); +} + +/** + * Post "pauseMission" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postPauseMission(): void { + ipcRenderer.send('post', 'pauseMission'); +} + +/** + * Post "receiveMessage" notification. + * + * Files that take this notification: + * - common/MessageHandler + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function postReceiveMessage(message: any): void { + ipcRenderer.send('post', 'receiveMessage', message); +} + +/** + * Post "resumeMission" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postResumeMission(): void { + ipcRenderer.send('post', 'resumeMission'); +} + +/** + * Post "saveConfig" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postSaveConfig( + saveOptions: FileOptions.FileSaveOptions, + mainWindow?: BrowserWindow | null, + missionWindow?: BrowserWindow | null, +): void { + if (mainWindow !== undefined) { + if (mainWindow) mainWindow.webContents.send('saveConfig', saveOptions); + if (missionWindow) missionWindow.webContents.send('saveConfig', saveOptions); + } else { + ipcRenderer.send('post', 'saveConfig', saveOptions); + } +} + +/** + * Post "sendMessage" notification. + * + * Files that take this notification: + * - common/MessageHandler + */ +function postSendMessage(vehicleId: number, message: Message.Message): void { + ipcRenderer.send('post', 'sendMessage', vehicleId, message); +} + +/** + * Post "setMapToUserLocation" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postSetMapToUserLocation( + mainWindow?: BrowserWindow | null, + missionWindow?: BrowserWindow | null, +): void { + if (mainWindow !== undefined) { + if (mainWindow) mainWindow.webContents.send('setMapToUserLocation'); + if (missionWindow) missionWindow.webContents.send('setMapToUserLocation'); + } else { + ipcRenderer.send('post', 'setMapToUserLocation'); + } +} + +/** + * Post "showMissionWindow" notification. + * + * Files that take this notification: + * - main/index + */ +function postShowMissionWindow(): void { + ipcRenderer.send('post', 'showMissionWindow'); +} + +/** + * Post "startMissions" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postStartMissions( + missions: MissionInformation.Information[], + activeVehicleMapping: MissionInformation.ActiveVehicleMapping, + options: MissionInformation.MissionOptions, + requireConfirmation: boolean, +): void { + ipcRenderer.send('post', 'startMissions', missions, activeVehicleMapping, options, requireConfirmation); +} + +/** + * Post "startNextMission" notification. + * + * Files that take this notification: + * - common/Orchestrator + */ +function postStartNextMission(): void { + ipcRenderer.send('post', 'startNextMission'); +} + +/** + * Post "stopMissions" notification. + * + * Files that take this notification: + * - common/Orchestrator + * - renderer/missionWindow/MissionWindow + */ +function postStopMissions(): void { + ipcRenderer.send('post', 'stopMissions'); +} + +/** + * Post "stopSendingMessage" notification. + * + * Files that take this notification: + * - common/MessageHandler + */ +function postStopSendingMessage(ackMessage: Message.JSONMessage): void { + ipcRenderer.send('post', 'stopSendingMessage', ackMessage); +} + +/** + * Post "stopSendingMessages" notification. + * + * Files that take this notification: + * - common/MessageHandler + */ +function postStopSendingMessages(): void { + ipcRenderer.send('post', 'stopSendingMessages'); +} + +/** + * Post "toggleTheme" notification. + * + * Files that take this notification: + * - renderer/index + */ +function postToggleTheme(): void { + ipcRenderer.send('post', 'toggleTheme'); +} + +/** + * Post "unlockParameterInputs" notification. + * + * Files that take this notification: + * - renderer/missionWindow/parameter/ISRSearch + * - renderer/missionWindow/parameter/PayloadDrop + * - renderer/missionWindow/parameter/UGVRescue + * - renderer/missionWindow/parameter/VTOLSearch + */ +function postUnlockParameterInputs(waypointType: string): void { + ipcRenderer.send('post', 'unlockParameterInputs', waypointType); +} + +/** + * Post "updateActiveVehicleMapping" notification. + * + * Files that take this notification: + * - renderer/missionWindow/MissionWindow + */ +function postUpdateActiveVehicleMapping( + missionName: MissionInformation.MissionName, + jobType: JobType, + vehicleId: number, +): void { + ipcRenderer.send('post', 'updateActiveVehicleMapping', missionName, jobType, vehicleId); +} + +/** + * Post "updateBoundingBoxes" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postUpdateBoundingBoxes( + updateMap: boolean, + ...boundingBoxes: { name: string; color?: string; bounds: BoundingBoxBounds }[] +): void { + ipcRenderer.send('post', 'updateBoundingBoxes', updateMap, ...boundingBoxes); +} + +/** + * Post "updateInformation" notification. + * + * Files that take this notification: + * - renderer/missionWindow/MissionWindow + */ +function postUpdateInformation(information: MissionInformation.Information): void { + ipcRenderer.send('post', 'updateInformation', information); +} + +/** + * Post "updateMapLocation" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postUpdateMapLocation( + location: Location, + mainWindow?: BrowserWindow | null, + missionWindow?: BrowserWindow | null, +): void { + if (mainWindow !== undefined) { + if (mainWindow) mainWindow.webContents.send('updateMapLocation', location); + if (missionWindow) missionWindow.webContents.send('updateMapLocation', location); + } else { + ipcRenderer.send('post', 'updateMapLocation', location); + } +} + +/** + * Post "updateOptions" notification. + * + * Files that take this notification: + * - renderer/missionWindow/MissionWindow + */ +function postUpdateOptions( + missionName: MissionInformation.MissionName, + option: string, + value: boolean, +): void { + ipcRenderer.send('post', 'updateOptions', missionName, option, value); +} + +/** + * Post "updatePOIs" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + */ +function postUpdatePOIs(...pois: { location: Location; type: 'valid' | 'invalid' | 'unknown' }[]): void { + ipcRenderer.send('post', 'updatePOIs', ...pois); +} + +/** + * Post "updateVehicles" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + * - renderer/mainWindow/vehicle/VehicleContainer + * - renderer/missionWindow/MissionWindow + */ +function postUpdateVehicles(...vehicles: VehicleObject[]): void { + ipcRenderer.send('post', 'updateVehicles', ...vehicles); +} + +/** + * Post "updateWaypoints" notification. + * + * Files that take this notification: + * - renderer/mainWindow/map/MapContainer + * - renderer/missionWindow/parameters/ISRSearch + * - renderer/missionWindow/parameters/VTOLSearch + * - renderer/missionWindow/parameters/PayloadDrop + * - renderer/missionWindow/parameters/UUVRescue + * + * @param updateMap Set this to true, except from the marker's drag event. + */ +function postUpdateWaypoints( + updateMap: boolean, + ...waypoints: { name: string; location: Location }[] +): void { + ipcRenderer.send('post', 'updateWaypoints', updateMap, ...waypoints); +} + +export default { + postCenterMapToVehicle, + postCompleteMission, + postConfirmCompleteMission, + postConnectToVehicle, + postCreateBoundingBoxes, + postCreateWaypoints, + postDisconnectFromVehicle, + postFinishMissions, + postHandleAcknowledgementMessage, + postHandleBadMessage, + postHandleCompleteMessage, + postHandlePOIMessage, + postHandleUpdateMessage, + postHideMissionWindow, + postLoadConfig, + postLogMessages, + postPauseMission, + postReceiveMessage, + postResumeMission, + postSaveConfig, + postSendMessage, + postSetMapToUserLocation, + postShowMissionWindow, + postStartMissions, + postStartNextMission, + postStopMissions, + postStopSendingMessage, + postStopSendingMessages, + postToggleTheme, + postUnlockParameterInputs, + postUpdateActiveVehicleMapping, + postUpdateBoundingBoxes, + postUpdateInformation, + postUpdateMapLocation, + postUpdateOptions, + postUpdatePOIs, + postUpdateVehicles, + postUpdateWaypoints, +}; diff --git a/src/util/parameter.ts b/src/util/parameter.ts new file mode 100644 index 00000000..febfee5d --- /dev/null +++ b/src/util/parameter.ts @@ -0,0 +1,17 @@ +/* eslint-disable import/prefer-default-export */ + +import { Component } from 'react'; + +type ParameterComponent = + Component<{}, { ready: boolean; checklist: { [check: string]: number | undefined } }>; + +export function readyToStart( + component: ParameterComponent, +): boolean { + const { checklist } = component.state; + + const ready = Object.values(checklist).every((value): boolean => value !== undefined); + + if (ready) component.setState({ ready }); + return ready; +} diff --git a/src/util/util.ts b/src/util/util.ts new file mode 100644 index 00000000..a82cb39f --- /dev/null +++ b/src/util/util.ts @@ -0,0 +1,105 @@ +import { Component } from 'react'; + +import { Location } from '../static/index'; + +import { BoundingBoxBounds } from '../types/componentStyle'; +import { VehicleObject } from '../types/vehicle'; + +/** + * Updates vehicles being shown. This is run on map and vehicle containers. + */ +export function updateVehicles( + component: Component<{}, { vehicles: { [vehicleId: string]: VehicleObject } }>, + ...vehicles: VehicleObject[] +): void { + const { vehicles: currentVehicles } = component.state; + const newVehicles = currentVehicles; + + vehicles.forEach((vehicle): void => { + newVehicles[vehicle.vehicleId] = vehicle; + }); + + component.setState({ vehicles: newVehicles }); +} + +/** + * Checks if the string is a JSON. + */ +export function isJSON(message: string): boolean { + if (!message) return false; + + try { + JSON.parse(message); + } catch (e) { + return false; + } + return true; +} + +export default { + isJSON, + updateVehicles, +}; + +/** + * Performs binary search for an element. The index that will be returned will either be: + * - index of value provided. + * - index where the value would fit in the array, if it were spliced in. + * ex: searchIndex([1, 2, 4, 5], 3, (a, b) => a - b) will return index = 2. + */ +export function searchIndex( + array: T[], + value: T, + compareFunction: (a: T, b: T) => number, +): number { + let left = 0; + let right = array.length - 1; + + while (left <= right) { + const middle = Math.floor((left + right) / 2); + const comparison = compareFunction(value, array[middle]); + + if (comparison === 0) { + return middle; + } + + if (comparison < 0) { + left = middle + 1; + } else { + right = middle - 1; + } + } + + return left; +} + +/** + * Calculates bounding box given a list of (lat, lng). + * + * @param waypoints Points in bounding box. + * @param error Extra margin of error around box. + */ +export function getBoundingBox(waypoints: Location[], error: number): BoundingBoxBounds { + const top = Math.max(...waypoints.map((waypoint): number => waypoint.lat)); + const bottom = Math.min(...waypoints.map((waypoint): number => waypoint.lat)); + const left = Math.max(...waypoints.map((waypoint): number => waypoint.lng)); + const right = Math.min(...waypoints.map((waypoint): number => waypoint.lng)); + + // Units in meters. https://stackoverflow.com/a/39540339. + const deltaLat = error / 111.32 / 1000; + const deltaLng = error * 360 / 40075 / Math.cos((top + bottom) / 2) / 1000; + + return { + top: top + deltaLat, + bottom: bottom - deltaLat, + left: left - deltaLng, + right: right + deltaLng, + }; +} + +/** + * Calculates distance between two points. + */ +export function getDistance(x: Location, y: Location): number { + return Math.sqrt(((x.lat - y.lat) ** 2) + ((x.lng - y.lng) ** 2)); +} diff --git a/test/common/struct/DictionaryList.test.ts b/test/common/struct/DictionaryList.test.ts new file mode 100644 index 00000000..30ff9d11 --- /dev/null +++ b/test/common/struct/DictionaryList.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; + +import DictionaryList, { Callback } from '../../../src/common/struct/DictionaryList'; + +let dict: DictionaryList; + +describe('DictionaryList', (): void => { + describe('+ shift()', (): void => { + beforeEach((): void => { dict = new DictionaryList(); }); + + it('should shift values in order that they were inserted', (): void => { + dict.push('P1', 100); + dict.push('P1', 101); + dict.push('P1', 102); + + expect(dict.size()).to.equal(3); + expect(dict.shift('P1')).to.equal(100); + expect(dict.shift('P1')).to.equal(101); + expect(dict.shift('P1')).to.equal(102); + expect(dict.size()).to.equal(0); + }); + + it('should return undefined when attempting to shift an empty or invalid key', (): void => { + dict.push('P1', 100); + + expect(dict.shift('P1')).to.equal(100); + expect(dict.shift('P1')).to.be.undefined; + expect(dict.shift('ETC')).to.be.undefined; + }); + }); + + describe('+ remove()', (): void => { + beforeEach((): void => { dict = new DictionaryList(); }); + + it('should use custom callback method to remove when set', (): void => { + const callback: Callback = (_, index, array): boolean => index === array.length - 1; + + dict.push('P1', 100); + dict.push('P1', 101); + dict.push('P1', 102); + dict.push('P1', 103); + + expect(dict.remove('P1', callback)).to.equal(103); + expect(dict.remove('P1', callback)).to.equal(102); + expect(dict.remove('P1', callback)).to.equal(101); + expect(dict.remove('P1', callback)).to.equal(100); + expect(dict.remove('P1', callback)).to.be.undefined; + }); + }); + + describe('+ push()', (): void => { + beforeEach((): void => { dict = new DictionaryList(); }); + + it('should add an item to the list with the specified key', (): void => { + expect(dict.get('P1')).to.be.undefined; + expect(dict.get('P2')).to.be.undefined; + expect(dict.get('P3')).to.be.undefined; + + dict.push('P1', 100); + dict.push('P1', 100); + dict.push('P1', 100); + dict.push('P2', 100); + + expect(dict.size('P1')).to.equal(3); + expect(dict.size('P2')).to.equal(1); + expect(dict.size('P3')).to.equal(0); + expect(dict.size()).to.equal(4); + }); + + it('should be able to add to an old key that has previously been exhaused', (): void => { + dict.push('P1', 100); + dict.push('P1', 100); + dict.push('P1', 100); + dict.push('P2', 100); + + dict.shift('P1'); + dict.shift('P1'); + dict.shift('P1'); + + expect(dict.size('P1')).to.equal(0); + expect(dict.size('P2')).to.equal(1); + expect(dict.size()).to.equal(1); + + dict.push('P1', 101); + + expect(dict.size('P1')).to.equal(1); + expect(dict.shift('P1')).to.equal(101); + }); + }); + + describe('+ removeAll()', (): void => { + beforeEach((): void => { dict = new DictionaryList(); }); + + it('should remove the values equal to that specified for the given key', (): void => { + dict.push('P1', 100); + dict.push('P1', 101); + dict.push('P2', 102); + dict.push('P3', 103); + + expect(dict.size('P1')).to.equal(2); + expect(dict.size()).to.equal(4); + + dict.removeAll('P1', (value): boolean => value === 101); + expect(dict.size('P1')).to.equal(1); + + dict.removeAll('P1', (value): boolean => value === 100); + expect(dict.size('P1')).to.equal(0); + expect(dict.size()).to.equal(2); + }); + + it('should remove all the values equal to that specified for the given key, other keys are untouched', (): void => { + dict.push('P1', 101); + dict.push('P1', 101); + dict.push('P1', 101); + dict.push('P1', 102); + dict.push('P1', 101); + + dict.push('P2', 101); + + expect(dict.size('P1')).to.equal(5); + expect(dict.size()).to.equal(6); + + dict.removeAll('P1', (value): boolean => value === 101); + expect(dict.size('P1')).to.equal(1); + + expect(dict.size()).to.equal(2); + }); + }); +}); diff --git a/test/common/struct/UpdateHandler.test.ts b/test/common/struct/UpdateHandler.test.ts new file mode 100644 index 00000000..e20e4f4a --- /dev/null +++ b/test/common/struct/UpdateHandler.test.ts @@ -0,0 +1,310 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; + +import UpdateHandler from '../../../src/common/struct/UpdateHandler'; + +let handler: UpdateHandler; + +describe('UpdateHandler', (): void => { + describe('+ addHandler() & + event()', (): void => { + beforeEach((): void => { handler = new UpdateHandler(); }); + + it('should allow adding a simple one time event', (): void => { + let counter = 0; + + handler.addHandler('status', (v): boolean => { + if (v === 10) counter += 1; + return v === 10; + }); + + expect(counter).to.equal(0); + + // Event should run and remove itself (only runs once). + handler.event('status', 10); + expect(counter).to.equal(1); + + // Since event not present, should do nothing. + handler.event('status', 10); + expect(counter).to.equal(1); + }); + + it('event does not get removed if it does not get run', (): void => { + let counter = 0; + + handler.addHandler('status', (v): boolean => { + if (v === 10) counter += 1; + return v === 10; + }); + + expect(counter).to.equal(0); + + handler.event('nope', 10); + expect(counter).to.equal(0); + + handler.event('status', 10); + expect(counter).to.equal(1); + }); + + it('should allow adding events that can be run, but not get removed immediately', (): void => { + let counter = 0; + + handler.addHandler('status', (v): boolean => { + counter += 1; + return v === 10; + }); + + expect(counter).to.equal(0); + + handler.event('status', 0); + expect(counter).to.equal(1); + + handler.event('status', -100); + expect(counter).to.equal(2); + + handler.event('status', 1); + expect(counter).to.equal(3); + + handler.event('status', 10); + expect(counter).to.equal(4); + + handler.event('status', 10); + expect(counter).to.equal(4); + }); + + it('should allow adding several events with different keys', (): void => { + let counter = 0; + + handler.addHandler('status', (v): boolean => { + counter += 1; + return v === 10; + }); + + handler.addHandler('job', (v): boolean => { + counter += 100; + return v === 'error'; + }); + + expect(counter).to.equal(0); + + handler.event('status', 0); + expect(counter).to.equal(1); + + handler.event('job', -100); + expect(counter).to.equal(101); + + handler.event('status', 1); + expect(counter).to.equal(102); + + handler.event('job', 'error'); + expect(counter).to.equal(202); + + handler.event('status', 10); + expect(counter).to.equal(203); + + handler.event('job', 'error'); + expect(counter).to.equal(203); + + handler.event('status', 10); + expect(counter).to.equal(203); + }); + + it('should allow adding several events with the same key', (): void => { + let counter1 = 0; + let counter2 = 0; + + handler.addHandler('status', (v): boolean => { + counter1 += 1; + return v === 10; + }); + + handler.addHandler('status', (v): boolean => { + counter2 += 100; + return v === 'error'; + }); + + expect(counter1).to.equal(0); + expect(counter2).to.equal(0); + + handler.event('status', 0); + expect(counter1).to.equal(1); + expect(counter2).to.equal(100); + + handler.event('status', 1); + expect(counter1).to.equal(2); + expect(counter2).to.equal(200); + + handler.event('status', 'error'); + expect(counter1).to.equal(3); + expect(counter2).to.equal(300); + + handler.event('status', 'error'); + expect(counter1).to.equal(4); + expect(counter2).to.equal(300); + + handler.event('status', 10); + expect(counter1).to.equal(5); + expect(counter2).to.equal(300); + + handler.event('status', 10); + expect(counter1).to.equal(5); + expect(counter2).to.equal(300); + }); + + it('should allow for events to expire on their own after a certain amount of time', (done): void => { + /* + * This test tests that the timeout runs after a certain amount of time. + * If this test is timing out, this is likely because the timeout handler is + * not getting executed. + * Failure can also happen if the expiry runs before the events (unlikely). + * Try increasing the expiry to a longer time (will slow tests) to check if this is your + * issue. + */ + let counter = 0; + + handler.addHandler('status', (v): boolean => { + counter += 1; + return v === 10; + }, { + callback(): void { + counter += 100; + expect(counter).to.equal(102); + + handler.event('status', 0); + expect(counter).to.equal(102); + + done(); + }, + time: 25, + }); + + expect(counter).to.equal(0); + + handler.event('status', 0); + expect(counter).to.equal(1); + + handler.event('status', 0); + expect(counter).to.equal(2); + }); + + it('should allow for events to expire on their own after a certain amount of time, with more events for the same key', (done): void => { + /* + * This test tests that the timeout runs after a certain amount of time. + * If this test is timing out, this is likely because the timeout handler is + * not getting executed. + * Failure can also happen if the expiry runs before the events (unlikely). + * Try increasing the expiry to a longer time (will slow tests) to check if this is your + * issue. + */ + let counter1 = 0; + let counter2 = 0; + + handler.addHandler('status', (v): boolean => { + counter1 += 1; + return v === 10; + }, { + callback(): void { + counter1 -= 100; + expect(counter1).to.equal(-98); + expect(counter2).to.equal(2); + + handler.event('status', 0); + expect(counter1).to.equal(-98); + expect(counter2).to.equal(3); + + done(); + }, + time: 25, + }); + + handler.addHandler('status', (v): boolean => { + counter2 += 1; + return v === 'error'; + }); + + expect(counter1).to.equal(0); + expect(counter2).to.equal(0); + + handler.event('status', 0); + expect(counter1).to.equal(1); + expect(counter2).to.equal(1); + + handler.event('status', 0); + expect(counter1).to.equal(2); + expect(counter2).to.equal(2); + }); + }); + + describe('+ events()', (): void => { + beforeEach((): void => { handler = new UpdateHandler(); }); + + it('should processing many events at once', (): void => { + handler = new UpdateHandler(); + let statusCounter = 0; + let locationCounter = 0; + + handler.addHandler('status', (v): boolean => { + statusCounter += v; + return false; + }); + + handler.addHandler('location', (v): boolean => { + locationCounter += v; + return false; + }); + + expect(statusCounter).to.equal(0); + expect(locationCounter).to.equal(0); + + handler.events({ status: 1, location: 1 }); + + expect(statusCounter).to.equal(1); + expect(locationCounter).to.equal(1); + + handler.events({ status: 5 }); + + expect(statusCounter).to.equal(6); + expect(locationCounter).to.equal(1); + }); + }); + + + describe('+ removeHandler()', (): void => { + beforeEach((): void => { handler = new UpdateHandler(); }); + + it('should allow the removal of the given handler', (): void => { + handler = new UpdateHandler(); + let statusCounter = 0; + let locationCounter = 0; + + const statusHandler = handler.addHandler('status', (v): boolean => { + statusCounter += v; + return false; + }); + + const locationHandler = handler.addHandler('location', (v): boolean => { + locationCounter += v; + return false; + }); + + expect(statusCounter).to.equal(0); + expect(locationCounter).to.equal(0); + + handler.events({ status: 1, location: 1 }); + + expect(statusCounter).to.equal(1); + expect(locationCounter).to.equal(1); + + statusHandler.removeHandler(); + handler.events({ status: 1, location: 1 }); + + expect(statusCounter).to.equal(1); + expect(locationCounter).to.equal(2); + + locationHandler.removeHandler(); + handler.events({ status: 1, location: 1 }); + + expect(statusCounter).to.equal(1); + expect(locationCounter).to.equal(2); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..eabc865f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "electron-webpack/tsconfig-base.json", + "compilerOptions": { + "target": "es6", + "module": "commonjs", + + "jsx": "react", + + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/typings/image.d.ts b/typings/image.d.ts new file mode 100644 index 00000000..a973eed5 --- /dev/null +++ b/typings/image.d.ts @@ -0,0 +1,5 @@ +// Allows us to import PNG files. +declare module '*.png' { + const value: string; + export = value; +} diff --git a/typings/react-leaflet-control.d.ts b/typings/react-leaflet-control.d.ts new file mode 100644 index 00000000..f9c4e79b --- /dev/null +++ b/typings/react-leaflet-control.d.ts @@ -0,0 +1,5 @@ +/* + * Allows us to use react-leaflet-control. + * TODO: Add typings to this (and perhaps contribute it to the original repo). + */ +declare module 'react-leaflet-control'; diff --git a/typings/xbee-api.d.ts b/typings/xbee-api.d.ts new file mode 100644 index 00000000..4ddb0a5a --- /dev/null +++ b/typings/xbee-api.d.ts @@ -0,0 +1,723 @@ +/* eslint-disable import/prefer-default-export, @typescript-eslint/class-name-casing, max-len, camelcase, @typescript-eslint/camelcase, @typescript-eslint/no-explicit-any */ + +/* eslint-enable max-len */ + +declare module 'xbee-api' { + import { Transform } from 'stream'; + + type START_BYTE = number; + type ESCAPE = number; + type XOFF = number; + type XON = number; + + export interface Frame { + addresses?: number[]; + analogSamples?: object; + broadcastRadius?: number; + clusterId?: string; + command?: string; + commandParameter?: any; + commandStatus?: string; + data?: Buffer; + deliveryStatus?: number; + destination16?: string; + destination64?: string; + deviceType?: number; + digiManufacturerID?: string; + digiProfileID?: string; + digitalSamples?: object; + discoveryStatus?: number; + destinationEndpoint?: string; + hopCount?: number; + id?: number; + modemStatus?: number; + nodeIdentifier?: string; + numSamples?: number; + options?: number; + profileId?: string; + remote16?: string; + remote64?: string; + remoteParent16?: string; + receiveOptions?: number; + remoteCommandOptions?: number; + rssi?: number; + sender16?: string; + sender64?: string; + sensors?: number; + sensorValues?: { + AD0: number; + AD1: number; + AD2: number; + AD3: number; + T: number; + temperature?: number; + relativeHumidity?: number; + trueHumidity?: number; + waterPresent: boolean; + }; + sourceEndpoint?: string; + sourceEvent?: number; + transmitRetryCount?: number; + type?: number; + } + + export interface XbeeAPIOptions { + raw_frames?: boolean; + api_mode?: 1 | 2; + module?: '802.15.4' | 'ZNet' | 'ZigBee' | 'Any'; + convert_adc?: boolean; + vref_adc?: number; + parser_buffer_size?: number; + builder_buffer_size?: number; + } + + export class XBeeAPI { + private options: XbeeAPIOptions; + + private parseState: { + buffer: Buffer; + offset: number; + length: number; + total: number; + checksum: number; + b: number; + escape_next: boolean; + waiting: boolean; + }; + + /** + * Builder transform stream of the Xbee. + */ + public builder: Transform; + + /** + * Parser transform stream of the Xbee. + */ + public parser: Transform; + + public constructor(options?: XbeeAPIOptions); + + private escape(buffer: Buffer): Buffer; + + /** + * Returns an API frame (buffer) created from the passed frame object. See Creating frames + * from objects to write to the XBee for details on how these passed objects are specified. + */ + public buildFrame(frame: Frame): Buffer; + + /** + * Parses and returns a frame object from the buffer passed. Note that the buffer must be + * a complete frame, starting with the start byte and ending with the checksum byte. See + * Objects created from received API Frames for details on how the retured objects are + * specified. + */ + public parseFrame(rawFrame: Buffer): Frame; + + /** + * Returns whether the library implements a parser for the frame contained in the provided + * buffer. The buffer only needs to contain up to the frame type segment to determine if it + * can be parsed, but `parseFrame()` will need a complete frame. + */ + public canParse(buffer: Buffer): boolean; + + private canBuild(type: string): boolean; + + public nextFrameId(): number; + + /** + * Returns a parser function with the profile `function(emitter, buffer) {}`. This can be + * passed to a serial reader such as serialport. Note that XBeeAPI will not use the + * emitter to emit a parsed frame, but it's own emitter + */ + public rawParser(): (emitter: any, buffer: Buffer) => void; + + private newStream(): any; + + /** + * Parses data in the buffer, assumes it is comming directly from the XBee. If a complete + * frame is collected, it is emitted as Event: 'frame_object'. + */ + public parseRaw(buffer: Buffer, enc?: any, cb?: () => void): void; + } + + interface Constants { + START_BYTE: START_BYTE; + ESCAPE: ESCAPE; + XOFF: XOFF; + XON: XON; + ESCAPE_WITH: number; + + UNKNOWN_16: [number, number]; + UNKNOWN_64: [number, number, number, number, number, number, number, number]; + BROADCAST_16_XB: [number, number]; + COORDINATOR_16: [number, number]; + COORDINATOR_64: [number, number, number, number, number, number, number, number]; + + ESCAPE_BYTES: [ + START_BYTE, + ESCAPE, + XOFF, + XON + ]; + + FRAME_TYPE: { + AT_COMMAND: number; + 0x08: string; + AT_COMMAND_QUEUE_PARAMETER_VALUE: number; + 0x09: string; + ZIGBEE_TRANSMIT_REQUEST: number; + 0x10: string; + EXPLICIT_ADDRESSING_ZIGBEE_COMMAND_FRAME: number; + 0x11: string; + REMOTE_AT_COMMAND_REQUEST: number; + 0x17: string; + CREATE_SOURCE_ROUTE: number; + 0x21: string; + REGISTER_JOINING_DEVICE: number; + 0x24: string; + AT_COMMAND_RESPONSE: number; + 0x88: string; + MODEM_STATUS: number; + 0x8A: string; + ZIGBEE_TRANSMIT_STATUS: number; + 0x8B: string; + ZIGBEE_RECEIVE_PACKET: number; + 0x90: string; + ZIGBEE_EXPLICIT_RX: number; + 0x91: string; + ZIGBEE_IO_DATA_SAMPLE_RX: number; + 0x92: string; + XBEE_SENSOR_READ: number; + 0x94: string; + NODE_IDENTIFICATION: number; + 0x95: string; + REMOTE_COMMAND_RESPONSE: number; + 0x97: string; + OTA_FIRMWARE_UPDATE_STATUS: number; + 0xA0: string; + ROUTE_RECORD: number; + 0xA1: string; + DEVICE_AUTHENITCATED_INDICATOR: number; + 0xA2: string; + MTO_ROUTE_REQUEST: number; + 0xA3: string; + REGISTER_JOINING_DEVICE_STATUS: number; + 0xA4: string; + JOIN_NOTIFICATION_STATUS: number; + 0xA5: string; + + // Series 1/802.15.4 Support + TX_REQUEST_64: number; + 0x00: string; + TX_REQUEST_16: number; + 0x01: string; + TX_STATUS: number; + 0x89: string; + RX_PACKET_64: number; + 0x80: string; + RX_PACKET_16: number; + 0x81: string; + RX_PACKET_64_IO: number; + 0x82: string; + RX_PACKET_16_IO: number; + 0x83: string; + }; + + DISCOVERY_STATUS: { + NO_DISCOVERY_OVERHEAD: number; + 0x00: string; + ADDRESS_DISCOVERY: number; + 0x01: string; + ROUTE_DISCOVERY: number; + 0x02: string; + ADDRESS_AND_ROUTE_DISCOVERY: number; + 0x03: string; + EXTENDED_TIMEOUT_DISCOVERY: number; + 0x40: string; + }; + + DELIVERY_STATUS: { + SUCCESS: number; + 0x00: string; + MAC_ACK_FALIURE: number; + 0x01: string; + CA_FAILURE: number; + 0x02: string; + INVALID_DESTINATION_ENDPOINT: number; + 0x15: string; + NETWORK_ACK_FAILURE: number; + 0x21: string; + NOT_JOINED_TO_NETWORK: number; + 0x22: string; + SELF_ADDRESSED: number; + 0x23: string; + ADDRESS_NOT_FOUND: number; + 0x24: string; + ROUTE_NOT_FOUND: number; + 0x25: string; + BROADCAST_SOURCE_FAILED: number; + 0x26: string; + INVALID_BINDING_TABLE_INDEX: number; + 0x2B: string; + RESOURCE_ERROR: number; + 0x2C: string; + ATTEMPTED_BROADCAST_WITH_APS_TRANS: number; + 0x2D: string; + ATTEMPTED_BROADCAST_WITH_APS_TRANS_EE0: number; + 0x2E: string; + RESOURCE_ERROR_B: number; + 0x32: string; + DATA_PAYLOAD_TOO_LARGE: number; + 0x74: string; + INDIRECT_MESSAGE_UNREQUESTED: number; + 0x75: string; + }; + + COMMAND_STATUS: { + OK: number; + 0x00: string; + ERROR: number; + 0x01: string; + INVALID_COMMAND: number; + 0x02: string; + INVALID_PARAMETER: number; + 0x03: string; + REMOTE_CMD_TRANS_FAILURE: number; + 0x04: string; + }; + + MODEM_STATUS: { + HARDWARE_RESET: number; + 0x00: string; + WATCHDOG_RESET: number; + 0x01: string; + JOINED_NETWORK: number; + 0x02: string; + DISASSOCIATED: number; + 0x03: string; + COORDINATOR_STARTED: number; + 0x06: string; + SECURITY_KEY_UPDATED: number; + 0x07: string; + VOLTAGE_SUPPLY_LIMIT_EXCEEDED: number; + 0x0D: string; + CONFIGURATION_CHANGED_DURING_JOIN: number; + 0x11: string; + STACK_ERROR: number; + 0x80: string; + }; + + RECEIVE_OPTIONS: { + PACKET_ACKNOWLEDGED: number; + 0x01: string; + PACKET_WAS_BROADCAST: number; + 0x02: string; + PACKET_ENCRYPTED: number; + 0x20: string; + PACKET_SENT_FROM_END_DEVICE: number; + 0x40: string; + }; + + DEVICE_TYPE: { + COORDINATOR: number; + 0x00: string; + ROUTER: number; + 0x01: string; + END_DEVICE: number; + 0x02: string; + }; + + DIGITAL_CHANNELS: { + MASK: { + 0: [string, string]; + 1: [string, string]; + 2: [string, string]; + 3: [string, string]; + 4: [string]; + 5: [string, string]; + 6: [string, string]; + 7: [string, string]; + 10: [string, string]; + 11: [string, string]; + 12: [string, string]; + }; + + DIO0: number; + DIO1: number; + DIO2: number; + DIO3: number; + DIO4: number; + DIO5: number; + DIO6: number; + DIO7: number; + DIO10: number; + DIO11: number; + DIO12: number; + + AD0: number; + AD1: number; + AD2: number; + AD3: number; + + ASSOCIATE: number; + RTS: number; + CTS: number; + RSSI: number; + PWM: number; + CD: number; + }; + + ANALOG_CHANNELS: { + MASK: { + 0: [string, string]; + 1: [string, string]; + 2: [string, string]; + 3: [string, string]; + 7: [string]; + }; + + PIN: { + 20: number; + 19: number; + 18: number; + 17: number; + 11: number; + 15: number; + 16: number; + 12: number; + 6: number; + 7: number; + 4: number; + }; + + AD0: number; + AD1: number; + AD2: number; + AD3: number; + + DIO0: number; + DIO1: number; + + SUPPLY: number; + }; + + PULLUP_RESISTOR: { + MASK: { + 0: [string]; + 1: [string, string]; + 2: [string, string]; + 3: [string, string]; + 4: [string, string]; + 5: [string, string]; + 6: [string, string, string]; + 7: [string, string]; + 8: [string, string]; + 9: [string, string]; + 10: [string]; + 11: [string, string, string]; + 12: [string, string]; + 13: [string, string]; + }; + + PIN: { + 11: number; + 17: number; + 18: number; + 19: number; + 20: number; + 16: number; + 9: number; + 3: number; + 15: number; + 13: number; + 4: number; + 6: number; + 7: number; + 12: number; + + DIO4: number; + DIO3: number; + DIO2: number; + DIO1: number; + DIO0: number; + DIO6: number; + DIO8: number; + DIO9: number; + DIO12: number; + DIO10: number; + DIO11: number; + DIO7: number; + + AD3: number; + AD2: number; + AD1: number; + AD0: number; + + PWM0: number; + PWM1: number; + + RTS: number; + DTR: number; + SLEEP_REQUEST: number; + DIN: number; + CONFIG: number; + ASSOCIATE: number; + ON: number; + SLEEP: number; + RSSI: number; + CTS: number; + }; + }; + + CHANGE_DETECTION: { + MASK: { + 0: [string]; + 1: [string]; + 2: [string]; + 3: [string]; + 4: [string]; + 5: [string]; + 6: [string]; + 7: [string]; + 8: [string]; + 9: [string]; + 10: [string]; + 11: [string]; + }; + + PIN: { + 20: number; + 19: number; + 18: number; + 17: number; + 11: number; + 15: number; + 16: number; + 12: number; + 9: number; + 13: number; + 6: number; + 7: number; + }; + + DIO0: number; + DIO1: number; + DIO2: number; + DIO3: number; + DIO4: number; + DIO5: number; + DIO6: number; + DIO7: number; + DIO8: number; + DIO9: number; + DIO10: number; + DIO11: number; + }; + + PIN_MODE: { + P2: { + UNMONITORED_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + P1: { + UNMONITORED_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + P0: { + DISABLED: number; + RSSI_PWM: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D4: { + DISABLED: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D7: { + DISABLED: number; + CTS_FLOW_CTRL: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + RS485_TX_LOW: number; + RS485_TX_HIGH: number; + 0x00: string; + 0x01: string; + 0x03: string; + 0x04: string; + 0x05: string; + 0x06: string; + 0x07: string; + }; + + D5: { + DISABLED: number; + ASSOC_LED: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D6: { + DISABLED: number; + RTS_FLOW_CTRL: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D0: { + DISABLED: number; + NODE_ID_ENABLED: number; // Only valid for D0! + ANALOG_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x02: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D1: { + DISABLED: number; + NODE_ID_ENABLED: number; // Only valid for D0! + ANALOG_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x02: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D2: { + DISABLED: number; + NODE_ID_ENABLED: number; // Only valid for D0! + ANALOG_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x02: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + + D3: { + DISABLED: number; + NODE_ID_ENABLED: number; // Only valid for D0! + ANALOG_INPUT: number; + DIGITAL_INPUT: number; + DIGITAL_OUTPUT_LOW: number; + DIGITAL_OUTPUT_HIGH: number; + 0x00: string; + 0x01: string; + 0x02: string; + 0x03: string; + 0x04: string; + 0x05: string; + }; + }; + + PIN_COMMAND: { + PIN: { + '6': string; + '7': string; + '4': string; + '12': string; + '16': string; + '20': string; + '19': string; + '18': string; + '17': string; + '11': string; + '15': string; + }; + + DIO10: string; + DIO11: string; + DIO12: string; + DIO7: string; + DIO6: string; + DIO0: string; + DIO1: string; + DIO2: string; + DIO3: string; + DIO4: string; + DIO5: string; + + PWM0: string; + PWM1: string; + + AD0: string; + AD1: string; + AD2: string; + AD3: string; + + RSSIM: string; + CTS: string; + ASSOC: string; + }; + + FRAME_TYPE_SETS: { + '802.15.4': [number, number, number, number, number, number, number, number, number, + number, number, number, number]; + ZNet: [number, number, number, number, number, number, number, number, number, + number, number, number, number, number]; + ZigBee: [number, number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number, number, number, + number, number, number]; + Any: [number, number, number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number]; + }; + } + + export const constants: Constants; +} diff --git a/webpack.config.js b/webpack.config.js index 5247797e..acad33aa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,6 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-var-requires */ + +// Webpack configuration cannot be written with ES6 import/export so we disable no-commonjs. const Dotenv = require('dotenv-webpack'); @@ -21,6 +23,6 @@ module.exports = { new Dotenv(), ], resolve: { - extensions: ['.js', '.jsx'], + extensions: ['.jsx'], }, };