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

@@ -142,7 +142,8 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
maxWhiteBalanceTemp: d.maxWhiteBalanceTemp,
matchedCameraInfo: d.matchedCameraInfo,
isConnected: d.isConnected,
hasConnected: d.hasConnected
hasConnected: d.hasConnected,
mismatch: d.mismatch
};
return acc;
}, {});

View File

@@ -266,6 +266,7 @@ export interface UiCameraConfiguration {
matchedCameraInfo: PVCameraInfo;
isConnected: boolean;
hasConnected: boolean;
mismatch: boolean;
}
export interface CameraSettingsChangeRequest {
@@ -388,7 +389,8 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = {
PVUsbCameraInfo: undefined
},
isConnected: true,
hasConnected: true
hasConnected: true,
mismatch: false
};
export enum CalibrationBoardTypes {

View File

@@ -69,6 +69,7 @@ export interface WebsocketCameraSettingsUpdate {
matchedCameraInfo: PVCameraInfo;
isConnected: boolean;
hasConnected: boolean;
mismatch: boolean;
}
export interface WebsocketNTUpdate {
connected: boolean;

View File

@@ -168,64 +168,7 @@ const deleteThisCamera = (cameraName: string) => {
});
};
const camerasMatch = (camera1: PVCameraInfo, camera2: PVCameraInfo) => {
if (camera1.PVUsbCameraInfo && camera2.PVUsbCameraInfo)
return (
camera1.PVUsbCameraInfo.name === camera2.PVUsbCameraInfo.name &&
camera1.PVUsbCameraInfo.vendorId === camera2.PVUsbCameraInfo.vendorId &&
camera1.PVUsbCameraInfo.productId === camera2.PVUsbCameraInfo.productId &&
camera1.PVUsbCameraInfo.uniquePath === camera2.PVUsbCameraInfo.uniquePath
);
else if (camera1.PVCSICameraInfo && camera2.PVCSICameraInfo)
return (
camera1.PVCSICameraInfo.uniquePath === camera2.PVCSICameraInfo.uniquePath &&
camera1.PVCSICameraInfo.baseName === camera2.PVCSICameraInfo.baseName
);
else if (camera1.PVFileCameraInfo && camera2.PVFileCameraInfo)
return (
camera1.PVFileCameraInfo.uniquePath === camera2.PVFileCameraInfo.uniquePath &&
camera1.PVFileCameraInfo.name === camera2.PVFileCameraInfo.name
);
else return false;
};
const cameraInfoFor = (camera: PVCameraInfo | null): PVUsbCameraInfo | PVCSICameraInfo | PVFileCameraInfo | any => {
if (!camera) return null;
if (camera.PVUsbCameraInfo) {
return camera.PVUsbCameraInfo;
}
if (camera.PVCSICameraInfo) {
return camera.PVCSICameraInfo;
}
if (camera.PVFileCameraInfo) {
return camera.PVFileCameraInfo;
}
return {};
};
/**
* Find the PVCameraInfo currently occupying the same uniquepath as the the given module
*/
const getMatchedDevice = (info: PVCameraInfo | undefined): PVCameraInfo => {
if (!info) {
return {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
};
}
return (
useStateStore().vsmState.allConnectedCameras.find(
(it) => cameraInfoFor(it).uniquePath === cameraInfoFor(info).uniquePath
) || {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
}
);
};
const cameraCononected = (uniquePath: string): boolean => {
const cameraConnected = (uniquePath: string): boolean => {
return (
useStateStore().vsmState.allConnectedCameras.find((it) => cameraInfoFor(it).uniquePath === uniquePath) !== undefined
);
@@ -252,8 +195,8 @@ const activeVisionModules = computed(() =>
// Display connected cameras first
.sort(
(first, second) =>
(cameraCononected(cameraInfoFor(second.matchedCameraInfo).uniquePath) ? 1 : 0) -
(cameraCononected(cameraInfoFor(first.matchedCameraInfo).uniquePath) ? 1 : 0)
(cameraConnected(cameraInfoFor(second.matchedCameraInfo).uniquePath) ? 1 : 0) -
(cameraConnected(cameraInfoFor(first.matchedCameraInfo).uniquePath) ? 1 : 0)
)
);
@@ -274,6 +217,45 @@ const setCameraDeleting = (camera: UiCameraConfiguration | WebsocketCameraSettin
cameraToDelete.value = camera;
};
const yesDeleteMySettingsText = ref("");
/**
* Get the connection-type-specific camera info from the given PVCameraInfo object.
*/
const cameraInfoFor = (camera: PVCameraInfo | null): PVUsbCameraInfo | PVCSICameraInfo | PVFileCameraInfo | any => {
if (!camera) return null;
if (camera.PVUsbCameraInfo) {
return camera.PVUsbCameraInfo;
}
if (camera.PVCSICameraInfo) {
return camera.PVCSICameraInfo;
}
if (camera.PVFileCameraInfo) {
return camera.PVFileCameraInfo;
}
return {};
};
/**
* Find the PVCameraInfo currently occupying the same uniquePath as the the given module
*/
const getMatchedDevice = (info: PVCameraInfo | undefined): PVCameraInfo => {
if (!info) {
return {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
};
}
return (
useStateStore().vsmState.allConnectedCameras.find(
(it) => cameraInfoFor(it).uniquePath === cameraInfoFor(info).uniquePath
) || {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
}
);
};
</script>
<template>
@@ -290,14 +272,11 @@ const yesDeleteMySettingsText = ref("");
>
<v-card color="surface" class="rounded-12">
<v-card-title>{{ cameraInfoFor(module.matchedCameraInfo).name }}</v-card-title>
<v-card-subtitle v-if="!cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath)"
<v-card-subtitle v-if="!cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath)"
>Status: <span class="inactive-status">Disconnected</span></v-card-subtitle
>
<v-card-subtitle
v-else-if="
cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath) &&
camerasMatch(getMatchedDevice(module.matchedCameraInfo), module.matchedCameraInfo)
"
v-else-if="cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath) && !module.mismatch"
>Status: <span class="active-status">Active</span></v-card-subtitle
>
<v-card-subtitle v-else>Status: <span class="mismatch-status">Mismatch</span></v-card-subtitle>
@@ -306,7 +285,7 @@ const yesDeleteMySettingsText = ref("");
<tbody>
<tr
v-if="
cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath) &&
cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath) &&
useStateStore().backendResults[module.uniqueName]
"
>
@@ -348,7 +327,7 @@ const yesDeleteMySettingsText = ref("");
</tbody>
</v-table>
<div
v-if="cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath)"
v-if="cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath)"
:id="`stream-container-${index}`"
class="d-flex flex-column justify-center align-center mt-3"
style="height: 250px"
@@ -370,7 +349,7 @@ const yesDeleteMySettingsText = ref("");
@click="
setCameraView(
module.matchedCameraInfo,
cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath)
cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath)
)
"
>
@@ -441,7 +420,7 @@ const yesDeleteMySettingsText = ref("");
</tr>
<tr>
<td>Connected</td>
<td>{{ cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath) }}</td>
<td>{{ cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath) }}</td>
</tr>
</tbody>
</v-table>
@@ -456,7 +435,7 @@ const yesDeleteMySettingsText = ref("");
@click="
setCameraView(
module.matchedCameraInfo,
cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath)
cameraConnected(cameraInfoFor(module.matchedCameraInfo).uniquePath)
)
"
>
@@ -562,7 +541,13 @@ const yesDeleteMySettingsText = ref("");
<v-card-text v-if="!viewingCamera[1]">
<PvCameraInfoCard :camera="viewingCamera[0]" />
</v-card-text>
<v-card-text v-else-if="!camerasMatch(getMatchedDevice(viewingCamera[0]), viewingCamera[0])">
<v-card-text
v-else-if="
activeVisionModules.find(
(it) => cameraInfoFor(it.matchedCameraInfo).uniquePath === cameraInfoFor(viewingCamera[0]).uniquePath
)?.mismatch
"
>
<v-alert
class="mb-3"
color="buttonActive"

View File

@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useTheme } from "vuetify";
const theme = useTheme();
import { PlaceholderCameraSettings } from "@/types/SettingTypes";
const cameraViewType = computed<number[]>({
get: (): number[] => {
@@ -54,6 +55,17 @@ const arducamWarningShown = computed<boolean>(() => {
);
});
const cameraMismatchWarningShown = computed<boolean>(() => {
return (
Object.values(useCameraSettingsStore().cameras)
// Ignore placeholder camera
.filter((camera) => JSON.stringify(camera) !== JSON.stringify(PlaceholderCameraSettings))
.some((camera) => {
return camera.mismatch;
})
);
});
const conflictingHostnameShown = computed<boolean>(() => {
return useSettingsStore().general.conflictingHostname;
});
@@ -104,6 +116,21 @@ const showCameraSetupDialog = ref(useCameraSettingsStore().needsCameraConfigurat
{{ useSettingsStore().general.conflictingCameras }}!
</span>
</v-alert>
<v-banner
v-if="cameraMismatchWarningShown"
v-model="cameraMismatchWarningShown"
rounded
color="error"
dark
class="mb-3"
icon="mdi-alert-circle-outline"
>
<span
>Camera Mismatch Detected! Visit the <a href="#/cameraConfigs">Camera Matching</a> page for more information.
Note: Camera matching is done by USB port. Ensure cameras are plugged into the same USB ports as when they were
activated.
</span>
</v-banner>
<v-row no-gutters>
<v-col cols="12" class="pb-3 pr-lg-3" lg="8" align-self="stretch">
<CamerasCard v-model="cameraViewType" />

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();
}
}