Add camera mismatch banner to dashboard (#1921)

## Description

Detects if a camera mismatch is present in any camera and displays a
banner in the dashboard for better visibility to the user. All detection
occurs in the backend, and is sent to the frontend via use of a mismatch
boolean included in each vision module.

<img width="1235" alt="image"
src="https://github.com/user-attachments/assets/19219a56-c366-4c56-8c4b-cb5a36fe4a04"
/>

Closes #1920

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_
- [ ] If this PR changes behavior or adds a feature, user documentation
is updated
- [ ] If this PR touches photon-serde, all messages have been
regenerated and hashes have not changed unexpectedly
- [x] If this PR touches configuration, this is backwards compatible
with settings back to v2024.3.1
- [x] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: Sam Freund <techguy763@gmail.com>
Co-authored-by: samfreund <samf.236@proton.me>
Co-authored-by: Matt Morley <matthew.morley.ca@gmail.com>
This commit is contained in:
Alan
2025-10-21 17:53:22 -07:00
committed by GitHub
parent d44480ddad
commit 054ed8b6a1
11 changed files with 299 additions and 78 deletions

View File

@@ -70,6 +70,8 @@ public class NetworkTablesManager {
// Creating the alert up here since it should be persistent
private final Alert conflictAlert = new Alert("PhotonAlerts", "", AlertType.kWarning);
private final Alert mismatchAlert = new Alert("PhotonAlerts", "", AlertType.kWarning);
public boolean conflictingHostname = false;
public String conflictingCameras = "";
private String currentMacAddress;
@@ -95,6 +97,7 @@ public class NetworkTablesManager {
// This should start as false, since we don't know if there's a conflict yet
conflictAlert.set(false);
mismatchAlert.set(false);
// Get the UI state in sync with the backend. NT should fire a callback when it
// first connects to the robot
@@ -115,6 +118,14 @@ public class NetworkTablesManager {
return INSTANCE;
}
public void setMismatchAlert(boolean on, String message) {
if (mismatchAlert != null) {
mismatchAlert.set(on);
mismatchAlert.setText(message);
SmartDashboard.updateValues();
}
}
private void logNtMessage(NetworkTableEvent event) {
String levelmsg = "DEBUG";
LogLevel pvlevel = LogLevel.DEBUG;

View File

@@ -52,6 +52,7 @@ public class UICameraConfiguration {
public double minWhiteBalanceTemp;
public double maxWhiteBalanceTemp;
public PVCameraInfo matchedCameraInfo;
public boolean mismatch;
// Status for if the underlying device is present and such
public boolean isConnected;

View File

@@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import edu.wpi.first.cscore.UsbCameraInfo;
import java.util.Arrays;
import java.util.Objects;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -70,8 +71,15 @@ public sealed interface PVCameraInfo {
CameraType type();
/**
* Default equals implementation that delegates to the implementing class's equals method. This
* method checks type compatibility first, then delegates to the actual implementation.
*/
default boolean equals(PVCameraInfo other) {
return uniquePath().equals(other.uniquePath());
if (other == null) return false;
if (this.type() != other.type()) return false;
// Delegate to the actual equals(Object) implementation of this instance
return this.equals((Object) other);
}
@JsonTypeName("PVUsbCameraInfo")
@@ -125,7 +133,17 @@ public sealed interface PVCameraInfo {
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
return obj instanceof PVCameraInfo info && equals(info);
if (!(obj instanceof PVUsbCameraInfo info)) return false;
return super.name.equals(info.name)
&& super.vendorId == info.vendorId
&& super.productId == info.productId
&& uniquePath().equals(info.uniquePath());
}
@Override
public int hashCode() {
return Objects.hash(super.name, super.vendorId, super.productId, uniquePath());
}
@Override
@@ -191,7 +209,14 @@ public sealed interface PVCameraInfo {
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
return obj instanceof PVCameraInfo info && equals(info);
if (!(obj instanceof PVCSICameraInfo info)) return false;
return baseName.equals(info.baseName) && path.equals(info.path);
}
@Override
public int hashCode() {
return Objects.hash(baseName, path);
}
@Override
@@ -248,7 +273,14 @@ public sealed interface PVCameraInfo {
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
return obj instanceof PVFileCameraInfo info && equals(info);
if (!(obj instanceof PVFileCameraInfo info)) return false;
return name.equals(info.name) && path.equals(info.path);
}
@Override
public int hashCode() {
return Objects.hash(name, path);
}
@Override

View File

@@ -93,6 +93,8 @@ public class VisionModule {
MJPGFrameConsumer inputVideoStreamer;
MJPGFrameConsumer outputVideoStreamer;
boolean mismatch;
public VisionModule(PipelineManager pipelineManager, VisionSource visionSource) {
logger =
new Logger(
@@ -100,6 +102,8 @@ public class VisionModule {
visionSource.getSettables().getConfiguration().nickname,
LogGroup.VisionModule);
mismatch = false;
cameraQuirks = visionSource.getCameraConfiguration().cameraQuirks;
if (visionSource.getCameraConfiguration().cameraQuirks == null)
@@ -568,6 +572,8 @@ public class VisionModule {
ret.deactivated = config.deactivated;
ret.mismatch = this.mismatch;
// TODO refactor into helper method
var temp = new HashMap<Integer, HashMap<String, Object>>();
var videoModes = visionSource.getSettables().getAllVideoModes();

View File

@@ -31,6 +31,7 @@ import org.photonvision.common.configuration.CameraConfiguration;
import org.photonvision.common.configuration.ConfigManager;
import org.photonvision.common.dataflow.DataChangeService;
import org.photonvision.common.dataflow.events.OutgoingUIEvent;
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
import org.photonvision.common.dataflow.websocket.UICameraConfiguration;
import org.photonvision.common.dataflow.websocket.UIPhotonConfiguration;
import org.photonvision.common.hardware.Platform;
@@ -311,16 +312,118 @@ public class VisionSourceManager {
.forEach(cameraInfos::add);
}
// FileVisionSources are a bit quirky. They aren't enumerated by the above, but i still want my
// FileVisionSources are a bit quirky. They aren't enumerated by the above, but I still want my
// UI to look like it ought to work
vmm.getModules().stream()
.map(it -> it.getCameraConfiguration().matchedCameraInfo)
.filter(info -> info instanceof PVCameraInfo.PVFileCameraInfo)
.forEach(cameraInfos::add);
checkMismatches(cameraInfos);
return cameraInfos;
}
/**
* Check for mismatches between connected cameras and saved camera configurations.
*
* <p>Note that if the information for a camera spontaneously changes without it being
* disconnected/unplugged and reconnected/replugged, we may experience unexpected behavior.
*
* @param cameraInfos List of currently connected camera infos, checked against saved configs
*/
protected void checkMismatches(List<PVCameraInfo> cameraInfos) {
// from the listed physical camera infos, match them to the camera configs and check for
// mismatches
for (VisionModule module : vmm.getModules()) {
PVCameraInfo matchedCameraInfo = module.getCameraConfiguration().matchedCameraInfo;
// We use unique paths to determine if the module has a camera in the port. If no unique path
// is found that matches the module, it's removed from the mismatched set as a disconnected
// camera cannot be mismatched.
if (!cameraInfos.stream()
.map(PVCameraInfo::uniquePath)
.toList()
.contains(matchedCameraInfo.uniquePath())) {
module.mismatch = false;
continue;
}
for (PVCameraInfo info : cameraInfos) {
// if the unique path doesn't match, skip cause it's not in the same port
if (!matchedCameraInfo.uniquePath().equals(info.uniquePath())) {
continue;
}
// If the camera info doesn't match, log an error
if (!matchedCameraInfo.equals(info) && !module.mismatch) {
logger.error("Camera mismatch error!");
logger.error("Camera config mismatch for " + matchedCameraInfo.name());
logCameraInfoDiff(matchedCameraInfo, info);
module.mismatch = true;
}
}
}
// Set the NetworkTables mismatch alert
if (vmm.getModules().stream().anyMatch(m -> m.mismatch)) {
NetworkTablesManager.getInstance()
.setMismatchAlert(
true,
"Camera mismatch error! See logs for details. ("
+ vmm.getModules().stream()
.filter(m -> m.mismatch)
.map(m -> m.getCameraConfiguration().nickname)
.toList()
.toString()
.replaceAll("[\\[\\]()]", "")
+ " affected)");
} else {
NetworkTablesManager.getInstance().setMismatchAlert(false, "");
}
}
/** Log the differences between two PVCameraInfo objects. */
private static void logCameraInfoDiff(PVCameraInfo saved, PVCameraInfo current) {
String expected = "Expected: Name: " + saved.name();
String actual = "Actual: Name: " + current.name();
if (saved instanceof PVCameraInfo.PVCSICameraInfo savedCsi
&& current instanceof PVCameraInfo.PVCSICameraInfo currentCsi) {
expected += " Base Name: " + savedCsi.baseName;
actual += " Base Name: " + currentCsi.baseName;
}
expected += " Type: " + saved.type().toString();
actual += " Type: " + current.type().toString();
if (saved instanceof PVCameraInfo.PVUsbCameraInfo savedUsb
&& current instanceof PVCameraInfo.PVUsbCameraInfo currentUsb) {
expected +=
" Device Number: "
+ savedUsb.dev
+ " Vendor ID: "
+ savedUsb.vendorId
+ " Product ID: "
+ savedUsb.productId;
actual +=
" Device Number: "
+ currentUsb.dev
+ " Vendor ID: "
+ currentUsb.vendorId
+ " Product ID: "
+ currentUsb.productId;
}
expected += " Path: " + saved.path();
actual += " Path: " + current.path();
expected += " Unique Path: " + saved.uniquePath();
actual += " Unique Path: " + current.uniquePath();
expected += " Other Paths: " + Arrays.toString(saved.otherPaths());
actual += " Other Paths: " + Arrays.toString(current.otherPaths());
logger.error(expected);
logger.error(actual);
}
private static List<PVCameraInfo> filterAllowedDevices(List<PVCameraInfo> allDevices) {
Platform platform = Platform.getCurrentPlatform();
ArrayList<PVCameraInfo> filteredDevices = new ArrayList<>();

View File

@@ -18,6 +18,7 @@
package org.photonvision.vision.processes;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import edu.wpi.first.cscore.UsbCameraInfo;
@@ -273,4 +274,55 @@ public class VisionSourceManagerTest {
assertEquals(2, vsm.getVsmState().disabledConfigs.size());
assertEquals(1, vsm.vmm.getModules().size());
}
@Test
public void testMismatch() throws InterruptedException {
var vsm = new TestVsm();
// Create a saved camera configuration that expects a device at /dev/video0 with a name
PVCameraInfo savedInfo =
PVCameraInfo.fromUsbCameraInfo(
new UsbCameraInfo(
0, "/dev/video0", "CamA", new String[] {"/dev/v4l/by-path/1"}, 111, 222));
CameraConfiguration savedConf = new CameraConfiguration(savedInfo);
savedConf.deactivated = false;
savedConf.nickname = "SavedCam";
// Register the saved config so VSM creates a VisionModule
vsm.registerLoadedConfigs(List.of(savedConf));
// Now simulate a connected camera at same uniquePath but with a different name (mismatch)
List<PVCameraInfo> currentInfo =
List.of(
PVCameraInfo.fromUsbCameraInfo(
new UsbCameraInfo(
0,
"/dev/video0",
"CamDifferent",
new String[] {"/dev/v4l/by-path/1"},
111,
222)));
// Trigger state evaluation
vsm.checkMismatches(currentInfo);
// The module should have detected a mismatch
assertTrue(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
// Now simulate the device being disconnected
currentInfo = List.of();
vsm.checkMismatches(currentInfo);
// Mismatch should be cleared when device is disconnected
assertFalse(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
// Test with a matching camera info
currentInfo = List.of(savedInfo);
vsm.checkMismatches(currentInfo);
// The mismatch should be cleared
assertFalse(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
vsm.teardown();
}
}