>();
var videoModes = visionSource.getSettables().getAllVideoModes();
diff --git a/photon-core/src/main/java/org/photonvision/vision/processes/VisionSourceManager.java b/photon-core/src/main/java/org/photonvision/vision/processes/VisionSourceManager.java
index a0c18e128..329908258 100644
--- a/photon-core/src/main/java/org/photonvision/vision/processes/VisionSourceManager.java
+++ b/photon-core/src/main/java/org/photonvision/vision/processes/VisionSourceManager.java
@@ -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.
+ *
+ * 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 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 filterAllowedDevices(List allDevices) {
Platform platform = Platform.getCurrentPlatform();
ArrayList filteredDevices = new ArrayList<>();
diff --git a/photon-core/src/test/java/org/photonvision/vision/processes/VisionSourceManagerTest.java b/photon-core/src/test/java/org/photonvision/vision/processes/VisionSourceManagerTest.java
index 6d3a9e51b..c3ec48d00 100644
--- a/photon-core/src/test/java/org/photonvision/vision/processes/VisionSourceManagerTest.java
+++ b/photon-core/src/test/java/org/photonvision/vision/processes/VisionSourceManagerTest.java
@@ -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 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();
+ }
}