Skip to content

Add camera mismatch banner to dashboard #1921

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
884472b
Add camera mismatch banner to dashboard
alaninnovates Apr 19, 2025
74b9ab0
Abstract getMatchedDevice more to take in all devices
alaninnovates Apr 19, 2025
0d5a388
Documentation within matching utils
alaninnovates Apr 19, 2025
449bc2a
run prettier
alaninnovates Apr 19, 2025
8f1db75
Add logs to backend on mismatch
alaninnovates Apr 20, 2025
7597dae
format
alaninnovates Apr 20, 2025
e156af8
Working matching logic
alaninnovates Apr 20, 2025
44ac1ce
add docs for duplicated logic
alaninnovates Apr 20, 2025
846c3c5
run spotless
alaninnovates Apr 20, 2025
0897d4f
Move camera mismatch logic
alaninnovates Apr 20, 2025
3fb3378
format
alaninnovates Apr 20, 2025
e8a756b
change logging format
alaninnovates Apr 20, 2025
7577459
add map that stores prior warned
alaninnovates Apr 21, 2025
ecdd849
make copy of camera info & all modules
alaninnovates Apr 21, 2025
4c1e9b8
move .equals logic to PVCameraInfo class
alaninnovates Apr 21, 2025
9a6aaab
Merge branch 'main' into camera-mismatch-banner
alaninnovates Apr 21, 2025
494403e
Fix camera mismatch showing on no cameras
alaninnovates Apr 22, 2025
759a5c4
Merge branch 'main' into camera-mismatch-banner
samfreund Apr 23, 2025
81508b5
fix calls to getMatchedDevice
alaninnovates Apr 25, 2025
ef11318
format; proper condition for match on dashboard
alaninnovates Apr 26, 2025
7d0eb5f
more verbose camera mismatch err message
alaninnovates Apr 26, 2025
00d5738
Merge branch 'main' into camera-mismatch-banner
alaninnovates Apr 26, 2025
f72d357
Merge branch 'main' into camera-mismatch-banner
samfreund Apr 30, 2025
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
68 changes: 68 additions & 0 deletions photon-client/src/lib/MatchingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { PVCameraInfo, type PVCSICameraInfo, type PVFileCameraInfo, type PVUsbCameraInfo } from "@/types/SettingTypes";

/**
* Check if two cameras match by comparing properties.
* For USB cameras, it checks the name, vendorId, productId, and uniquePath.
* For CSI cameras, it checks the uniquePath and baseName.
* For file cameras, it checks the uniquePath and name.
* Note: When changing this function, change the equivalent function within photon-core's VisionSourceManager class
*/
export 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;
};

/**
* Get the connection-type-specific camera info from the given PVCameraInfo object.
*/
export 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
*/
export const getMatchedDevice = (allDevices: PVCameraInfo[], info: PVCameraInfo | undefined): PVCameraInfo => {
if (!info) {
return {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
};
}
return (
allDevices.find((it) => cameraInfoFor(it).uniquePath === cameraInfoFor(info).uniquePath) || {
PVFileCameraInfo: undefined,
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
}
);
};
72 changes: 6 additions & 66 deletions photon-client/src/views/CameraMatchingView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { computed, inject, ref } from "vue";
import { useStateStore } from "@/stores/StateStore";
import {
PlaceholderCameraSettings,
PVCameraInfo,
type PVCSICameraInfo,
type PVFileCameraInfo,
type PVUsbCameraInfo,
type UiCameraConfiguration
} from "@/types/SettingTypes";
import { PlaceholderCameraSettings, PVCameraInfo, type UiCameraConfiguration } from "@/types/SettingTypes";
import { getResolutionString } from "@/lib/PhotonUtils";
import PvCameraInfoCard from "@/components/common/pv-camera-info-card.vue";
import axios from "axios";
import PvCameraMatchCard from "@/components/common/pv-camera-match-card.vue";
import type { WebsocketCameraSettingsUpdate } from "@/types/WebsocketDataTypes";
import { camerasMatch, cameraInfoFor, getMatchedDevice } from "@/lib/MatchingUtils";

const formatUrl = (port) => `http://${inject("backendHostname")}:${port}/stream.mjpg`;
const host = inject<string>("backendHost");
Expand Down Expand Up @@ -95,63 +89,6 @@ 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 => {
return (
useStateStore().vsmState.allConnectedCameras.find((it) => cameraInfoFor(it).uniquePath === uniquePath) !== undefined
Expand Down Expand Up @@ -226,7 +163,10 @@ const openExportSettingsPrompt = () => {
<v-card-subtitle
v-else-if="
cameraCononected(cameraInfoFor(module.matchedCameraInfo).uniquePath) &&
camerasMatch(getMatchedDevice(module.matchedCameraInfo), module.matchedCameraInfo)
camerasMatch(
getMatchedDevice(useStateStore().vsmState.allConnectedCameras, module.matchedCameraInfo),
module.matchedCameraInfo
)
"
class="pb-2"
>Status: <span class="active-status">Active</span></v-card-subtitle
Expand Down
25 changes: 25 additions & 0 deletions photon-client/src/views/DashboardView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import PipelineConfigCard from "@/components/dashboard/ConfigOptions.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import { PlaceholderCameraSettings } from "@/types/SettingTypes";
import { camerasMatch, getMatchedDevice } from "@/lib/MatchingUtils";

const cameraViewType = computed<number[]>({
get: (): number[] => {
Expand Down Expand Up @@ -58,6 +59,16 @@ const arducamWarningShown = computed<boolean>(() => {
)
);
});

const cameraMismatchWarningShown = computed<boolean>(() => {
return Object.values(useCameraSettingsStore().cameras).some(
(camera) =>
!camerasMatch(
getMatchedDevice(useStateStore().vsmState.allConnectedCameras, camera.matchedCameraInfo),
camera.matchedCameraInfo
)
);
});
</script>

<template>
Expand All @@ -75,6 +86,20 @@ const arducamWarningShown = computed<boolean>(() => {
>Arducam Camera Detected! Please configure the camera model in the <a href="#/cameras">Cameras tab</a>!
</span>
</v-banner>
<v-banner
v-if="cameraMismatchWarningShown"
v-model="cameraMismatchWarningShown"
rounded
color="error"
dark
class="mb-3"
icon="mdi-alert-circle-outline"
>
<span
>Camera Mismatch Detected! Please ensure cameras are plugged in correctly. Visit the
<a href="#/cameraConfigs">Camera Matching</a> page for more information.
</span>
</v-banner>
<v-row no-gutters align="center" justify="center">
<v-col cols="12" class="pb-3 pr-lg-3" lg="8" align-self="stretch">
<CamerasCard v-model="cameraViewType" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Stream;
Expand Down Expand Up @@ -79,6 +80,9 @@ public static class VisionSourceManagerState {
// Map of (unique name) -> (all CameraConfigurations) that have been registered
protected final HashMap<String, CameraConfiguration> disabledCameraConfigs = new HashMap<>();

// Set of cameras that where a camera mismatch error was logged
protected final Set<String> warnedMismatchCameras = Set.of();

// The subset of cameras that are "active", converted to VisionModules
public VisionModuleManager vmm = new VisionModuleManager();

Expand All @@ -97,7 +101,8 @@ public synchronized void registerLoadedConfigs(Collection<CameraConfiguration> c

final HashMap<String, CameraConfiguration> deserializedConfigs = new HashMap<>();

// 1. Verify all camera unique names are unique and paths/types are unique for paranoia. This
// 1. Verify all camera unique names are unique and paths/types are unique for
// paranoia. This
// seems redundant, consider deleting
for (var config : configs) {
Predicate<PVCameraInfo> checkDuplicateCamera =
Expand All @@ -118,7 +123,8 @@ public synchronized void registerLoadedConfigs(Collection<CameraConfiguration> c
}
}

// 2. create sources -> VMMs for all active cameras and add to our VMM. We don't care about if
// 2. create sources -> VMMs for all active cameras and add to our VMM. We don't
// care about if
// the underlying device is currently connected or not.
deserializedConfigs.values().stream()
.filter(it -> !it.deactivated)
Expand Down Expand Up @@ -311,16 +317,116 @@ protected List<PVCameraInfo> getConnectedCameras() {
.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);

// from the listed physical camera infos, match them to the camera configs and
// check for mismatches
var allModulesCopy = new ArrayList<>(vmm.getModules());
var cameraInfosCopy = new ArrayList<>(cameraInfos);
cameraInfosCopy.stream()
.filter(cameraInfo -> !warnedMismatchCameras.contains(cameraInfo.toString()))
.forEach(
cameraInfo -> {
allModulesCopy.stream()
.filter(
module ->
module
.getCameraConfiguration()
.matchedCameraInfo
.uniquePath()
.equals(cameraInfo.uniquePath()))
.forEach(
module -> {
if (!camerasMatch(
module.getCameraConfiguration().matchedCameraInfo, cameraInfo)) {
logger.error("Camera mismatch error!");
logger.error(
"Camera config mismatch for "
+ module.getCameraConfiguration().nickname);
logCameraInfoDiff(
module.getCameraConfiguration().matchedCameraInfo, cameraInfo);
warnedMismatchCameras.add(cameraInfo.toString());
}
});
});

return cameraInfos;
}

/**
* Check if two cameras match by comparing properties. For USB cameras, it checks the name,
* vendorId, productId, and uniquePath. For CSI cameras, it checks the uniquePath and baseName.
* For file cameras, it checks the uniquePath and name. Note: When changing this function, change
* the equivalent function within photon-client's lib/MatchingUtils.ts file
*/
private static boolean camerasMatch(PVCameraInfo camera1, PVCameraInfo camera2) {
if (camera1 instanceof PVCameraInfo.PVUsbCameraInfo usbCamera1
&& camera2 instanceof PVCameraInfo.PVUsbCameraInfo usbCamera2) {
return usbCamera1.name().equals(usbCamera2.name())
&& usbCamera1.vendorId == usbCamera2.vendorId
&& usbCamera1.productId == usbCamera2.productId
&& usbCamera1.uniquePath().equals(usbCamera2.uniquePath());
} else if (camera1 instanceof PVCameraInfo.PVCSICameraInfo csiCamera1
&& camera2 instanceof PVCameraInfo.PVCSICameraInfo csiCamera2) {
return csiCamera1.uniquePath().equals(csiCamera2.uniquePath())
&& csiCamera1.baseName.equals(csiCamera2.baseName);
} else if (camera1 instanceof PVCameraInfo.PVFileCameraInfo fileCamera1
&& camera2 instanceof PVCameraInfo.PVFileCameraInfo fileCamera2) {
return fileCamera1.uniquePath().equals(fileCamera2.uniquePath())
&& fileCamera1.name().equals(fileCamera2.name());
} else {
return 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<>();
Expand Down Expand Up @@ -372,7 +478,8 @@ private static List<PVCameraInfo> filterAllowedDevices(List<PVCameraInfo> allDev
protected VisionSource loadVisionSourceFromCamConfig(CameraConfiguration configuration) {
logger.debug("Creating VisionSource for " + configuration.toShortString());

// First, make sure that nickname is globally unique since we use the nickname in NetworkTables.
// First, make sure that nickname is globally unique since we use the nickname
// in NetworkTables.
// "Just one more source of truth bro it'll real this time I promise"
var currentNicknames = new ArrayList<String>();
this.disabledCameraConfigs.values().stream()
Expand Down
Loading