Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/main/java/frc/lib/hardware/CANBusHealth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Triple Helix Robotics, FRC Team 2363
// https://github.com/TripleHelixProgramming
//
// Use of this source code is governed by a BSD
// license that can be found in the LICENSE file
// at the root directory of this project.

package frc.lib.hardware;

/**
* Decides whether a CAN bus needs an alert, from one cycle of logged bus status at a time.
*
* <p>The states follow the CAN standard's fault confinement: a controller enters ErrorWarning when
* an error counter reaches 96, ErrorPassive at 128, and BusOff when the transmit counter passes
* 255. A few error frames on a healthy bus raise the counters without reaching those states, so the
* raw counters are logged but do not trigger alerts.
*
* <p>This class uses only strings, numbers and the caller's timestamps, so it gives the same result
* in log replay and can be unit tested without Phoenix or the HAL.
*/
public class CANBusHealth {
/** How urgent a bus problem is. */
public enum Severity {
NONE,
MEDIUM,
HIGH
}

/** How long an alert stays active after the condition was last seen, in seconds. */
public static final double HOLD_SECONDS = 0.5;

/** How long the sample count may stay unchanged before the reader counts as stalled. */
public static final double STALE_SECONDS = 1.5;

private boolean hasPrevious = false;
private long previousSampleCount;
private long previousBusOffCount;
private long previousRestartCount;
private double lastSampleChangeTime;
private double lastHighTime = Double.NEGATIVE_INFINITY;
private double lastMediumTime = Double.NEGATIVE_INFINITY;

/**
* Returns the severity of one bus status sample.
*
* @param status the Phoenix StatusCode name of the status read ("OK" when the read worked)
* @param state the Phoenix CANState name
* @param busOffRose whether the bus-off count rose since the previous sample
* @param restartRose whether the controller restart count rose since the previous sample
* @param stale whether the reader has stopped producing samples
*/
public static Severity severity(
String status, String state, boolean busOffRose, boolean restartRose, boolean stale) {
if (!"OK".equals(status)
|| "ErrorPassive".equals(state)
|| "BusOff".equals(state)
|| "Stopped".equals(state)
|| busOffRose
|| restartRose
|| stale) {
return Severity.HIGH;
}
if ("ErrorWarning".equals(state)) return Severity.MEDIUM;
return Severity.NONE;
}

/**
* Takes one cycle of logged bus status.
*
* <p>A sample count of 0 means no status has been read, as in simulation or when replaying a log
* recorded without a reader. Such cycles never count as stale. Counter increases are measured
* from the first real sample, so counts left from before a code restart do not trigger an alert.
*
* @param now the current timestamp in seconds
* @param sampleCount how many status reads the reader has completed
* @param status the Phoenix StatusCode name of the status read
* @param state the Phoenix CANState name
* @param busOffCount the cumulative bus-off count
* @param restartCount the cumulative controller restart count
*/
public void update(
double now,
long sampleCount,
String status,
String state,
long busOffCount,
long restartCount) {
boolean busOffRose = false;
boolean restartRose = false;
boolean stale = false;
if (sampleCount > 0) {
if (!hasPrevious) {
hasPrevious = true;
previousSampleCount = sampleCount;
lastSampleChangeTime = now;
} else {
busOffRose = busOffCount > previousBusOffCount;
restartRose = restartCount > previousRestartCount;
if (sampleCount != previousSampleCount) {
previousSampleCount = sampleCount;
lastSampleChangeTime = now;
}
stale = now - lastSampleChangeTime > STALE_SECONDS;
}
previousBusOffCount = busOffCount;
previousRestartCount = restartCount;
}

switch (severity(status, state, busOffRose, restartRose, stale)) {
case HIGH -> lastHighTime = now;
case MEDIUM -> lastMediumTime = now;
case NONE -> {}
}
}

/** Returns true while a high-severity problem was seen within the last {@link #HOLD_SECONDS}. */
public boolean isHighActive(double now) {
return now - lastHighTime < HOLD_SECONDS;
}

/** Returns true while a warning was seen within the last {@link #HOLD_SECONDS}. */
public boolean isMediumActive(double now) {
return now - lastMediumTime < HOLD_SECONDS;
}
}
114 changes: 108 additions & 6 deletions src/main/java/frc/lib/hardware/LoggedCANBus.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@
package frc.lib.hardware;

import com.ctre.phoenix6.CANBus;
import com.ctre.phoenix6.CANBus.CANBusStatus;
import org.littletonrobotics.junction.AutoLog;
import org.littletonrobotics.junction.Logger;
import org.wpilib.driverstation.DriverStationErrors;
import org.wpilib.system.Timer;
import org.wpilib.util.Alert;

/**
* Logs a CAN bus's controller status from Phoenix and raises alerts when the bus is in trouble.
*
* <p>{@link CANBus#getStatus()} can block for up to 1 ms (its Javadoc), so a background thread
* reads it and the main loop only copies the latest sample. Without {@link #start()}, as in
* simulation, the inputs keep their defaults. Alerts come only from the logged inputs, so replay
* reproduces them.
*/
public class LoggedCANBus {
@AutoLog
public static class CANBusStatusInputs {
Expand All @@ -19,11 +31,29 @@ public static class CANBusStatusInputs {
public long txFullCount = 0;
public long receiveErrorCount = 0;
public long transmitErrorCount = 0;
public long busErrorCount = 0;
public long arbitrationLostCount = 0;
public long restartCount = 0;
public String state = "ErrorActive";
public String status = "OK";
public long sampleCount = 0;
}

/** How often the background thread reads the bus status, in milliseconds. */
private static final long SAMPLE_PERIOD_MS = 400;

/** One status read, paired with its sequence number so the two are published together. */
private record Sample(CANBusStatus status, long sequence) {}

private final CANBus bus;
private final String name;
private final String key;
private final CANBusStatusInputsAutoLogged inputs = new CANBusStatusInputsAutoLogged();
private final CANBusHealth health = new CANBusHealth();
private final Alert errorAlert;
private final Alert warningAlert;
private volatile Sample latest = null;
private Thread reader = null;

/**
* Creates a logged CAN bus status reporter.
Expand All @@ -33,16 +63,88 @@ public static class CANBusStatusInputs {
*/
public LoggedCANBus(String name, CANBus bus) {
this.bus = bus;
this.name = name;
this.key = "CANBus/" + name;
errorAlert = new Alert("CANBus/" + name + "/errors", "", Alert.Level.HIGH);
warningAlert = new Alert("CANBus/" + name + "/warning", "", Alert.Level.MEDIUM);
}

/** Reads the status once, then starts the background thread that keeps reading it. */
public synchronized void start() {
if (reader != null) return;
latest = new Sample(bus.getStatus(), 1);
reader = new Thread(this::readLoop, "CANBusReader-" + name);
reader.setDaemon(true);
reader.start();
}

private void readLoop() {
long sequence = latest.sequence();
boolean warned = false;
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(SAMPLE_PERIOD_MS);
} catch (InterruptedException e) {
return;
}
try {
sequence++;
latest = new Sample(bus.getStatus(), sequence);
} catch (RuntimeException e) {
// Keep reading. A stalled sample count raises the stale alert.
if (!warned) {
DriverStationErrors.reportWarning(
"CAN status read failed on " + name + ": " + e.getMessage(), false);
warned = true;
}
}
}
}

public void log() {
var status = bus.getStatus();
inputs.busUtilization = status.BusUtilization;
inputs.busOffCount = status.BusOffCount;
inputs.txFullCount = status.TxFullCount;
inputs.receiveErrorCount = status.REC;
inputs.transmitErrorCount = status.TEC;
Sample sample = latest;
if (sample != null) {
CANBusStatus status = sample.status();
inputs.busUtilization = status.BusUtilization;
inputs.busOffCount = status.BusOffCount;
inputs.txFullCount = status.TxFullCount;
inputs.receiveErrorCount = status.REC;
inputs.transmitErrorCount = status.TEC;
inputs.busErrorCount = status.BusErrorCount;
inputs.arbitrationLostCount = status.ArbitrationLostCount;
inputs.restartCount = status.RestartCount;
inputs.state = String.valueOf(status.State);
inputs.status = status.Status.getName();
inputs.sampleCount = sample.sequence();
}
Logger.processInputs(key, inputs);

double now = Timer.getTimestamp();
health.update(
now,
inputs.sampleCount,
inputs.status,
inputs.state,
inputs.busOffCount,
inputs.restartCount);
setAlert(
errorAlert,
health.isHighActive(now),
"CAN errors on "
+ name
+ " ("
+ inputs.state
+ ", "
+ inputs.status
+ "); robot may not be controllable.");
setAlert(
warningAlert,
health.isMediumActive(now),
"CAN error warning on " + name + " (" + inputs.state + ").");
}

private static void setAlert(Alert alert, boolean active, String text) {
if (active && !text.equals(alert.getText())) alert.setText(text);
alert.set(active);
}
}
50 changes: 50 additions & 0 deletions src/main/java/frc/lib/hardware/PhoenixFirmware.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2026 Triple Helix Robotics, FRC Team 2363
// https://github.com/TripleHelixProgramming
//
// Use of this source code is governed by a BSD
// license that can be found in the LICENSE file
// at the root directory of this project.

package frc.lib.hardware;

/**
* Pure helpers for Phoenix 6 firmware status. They take plain strings and ints, not Phoenix types,
* so they can be unit tested without loading Phoenix.
*/
public final class PhoenixFirmware {
private PhoenixFirmware() {}

/**
* Returns true when a {@code setControl} status means Phoenix is blocking the motor's output.
*
* <p>Phoenix compares the device's firmware compliancy with the API's. On a mismatch it sends a
* neutral output instead of the request and returns {@code FirmwareTooOld} or {@code ApiTooOld}
* from {@code setControl} ({@code ParentDevice.setControlPrivate}, Phoenix 26.70.0-alpha-2).
*
* @param controlStatus the {@code StatusCode} name returned by {@code setControl}
*/
public static boolean isBlocked(String controlStatus) {
return "FirmwareTooOld".equals(controlStatus) || "ApiTooOld".equals(controlStatus);
}

/**
* Formats a device's four-byte {@code getVersion()} value as "major.minor.bugfix.build".
*
* <p>The byte order, most significant byte first, is an assumption: Phoenix documents only a
* "four byte value". Compare the result with {@code getVersionMajor()} and the other per-byte
* signals before relying on it.
*
* @param version the raw version value
* @return the formatted version, or "" for 0, which means the version is unknown
*/
public static String format(int version) {
if (version == 0) return "";
return ((version >>> 24) & 0xFF)
+ "."
+ ((version >>> 16) & 0xFF)
+ "."
+ ((version >>> 8) & 0xFF)
+ "."
+ (version & 0xFF);
}
}
3 changes: 3 additions & 0 deletions src/main/java/frc/robot/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public static final class FeatureFlags {

/** Enable to add the module forces from Choreo trajectories as drive feedforward. */
public static final boolean TRAJECTORY_FORCE_FF = false;

/** Enable to let Phoenix write .hoot signal logs. Off by default. */
public static final boolean HOOT_LOGGING_ENABLED = false;
}

public final class RobotConstants {
Expand Down
Loading
Loading