diff --git a/src/hub/dataSources/rev/REVDataSource.ts b/src/hub/dataSources/rev/REVDataSource.ts new file mode 100644 index 000000000..16971e15d --- /dev/null +++ b/src/hub/dataSources/rev/REVDataSource.ts @@ -0,0 +1,98 @@ +import { LiveDataSource, LiveDataSourceStatus } from "../LiveDataSource"; +import Log from "../../../shared/log/Log"; +import { REVTelemetryClient } from "./REVTelemetryClient"; + +/* + * The REV DataSource is a WebSocket hosted by an external server. + * The server will be running either on the user's desktop, or a + * SystemCore. + * + * The server listens for a message in the format: + * ``` + * { + * "key": "an arbitrary key" + * } + * ``` + * + * where the key identifies this AdvantageScope instance. + * The server then registers this key, which is used to manage + * the status frames and devices that are exposed to advantage scope. + * + * Once a key is registered, and any frames are enabled on the server, + * messages of the form: + * + * ``` + * { + * "name": "NameOfDevice", + * "descriptor": "technical descriptor used by server to identify the device", + * "timestamp": 1.23, + * "data": { + * "status0": { + * "isPoweredOn": true, + * "position": 123.456, + * }, + * "status1": { + * "aField": 7 + * } + * } + * } + * ``` + * + * are sent by the server asynchronously. This data source + * will receive these messages, and create fields named + * `${frame.name}.${frame.data.}.${field.name}`, + * such as `NameOfDevice.status0.isPoweredOn` in the previous frame example. + * + * Fields can have type of number or boolean. + * + * Currently, the data structure enforces that there must be two + * layers of indirection from the data object (such that data.foo.bar is valid, + * and data.bar or data.foo.baz.bar are both invalid). + * + * Any fields that are null will not be updated or removed, instead keeping the + * last value, and the timestamp will not be updated. + * + * No assumptions are made about the format of name, or data fields, meaning + * they can have any value. Specifically, data fields do not need to be named + * status, but it is a good convention. + */ + +export default class REVDataSource extends LiveDataSource { + client: REVTelemetryClient | undefined; + liveZeroTime: number | undefined; + + override connect( + address: string, + statusCallback: (status: LiveDataSourceStatus) => void, + outputCallback: (log: Log, timeSupplier: () => number) => void + ) { + super.connect(address, statusCallback, outputCallback, false); + this.log = new Log(); + this.client = new REVTelemetryClient(this.log, this.onMessage.bind(this)); + let port = window.preferences?.revTelemetryPort ?? 8080; + this.client.connect(address, port); + } + + onMessage() { + this.setStatus(LiveDataSourceStatus.Active); + + if (this.liveZeroTime === undefined) { + this.liveZeroTime = new Date().getTime() / 1000; + } + + if (this.log === null) { + return; + } + + // Run output callback + if (this.outputCallback !== null) { + this.outputCallback(this.log, () => { + if (this.log && this.liveZeroTime !== undefined) { + return new Date().getTime() / 1000 - this.liveZeroTime + this.log.getTimestampRange()[0]; + } else { + return 0; + } + }); + } + } +} diff --git a/src/hub/dataSources/rev/REVTelemetryClient.ts b/src/hub/dataSources/rev/REVTelemetryClient.ts new file mode 100644 index 000000000..13fd36340 --- /dev/null +++ b/src/hub/dataSources/rev/REVTelemetryClient.ts @@ -0,0 +1,64 @@ +import Log from "../../../shared/log/Log"; + +export class REVTelemetryClient { + log: Log; + onMessage: () => void; + + constructor(log: Log, onMessage: () => void) { + this.log = log; + this.onMessage = onMessage; + } + + connect(address: string, port: number) { + let socket = new WebSocket(`ws://${address}:${port}/v1/ws/status`); + + socket.addEventListener("open", event => { + let key = window.preferences?.revTelemetryKey ?? crypto.randomUUID(); + console.log(key); + + socket.send(JSON.stringify({ key: key })); + }); + + socket.addEventListener("message", event => { + let statusData = JSON.parse(event.data); + + if(statusData !== undefined && statusData !== null) { + this.handleStatusFrame(statusData); + } + }); + } + + handleStatusFrame(frameData: any) { + this.onMessage(); + let timestamp = frameData.timestamp; + let name = frameData.name; + + for(let statusFrameKey in frameData.data) { + let statusFrame = frameData.data[statusFrameKey]; + + if(statusFrame === undefined || statusFrame === null) { + continue; + } + + if(typeof statusFrame !== "object") { + continue; + } + + for(let fieldKey in statusFrame) { + let value = statusFrame[fieldKey]; + let fieldName = `${name}/${statusFrameKey}/${fieldKey}`; + + if(value !== undefined && value !== null) { + switch(typeof value) { + case "number": + this.log.putNumber(fieldName, timestamp, value); + break; + case "boolean": + this.log.putBoolean(fieldName, timestamp, value); + break; + } + } + } + } + } +} diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 549152e20..24841eb32 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -29,6 +29,7 @@ import FTCDashboardSource from "./dataSources/ftcdashboard/FTCDashboardSource"; import { NT4Publisher, NT4PublisherStatus } from "./dataSources/nt4/NT4Publisher"; import NT4Source from "./dataSources/nt4/NT4Source"; import RLOGServerSource from "./dataSources/rlog/RLOGServerSource"; +import REVDataSource from "./dataSources/rev/REVDataSource"; // Constants const STATE_SAVE_PERIOD_MS = 250; @@ -440,6 +441,8 @@ function startLive(isSim = false) { case "ftcdashboard": liveSource = new FTCDashboardSource(); break; + case "rev": + liveSource = new REVDataSource(); } let address = ""; diff --git a/src/main/electron/main.ts b/src/main/electron/main.ts index 2e8ae332e..71f410341 100644 --- a/src/main/electron/main.ts +++ b/src/main/electron/main.ts @@ -1898,7 +1898,7 @@ function setupMenu() { } }, { type: "separator" }, - ...(["nt4", "nt4-akit", "phoenix", "rlog", "ftcdashboard"] as const).map((liveMode: LiveMode) => { + ...(["nt4", "nt4-akit", "phoenix", "rlog", "ftcdashboard", "rev"] as const).map((liveMode: LiveMode) => { let item: Electron.MenuItemConstructorOptions = { label: getLiveModeName(liveMode), click(_, baseWindow) { @@ -1929,7 +1929,7 @@ function setupMenu() { } }, { type: "separator" }, - ...(["nt4", "nt4-akit", "phoenix", "rlog", "ftcdashboard"] as const).map((liveMode: LiveMode) => { + ...(["nt4", "nt4-akit", "phoenix", "rlog", "ftcdashboard", "rev"] as const).map((liveMode: LiveMode) => { let item: Electron.MenuItemConstructorOptions = { label: getLiveModeName(liveMode), click(_, baseWindow) { @@ -3050,8 +3050,8 @@ function openPreferences(parentWindow: Electron.BrowserWindow) { } const width = 400; - const optionRows = 12; - const titleRows = 2; + const optionRows = 14; + const titleRows = 3; const height = optionRows * 27 + titleRows * 34 + 54; prefsWindow = new BrowserWindow({ width: width, diff --git a/src/main/lite/main.ts b/src/main/lite/main.ts index 114b409f5..872932eae 100644 --- a/src/main/lite/main.ts +++ b/src/main/lite/main.ts @@ -167,8 +167,8 @@ function openSourceListHelp(config: SourceListConfig) { /** Opens a popup window for preferences. */ function openPreferences() { const width = 400; - const optionRows = 7; - const titleRows = 2; + const optionRows = 9; + const titleRows = 3; const height = optionRows * 27 + titleRows * 34 + 54; openPopupWindow("www/preferences.html", [width, height], "pixels", (message) => { closePopupWindow(); diff --git a/src/preferences.ts b/src/preferences.ts index c236fa6a4..ce27d60f0 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -22,6 +22,8 @@ const FIELD_3D_ANTIALIASING = document.getElementById("field3dAntialiasing") as const TBA_API_KEY = document.getElementById("tbaApiKey") as HTMLInputElement; const EXIT_BUTTON = document.getElementById("exit") as HTMLInputElement; const CONFIRM_BUTTON = document.getElementById("confirm") as HTMLInputElement; +const REV_PORT = document.getElementById("revPort") as HTMLInputElement; +const REV_KEY = document.getElementById("revKey") as HTMLInputElement; window.addEventListener("message", (event) => { if (event.data === "port") { @@ -67,6 +69,8 @@ window.addEventListener("message", (event) => { FIELD_3D_MODE_BATTERY.value = oldPrefs.field3dModeBattery; FIELD_3D_ANTIALIASING.value = oldPrefs.field3dAntialiasing.toString(); TBA_API_KEY.value = oldPrefs.tbaApiKey; + REV_PORT.value = oldPrefs.revTelemetryPort.toString(); + REV_KEY.value = oldPrefs.revTelemetryKey; // Close function function close(useNewPrefs: boolean) { @@ -98,6 +102,11 @@ window.addEventListener("message", (event) => { if (FIELD_3D_MODE_BATTERY.value === "standard") field3dModeBattery = "standard"; if (FIELD_3D_MODE_BATTERY.value === "low-power") field3dModeBattery = "low-power"; + let revPort = parseInt(REV_PORT.value); + if(isNaN(revPort)) { + revPort = oldPrefs.revTelemetryPort; + } + let newPrefs: Preferences = { theme: theme, robotAddress: ROBOT_ADDRESS.value, @@ -117,7 +126,9 @@ window.addEventListener("message", (event) => { skipFrcLogFolderDefault: oldPrefs.skipFrcLogFolderDefault, skipNumericArrayDeprecationWarning: oldPrefs.skipNumericArrayDeprecationWarning, skipFTCExperimentalWarning: oldPrefs.skipFTCExperimentalWarning, - ctreLicenseAccepted: oldPrefs.ctreLicenseAccepted + ctreLicenseAccepted: oldPrefs.ctreLicenseAccepted, + revTelemetryKey: REV_KEY.value, + revTelemetryPort: revPort, }; messagePort.postMessage(newPrefs); } else { diff --git a/src/shared/Preferences.ts b/src/shared/Preferences.ts index e5c2cf895..4d1b81f67 100644 --- a/src/shared/Preferences.ts +++ b/src/shared/Preferences.ts @@ -29,6 +29,8 @@ export default interface Preferences { skipFrcLogFolderDefault: boolean; ctreLicenseAccepted: boolean; usb?: boolean; + revTelemetryPort: number; + revTelemetryKey: string; } export const DEFAULT_PREFS: Preferences = { @@ -50,10 +52,12 @@ export const DEFAULT_PREFS: Preferences = { skipFrcLogFolderDefault: false, skipNumericArrayDeprecationWarning: false, skipFTCExperimentalWarning: false, - ctreLicenseAccepted: false + ctreLicenseAccepted: false, + revTelemetryPort: 8080, + revTelemetryKey: "REV", }; -export type LiveMode = "nt4" | "nt4-akit" | "phoenix" | "rlog" | "ftcdashboard"; +export type LiveMode = "nt4" | "nt4-akit" | "phoenix" | "rlog" | "ftcdashboard" | "rev"; export function getLiveModeName(mode: LiveMode): string { switch (mode) { @@ -67,12 +71,14 @@ export function getLiveModeName(mode: LiveMode): string { return "RLOG Server"; case "ftcdashboard": return "FTC Dashboard"; + case "rev": + return "Rev Dashboard"; } } // Phoenix not possible due to cross origin restrictions // RLOG not possible because it uses raw TCP -export const LITE_ALLOWED_LIVE_MODES: LiveMode[] = ["nt4", "nt4-akit", "ftcdashboard"]; +export const LITE_ALLOWED_LIVE_MODES: LiveMode[] = ["nt4", "nt4-akit", "ftcdashboard", "rev"]; export function mergePreferences(basePrefs: Preferences, newPrefs: object) { if ("theme" in newPrefs && (newPrefs.theme === "light" || newPrefs.theme === "dark" || newPrefs.theme === "system")) { @@ -102,7 +108,8 @@ export function mergePreferences(basePrefs: Preferences, newPrefs: object) { newPrefs.liveMode === "nt4-akit" || newPrefs.liveMode === "phoenix" || newPrefs.liveMode === "rlog" || - newPrefs.liveMode === "ftcdashboard") + newPrefs.liveMode === "ftcdashboard" || + newPrefs.liveMode === "rev") ) { basePrefs.liveMode = newPrefs.liveMode; } @@ -121,6 +128,12 @@ export function mergePreferences(basePrefs: Preferences, newPrefs: object) { if ("rlogPort" in newPrefs && typeof newPrefs.rlogPort === "number") { basePrefs.rlogPort = newPrefs.rlogPort; } + if ("revTelemetryPort" in newPrefs && typeof newPrefs.revTelemetryPort === "number") { + basePrefs.revTelemetryPort = newPrefs.revTelemetryPort; + } + if ("revTelemetryKey" in newPrefs && typeof newPrefs.revTelemetryKey === "string") { + basePrefs.revTelemetryKey = newPrefs.revTelemetryKey; + } if ( "coordinateSystem" in newPrefs && (newPrefs.coordinateSystem === "automatic" || diff --git a/www/preferences.html b/www/preferences.html index 86a883c76..805bbd09d 100644 --- a/www/preferences.html +++ b/www/preferences.html @@ -98,6 +98,24 @@ + + +
+ REV Options + + + + REV Port + + + + + + REV Key (used to identify connection) + + + +