Skip to content

Commit

Permalink
Add Phoenix diagnostics live source
Browse files Browse the repository at this point in the history
  • Loading branch information
jwbonner committed Dec 13, 2023
1 parent d18b604 commit 813890e
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 17 deletions.
1 change: 1 addition & 0 deletions docs/OPEN-LIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The following sources of live data are supported by AdvantageScope:

- **NetworkTables 4:** This is the default networking protocol starting in WPILib 2023, and is commonly used by dashboards, coprocessors, etc. See the [WPILib documentation](https://docs.wpilib.org/en/stable/docs/software/networktables/index.html) for more details. Note that NetworkTables 3 (used by WPILib 2022 and older) is not supported by AdvantageScope.
- **NetworkTables 4 (AdvantageKit):** This mode is designed for use with robot code running AdvantageKit, which publishes to the "/AdvantageKit" table in NetworkTables. The only difference from the **NetworkTables 4** mode is that the "/AdvantageKit" table is used as the root, which allows for easier switching between an NT4 connection and an AdvantageKit log file.
- **Phoenix Diagnostics:** This mode uses HTTP to connect to a Phoenix [diagnostic server](https://pro.docs.ctr-electronics.com/en/latest/docs/installation/running-diagnostics.html), which allows for data streaming from CTRE CAN devices. This is similar to the [plotting feature](https://pro.docs.ctr-electronics.com/en/latest/docs/tuner/plotting.html) in Phoenix Tuner, but includes support for previewing values in the sidebar and storing the full history of signals (like any other AdvantageScope live source). Note that the diagnostic server only supports plotting signals from **one device at a time**. AdvantageScope will switch between devices automatically based on the signals being viewed.
- **PathPlanner 2023:** This mode connects using the `PathPlannerServer` protocol used for telemetry by PathPlanner 2023. The connection is always initiated on port 5811. Note that PathPlanner 2024 and later publish telemetry data using NetworkTables, so the **NetworkTables 4** mode should be used.
- **RLOG Server:** This protocol is used by AdvantageKit v1 (2022), and is included for compatibility with older code bases. The connection is initiated on port 5810 by default.

Expand Down
4 changes: 0 additions & 4 deletions src/hub/Tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,6 @@ export default class Tabs {
activeFields.add(field);
});
});
let enabledKey = getEnabledKey(window.log);
if (enabledKey !== undefined) {
activeFields.add(enabledKey);
}
return activeFields;
}

Expand Down
4 changes: 3 additions & 1 deletion src/hub/dataSources/NT4Source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Log from "../../shared/log/Log";
import { PROTO_PREFIX, STRUCT_PREFIX } from "../../shared/log/LogUtil";
import { PROTO_PREFIX, STRUCT_PREFIX, getEnabledKey } from "../../shared/log/LogUtil";
import LoggableType from "../../shared/log/LoggableType";
import { checkArrayType } from "../../shared/util";
import { LiveDataSource, LiveDataSourceStatus } from "./LiveDataSource";
Expand Down Expand Up @@ -66,10 +66,12 @@ export default class NT4Source extends LiveDataSource {
let activeFields: Set<string> = new Set();
if (window.log === this.log) {
let announcedKeys = this.log.getFieldKeys().filter((key) => this.log?.getType(key) !== LoggableType.Empty);
let enabledKey = getEnabledKey(this.log);
[
...(this.akitMode
? ["/.schema"]
: [this.WPILOG_PREFIX + "/.schema", this.WPILOG_PREFIX + this.AKIT_PREFIX + "/.schema"]),
...(enabledKey === undefined ? [] : [enabledKey]),
...window.tabs.getActiveFields(),
...window.sidebar.getActiveFields()
].forEach((key) => {
Expand Down
2 changes: 0 additions & 2 deletions src/hub/dataSources/PathPlannerSource.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import Log from "../../shared/log/Log";
import LoggableType from "../../shared/log/LoggableType";
import { LiveDataSource, LiveDataSourceStatus } from "./LiveDataSource";
import RLOGDecoder from "./RLOGDecoder";

export default class PathPlannerSource extends LiveDataSource {
private RECONNECT_DELAY_MS = 500;
Expand Down
302 changes: 302 additions & 0 deletions src/hub/dataSources/PhoenixDiagnosticsSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
import Log from "../../shared/log/Log";
import LoggableType from "../../shared/log/LoggableType";
import { LiveDataSource, LiveDataSourceStatus } from "./LiveDataSource";

export default class PhoenixDiagnosticsSource extends LiveDataSource {
private PORT = 1250;
private GET_DEVICES_PERIOD = 500;
private GET_DEVICES_TIMEOUT = 400;
private GET_SIGNALS_TIMEOUT = 200;
private PLOT_PERIOD = 50;
private PLOT_TIMEOUT = 40;
private PLOT_RESOLUTION = 50;
private LOG_PREFIX = "Phoenix6";

private getDevicesInterval: NodeJS.Timeout | null = null;
private plotInterval: NodeJS.Timeout | null = null;

private deviceDescriptions: { [key: string]: Response_Device } = {};
private deviceSignals: { [key: string]: Response_Signal[] } = {};
private liveStartRealTime: number | null = null;
private liveStartLogTime: number | null = null;

connect(
address: string,
statusCallback: (status: LiveDataSourceStatus) => void,
outputCallback: (log: Log, timeSupplier: () => number) => void
) {
super.connect(address, statusCallback, outputCallback);

// Get devices periodically
let getDevicesPeriodic = () => {
this.getDevices()
.then((devices) => {
let initialConnection = this.status !== LiveDataSourceStatus.Active;
if (initialConnection) {
this.log = new Log();
this.deviceDescriptions = {};
this.deviceSignals = {};
this.liveStartRealTime = new Date().getTime() / 1000;
this.liveStartLogTime = null;
}
this.setStatus(LiveDataSourceStatus.Active);
devices.forEach((device) => {
let deviceName = this.getDeviceName(device);
if (!(deviceName in this.deviceDescriptions)) {
this.deviceDescriptions[deviceName] = device;
}
if (!(deviceName in this.deviceSignals)) {
this.deviceSignals[deviceName] = [];
}
if (this.deviceSignals[deviceName].length === 0) {
this.getSignals(device).then((signals) => {
this.deviceSignals[deviceName] = signals;

// Add fields for all signals
signals.forEach((signal) => {
this.log?.createBlankField(
this.LOG_PREFIX + "/" + deviceName + "/" + signal.Name,
LoggableType.Number
);
});
this.newOutput();
});
}
});
if (initialConnection) this.newOutput();
})
.catch(() => {
this.setStatus(LiveDataSourceStatus.Connecting);
});
};
getDevicesPeriodic();
this.getDevicesInterval = setInterval(getDevicesPeriodic, this.GET_DEVICES_PERIOD);

// Get new plot data periodically
this.plotInterval = setInterval(() => {
// Get active signal IDs
let findActiveSignals = (activeFields: string[]) => {
let activeSignals: { [key: string]: Response_Signal[] } = {};
activeFields.forEach((activeField) => {
if (!activeField.startsWith(this.LOG_PREFIX)) return;
let splitKey = activeField.split("/");
let deviceName: string, signalName: string;
if (splitKey.length === 3) {
deviceName = splitKey[1];
signalName = splitKey[2];
} else if (splitKey.length === 4) {
deviceName = splitKey[1] + "/" + splitKey[2];
signalName = splitKey[3];
} else {
return;
}

if (!(deviceName in activeSignals)) {
activeSignals[deviceName] = [];
}
if (!(deviceName in this.deviceSignals)) return;
let signal = this.deviceSignals[deviceName].find((signal) => signal.Name === signalName);
if (signal === undefined) return;
if (activeSignals[deviceName].find((prevSignal) => prevSignal.Id === signal!.Id) !== undefined) return;
activeSignals[deviceName].push(signal);
});
return activeSignals;
};
let tabsActiveSignals = findActiveSignals([...window.tabs.getActiveFields()]);
let sidebarActiveSignals = findActiveSignals([...window.sidebar.getActiveFields()]);

// Choose device to request
if (Object.keys(sidebarActiveSignals).length + Object.keys(tabsActiveSignals).length === 0) return;
let deviceName = "";
let signalCount = 0;
Object.entries(tabsActiveSignals).forEach(([activeDeviceName, activeDeviceSignals]) => {
if (activeDeviceSignals.length > signalCount) {
deviceName = activeDeviceName;
signalCount = activeDeviceSignals.length;
}
});
if (signalCount === 0) {
// No active signals for tabs, use sidebar instead
Object.entries(sidebarActiveSignals).forEach(([activeDeviceName, activeDeviceSignals]) => {
if (activeDeviceSignals.length > signalCount) {
deviceName = activeDeviceName;
signalCount = activeDeviceSignals.length;
}
});
}

// Merge sidebar and tab signals
let signals: Response_Signal[] = [];
if (deviceName in sidebarActiveSignals) {
signals = signals.concat(signals, sidebarActiveSignals[deviceName]);
}
if (deviceName in tabsActiveSignals) {
signals = signals.concat(signals, tabsActiveSignals[deviceName]);
}

// Request data
if (!(deviceName in this.deviceDescriptions)) return;
let device = this.deviceDescriptions[deviceName];
this.getPlotData(
device,
signals.map((signal) => signal.Id)
).then((points) => {
// Reset live time based on last timestamp
if (this.liveStartLogTime === null && this.liveStartRealTime !== null && points.length > 0) {
this.liveStartLogTime =
points[points.length - 1].Timestamp - (new Date().getTime() / 1000 - this.liveStartRealTime);
}

// Add all points
points.forEach((point) => {
Object.entries(point.Signals).forEach(([signalIdStr, value]) => {
let signalId = Number(signalIdStr);
let signal = signals.find((signal) => signal.Id === signalId);
if (signal === undefined) return;
this.log?.putNumber(
this.LOG_PREFIX + "/" + deviceName + "/" + signal.Name,
point.Timestamp - this.liveStartLogTime!,
value
);
});
});
this.newOutput();
});
}, this.PLOT_PERIOD);
}

stop() {
if (this.getDevicesInterval) clearInterval(this.getDevicesInterval);
if (this.plotInterval) clearInterval(this.plotInterval);
super.stop();
}

/** Runs the output callback with the current log and an appropriate timestamp supplier. */
private newOutput() {
if (this.outputCallback !== null && this.log !== null) {
this.log.clearBeforeTime(0.0);
this.outputCallback(this.log, () => {
if (this.liveStartRealTime !== null) {
return new Date().getTime() / 1000 - this.liveStartRealTime;
} else {
return 0;
}
});
}
}

/** Converts a device object to its simple name. */
private getDeviceName(device: Response_Device): string {
let name = device.Model.replaceAll(" ", "");
if (device.Model.startsWith("CANCoder")) {
name = "CANcoder";
}
name = name + "-" + device.ID.toString();
if (device.CANbus.length > 0) {
name = device.CANbus + "/" + name;
}
return name;
}

/** Returns the set of attached devices. */
private async getDevices(): Promise<Response_Device[]> {
let response = await fetch("http://" + this.address + ":" + this.PORT.toString() + "/?action=getdevices", {
signal: AbortSignal.timeout(this.GET_DEVICES_TIMEOUT)
});
let json = (await response.json()) as Response_GetDevices;
if (json.GeneralReturn.Error !== 0) throw "Non-zero error code";
return json.DeviceArray;
}

/** Returns the set of available signals for a device. */
private async getSignals(device: Response_Device): Promise<Response_Signal[]> {
let response = await fetch(
"http://" +
this.address +
":" +
this.PORT.toString() +
"/?action=getsignals&model=" +
encodeURIComponent(device.Model) +
"&id=" +
device.ID.toString() +
"&canbus=" +
encodeURIComponent(device.CANbus),
{
signal: AbortSignal.timeout(this.GET_SIGNALS_TIMEOUT)
}
);
let json = (await response.json()) as Response_GetSignals;
if (json.GeneralReturn.Error !== 0) throw "Non-zero error code";
return json.Signals;
}

/** Returns a section of plot data for a set of signals. */
private async getPlotData(device: Response_Device, signals: number[]): Promise<Response_Point[]> {
let response = await fetch(
"http://" +
this.address +
":" +
this.PORT.toString() +
"/?action=plotpro&model=" +
encodeURIComponent(device.Model) +
"&id=" +
device.ID.toString() +
"&canbus=" +
encodeURIComponent(device.CANbus) +
"&signals=" +
signals.map((value) => value.toString()).join(",") +
"&resolution=" +
this.PLOT_RESOLUTION.toString(),
{
signal: AbortSignal.timeout(this.PLOT_TIMEOUT)
}
);
let json = (await response.json()) as Response_PlotPro;
if (json.GeneralReturn.Error !== 0) throw "Non-zero error code";
return json.Points;
}
}

interface Response {
GeneralReturn: Response_GeneralReturn;
}

interface Response_GeneralReturn {
Error: number;
ErrorMessage: string;
// ... incomplete
}

interface Response_GetDevices extends Response {
BusUtilPerc: number;
DeviceArray: Response_Device[];
}

interface Response_Device {
CANbus: string;
ID: number;
Model: string;
// ... incomplete
}

interface Response_GetSignals extends Response {
Signals: Response_Signal[];
}

interface Response_Signal {
Id: number;
Name: string;
Summary: string;
Units: string;
}

interface Response_PlotPro extends Response {
Count: number;
Points: Response_Point[];
}

interface Response_Point {
Ordinal: number;
Timestamp: number;
Signals: { [key: string]: number };
}
8 changes: 7 additions & 1 deletion src/hub/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import loadZebra from "./dataSources/LoadZebra";
import { NT4Publisher, NT4PublisherStatus } from "./dataSources/NT4Publisher";
import NT4Source from "./dataSources/NT4Source";
import PathPlannerSource from "./dataSources/PathPlannerSource";
import PhoenixDiagnosticsSource from "./dataSources/PhoenixDiagnosticsSource";
import RLOGServerSource from "./dataSources/RLOGServerSource";
import Selection from "./Selection";
import Sidebar from "./Sidebar";
Expand Down Expand Up @@ -275,6 +276,9 @@ function startLive(isSim: boolean) {
case "nt4-akit":
liveSource = new NT4Source(true);
break;
case "phoenix":
liveSource = new PhoenixDiagnosticsSource();
break;
case "pathplanner":
liveSource = new PathPlannerSource();
break;
Expand Down Expand Up @@ -586,7 +590,9 @@ function handleMainMessage(message: NamedMessage) {
isExporting = true;
window.sendMainMessage("prompt-export", {
path: logPath,
incompleteWarning: liveConnected && window.preferences?.liveSubscribeMode === "low-bandwidth"
incompleteWarning:
liveConnected &&
(window.preferences?.liveSubscribeMode === "low-bandwidth" || window.preferences?.liveMode === "phoenix")
});
}
break;
Expand Down
3 changes: 2 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) {
title: "Warning",
message: "Incomplete data for export",
detail:
'Some fields will not be available in the exported data. To save all fields from the server, the "Logging" live mode must be selected. Check the AdvantageScope documentation for details.',
'Some fields will not be available in the exported data. To save all fields from the server, the "Logging" live mode must be selected with NetworkTables, PathPlanner, or RLOG as the live source. Check the AdvantageScope documentation for details.',
buttons: ["Continue", "Cancel"],
icon: WINDOW_ICON
})
Expand Down Expand Up @@ -2148,6 +2148,7 @@ app.whenReady().then(() => {
"liveMode" in oldPrefs &&
(oldPrefs.liveMode === "nt4" ||
oldPrefs.liveMode === "nt4-akit" ||
oldPrefs.liveMode === "phoenix" ||
oldPrefs.liveMode === "pathplanner" ||
oldPrefs.liveMode === "rlog")
) {
Expand Down
Loading

0 comments on commit 813890e

Please sign in to comment.