diff --git a/.gitattributes b/.gitattributes index 42a998b1f..59525ef62 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -sitl_setup/run.sh eol=lf \ No newline at end of file +sitl_setup/run.sh eol=lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a9069000..d93b942a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -This document outlines the full process for creating, working on, and merging tickets within the FGCS repository. +This document outlines the full process for creating, working on, and merging tickets within the FGCS repository. All steps can be completed directly through GitHub and VS Code. --- @@ -35,7 +35,7 @@ You can either: 2. Drag your ticket into the **“In Progress”** column on the board. -3. Open the ticket again, locate the **Development** section on the right-hand side, and click **“Create a new branch.”** +3. Open the ticket again, locate the **Development** section on the right-hand side, and click **“Create a new branch.”** Then click **“Create Branch.”**

@@ -58,10 +58,10 @@ You can commit changes using **VS Code (recommended)** or **Bash**. ### Method 1: VS Code (Recommended) -1. Open the **Source Control** tab on the left-hand side. -2. Right-click the files you want to commit and select **“Stage Changes.”** +1. Open the **Source Control** tab on the left-hand side. +2. Right-click the files you want to commit and select **“Stage Changes.”** Confirm the correct files appear under the *Staged Changes* section. -3. Add a clear commit message describing your changes. +3. Add a clear commit message describing your changes. 4. Press **Commit**, then click the **arrows in the bottom-left corner** to *Synchronize Changes* (push your commit).

@@ -92,8 +92,8 @@ git push origin --- ### Merging -Navigate to the ticket again and open a pull request. Wait until the automatic tests are ran and fix any changes suggested by those. +Navigate to the ticket again and open a pull request. Wait until the automatic tests are ran and fix any changes suggested by those. -Then, in the top right click reviewers then Copilot (GitHub Pro required). Copilot will then offer a code review, it is strongly suggested you make these changes. +Then, in the top right click reviewers then Copilot (GitHub Pro required). Copilot will then offer a code review, it is strongly suggested you make these changes. Finally, request the "Avis Code team" as a reviewer, and one of the official code reviewers will look over your work. If they deem it suitable to merge, you will be cleared to merge; alternatively, changes will be suggested which you will make and then request another review. diff --git a/gcs/.env_sample b/gcs/.env_sample deleted file mode 100644 index e23e49d29..000000000 --- a/gcs/.env_sample +++ /dev/null @@ -1,2 +0,0 @@ -VITE_MAPTILER_API_KEY= -VITE_BACKEND_URL=http://127.0.0.1:4237 \ No newline at end of file diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts index 4f7f33e89..5049f94ae 100644 --- a/gcs/electron/main.ts +++ b/gcs/electron/main.ts @@ -40,6 +40,9 @@ import registerVibeStatusIPC, { } from "./modules/vibeStatusWindow" import registerVideoIPC, { destroyVideoWindow } from "./modules/videoWindow" import { readParamsFile } from "./utils/paramsFile" +import registerGraphWindowIPC, { + destroyAllGraphWindows, +} from "./modules/graphWindow" // Check if required data files exist function checkRequiredDataFiles(): { @@ -277,6 +280,7 @@ function createWindow() { registerFFmpegBinaryIPC() registerRTSPStreamIPC(win) registerFlaParamsIPC() + registerGraphWindowIPC(win) // Open links in browser, not within the electron window. // Note, links must have target="_blank" @@ -465,6 +469,7 @@ function closeWindows() { destroyVibeStatusWindow() cleanupAllRTSPStreams() destroyFlaParamsWindow() + destroyAllGraphWindows() } // Quit when all windows are closed, except on macOS. There, it's common diff --git a/gcs/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts new file mode 100644 index 000000000..3593b9430 --- /dev/null +++ b/gcs/electron/modules/graphWindow.ts @@ -0,0 +1,212 @@ +import { BrowserWindow, ipcMain } from "electron" +import path from "path" + +const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"] + +type GraphKey = "graph_a" | "graph_b" | "graph_c" | "graph_d" + +type GraphWindowMeta = { + id: string + msg: string + field: string + title: string + description?: string + label?: string +} + +type OpenArgs = { + graphKey: GraphKey + meta: GraphWindowMeta +} + +type CloseArgs = { + graphKey: GraphKey +} + +type GraphPoint = { + graphKey: GraphKey + data: { x: number; y: number } +} + +const graphWins: Partial> = {} +const lastMeta: Partial> = {} + +// Reference to the main FGCS window (the one with Redux + toolbar) +let mainWin: BrowserWindow | null = null + +/** Get a window if it's still usable; auto-clean if destroyed. */ +function getGraphWin(graphKey: GraphKey): BrowserWindow | null { + const win = graphWins[graphKey] + if (!win) return null + + if (win.isDestroyed()) { + graphWins[graphKey] = undefined + return null + } + + if (win.webContents.isDestroyed()) { + graphWins[graphKey] = undefined + return null + } + + return win +} + +function notifyMainGraphClosed(graphKey: GraphKey) { + try { + if (!mainWin) return + if (mainWin.isDestroyed()) return + if (mainWin.webContents.isDestroyed()) return + mainWin.webContents.send("app:graph-window:closed", { graphKey }) + } catch { + // ignore during shutdown / race conditions + } +} + +function sendInit(graphKey: GraphKey) { + const win = getGraphWin(graphKey) + const meta = lastMeta[graphKey] + if (!win || !meta) return + try { + win.webContents.send("app:graph-window:init", { graphKey, meta }) + } catch { + // ignore (window may be closing) + } +} + +export function openGraphWindow({ graphKey, meta }: OpenArgs) { + // Always cache latest meta for this slot (used by ready-handshake) + lastMeta[graphKey] = meta + + // Reuse existing window if it's alive + let win = getGraphWin(graphKey) + + if (!win) { + win = new BrowserWindow({ + width: 700, + height: 350, + frame: true, + icon: path.join(process.env.VITE_PUBLIC!, "app_icon.ico"), + show: false, + title: meta?.title ?? "Graph", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + }, + fullscreen: false, + fullscreenable: false, + alwaysOnTop: true, + }) + + graphWins[graphKey] = win + win.setMenuBarVisibility(false) + + // IMPORTANT: clean up reference when user closes window + win.on("closed", () => { + graphWins[graphKey] = undefined + delete lastMeta[graphKey] + // Tell the main window so Redux can untick the checkbox + notifyMainGraphClosed(graphKey) + }) + + // Load content only on first creation + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "graphWindow.html") + } else { + win.loadFile(path.join(process.env.DIST!, "graphWindow.html")) + } + + // Best-effort init on load (ready-handshake below is the real guarantee) + win.webContents.once("did-finish-load", () => { + const alive = getGraphWin(graphKey) + if (!alive) return + sendInit(graphKey) + }) + } else { + // Window already exists: just update title + init payload + try { + win.setTitle(meta?.title ?? "Graph") + sendInit(graphKey) + } catch (e) { + // If it threw, treat it as dead and try again once + graphWins[graphKey] = undefined + return openGraphWindow({ graphKey, meta }) + } + } + + // Show/focus safely + if (!win.isDestroyed()) { + win.show() + win.focus() + } +} + +export function closeGraphWindow({ graphKey }: CloseArgs) { + const win = getGraphWin(graphKey) + if (!win) return + // Do NOT set undefined here — let the 'closed' event clean up. + win.close() +} + +export function destroyAllGraphWindows() { + ;(Object.keys(graphWins) as GraphKey[]).forEach((k) => { + const win = getGraphWin(k) + win?.close() + }) +} + +// Accept the main window so we can send close events back to Redux UI +export default function registerGraphWindowIPC(appWin?: BrowserWindow) { + if (appWin) mainWin = appWin + + ipcMain.removeHandler("app:open-graph-window") + ipcMain.removeHandler("app:close-graph-window") + ipcMain.removeHandler("app:update-graph-windows") + + // READY HANDSHAKE: + // popout renderer sends "app:graph-window:ready" after it registers listeners. + // When we receive it, we send init using cached meta so init is never missed. + ipcMain.removeAllListeners("app:graph-window:ready") + ipcMain.on("app:graph-window:ready", (event) => { + const senderId = event.sender.id + + const graphKey = (Object.keys(graphWins) as GraphKey[]).find((k) => { + const w = getGraphWin(k) + return w?.webContents?.id === senderId + }) + + if (!graphKey) return + + sendInit(graphKey) + }) + + ipcMain.handle("app:open-graph-window", (_event, args: OpenArgs) => { + openGraphWindow(args) + }) + + ipcMain.handle("app:close-graph-window", (_event, args: CloseArgs) => { + closeGraphWindow(args) + }) + + ipcMain.handle( + "app:update-graph-windows", + (_event, graphResults: GraphPoint[] | false) => { + if (!graphResults) return + + for (const result of graphResults) { + const win = getGraphWin(result.graphKey) + if (!win) continue + + try { + win.webContents.send("app:send-graph-point", result) + } catch { + // If it errors during close, drop it + graphWins[result.graphKey] = undefined + delete lastMeta[result.graphKey] + // Also tell the main window, in case the close event races + notifyMainGraphClosed(result.graphKey) + } + } + }, + ) +} diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index bd2f1bc42..76430db2c 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -16,6 +16,9 @@ const ALLOWED_INVOKE_CHANNELS = [ "settings:save-settings", "app:open-video-window", "app:close-video-window", + "app:open-graph-window", + "app:close-graph-window", + "app:update-graph-windows", "app:start-rtsp-stream", "app:stop-rtsp-stream", "app:get-current-stream-url", @@ -54,6 +57,7 @@ const ALLOWED_SEND_CHANNELS = [ "window:update-title", // drone state updates (connectedToDrone, isArmed, isFlying) "app:drone-state", + "app:graph-window:ready", ] const ALLOWED_ON_CHANNELS = [ @@ -67,6 +71,9 @@ const ALLOWED_ON_CHANNELS = [ "settings:open", "mavlink-forwarding:open", "app:send-fla-params", + "app:graph-window:init", + "app:send-graph-point", + "app:graph-window:closed", ] contextBridge.exposeInMainWorld("ipcRenderer", { @@ -94,6 +101,13 @@ contextBridge.exposeInMainWorld("ipcRenderer", { throw new Error(`IPC on channel '${channel}' is not allowed`) }, + removeListener: (channel, callback) => { + if (ALLOWED_ON_CHANNELS.includes(channel)) { + return ipcRenderer.removeListener(channel, callback) + } + throw new Error(`IPC removeListener channel '${channel}' is not allowed`) + }, + // Secure removeAllListeners - only for whitelisted channels removeAllListeners: (channel) => { if (ALLOWED_ON_CHANNELS.includes(channel)) { diff --git a/gcs/graphWindow.html b/gcs/graphWindow.html new file mode 100644 index 000000000..2059e9716 --- /dev/null +++ b/gcs/graphWindow.html @@ -0,0 +1,12 @@ + + + + + + + + +

+ + + diff --git a/gcs/src/components/dashboard/resizableInfoBox.jsx b/gcs/src/components/dashboard/resizableInfoBox.jsx index a22d34c5c..38f2e1bf8 100644 --- a/gcs/src/components/dashboard/resizableInfoBox.jsx +++ b/gcs/src/components/dashboard/resizableInfoBox.jsx @@ -1,6 +1,6 @@ /* Resizable information box. This is the left hand side if the screen that moves and will contain - both the telemetry information and actions, which are both separate components. These + both the telemetry information and actions, which are both separate components. These components are passed in via the props.children which in this case is from dashboard.jsx */ diff --git a/gcs/src/components/graphWindow/graphWindow.jsx b/gcs/src/components/graphWindow/graphWindow.jsx new file mode 100644 index 000000000..6ece18a16 --- /dev/null +++ b/gcs/src/components/graphWindow/graphWindow.jsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react" + +export default function GraphWindow() { + const [meta, setMeta] = useState(null) + + useEffect(() => { + window.ipcRenderer.on("app:graph-window:init", (_event, data) => { + setMeta(data) + }) + }, []) + + return ( +
+
{meta?.title ?? "Graph"}
+
+ {meta?.id ?? ""} +
+
+ {meta?.description ?? ""} +
+
Graph goes here later.
+
+ ) +} diff --git a/gcs/src/components/graphs/graphPanel.jsx b/gcs/src/components/graphs/graphPanel.jsx deleted file mode 100644 index c5562ba62..000000000 --- a/gcs/src/components/graphs/graphPanel.jsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - GraphPanel Component - - This component is responsible for rendering a group of resizable panels, each containing a RealtimeGraph component. - The graphs are displayed based on the selected values passed as props. If no value is selected for a graph, - a message prompts the user to select a value for that graph. - - Props: - - selectValues: An object containing the selected values for each graph. - - graphRefs: An object containing the refs for each graph. - - graphColors: An object containing the colors for each graph. -*/ - -// 3rd Party Imports -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels" -import RealtimeGraph from "../realtimeGraph.jsx" -import { graphOptions } from "../../helpers/realTimeGraphOptions.js" - -// Helper function to extract graph information -const getGraphInfo = (selectValue) => { - if (!selectValue) return null - const [category, value] = selectValue.split(".") - return { - value, - label: `${value} ${graphOptions[category][value]}`, - } -} - -// GraphPanel component -export default function GraphPanel({ selectValues, graphRefs, graphColors }) { - const renderGraph = (graphKey) => { - const graphInfo = getGraphInfo(selectValues[graphKey]) - - if (graphInfo) { - return ( - - ) - } - - return ( -

- Select a value to plot on graph{" "} - {graphKey.charAt(graphKey.length - 1).toUpperCase()} -

- ) - } - - return ( - - - - {renderGraph("graph_a")} - - {renderGraph("graph_b")} - - - - - - {renderGraph("graph_c")} - - {renderGraph("graph_d")} - - - - ) -} diff --git a/gcs/src/components/graphs/messageSelector.jsx b/gcs/src/components/graphs/messageSelector.jsx deleted file mode 100644 index a7d36c0cc..000000000 --- a/gcs/src/components/graphs/messageSelector.jsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - MessageSelector Component - - This component is a custom Select component that allows users to select a message from a list of options. - The options are grouped by message name and are searchable. The selected value is stored in the currentValues object. - - Props: - - graphOptions: An object containing the available options for each message. - - label: The label for the Select component. - - labelColor: The color for the label. - - valueKey: The key used to store the selected value in the currentValues object. - - currentValues: An object containing the currently selected values. - - setValue: A function used to set the selected value. -*/ - -// 3rd Party Imports -import { Select } from "@mantine/core" - -export default function MessageSelector({ - graphOptions, - label, - labelColor, - valueKey, - currentValues, - setValue, -}) { - return ( -