TypeCheck Frontend (#2394)

We recently had an error that would've been caught by type checking in the frontend (see #2393). This PR implements type checking so that future errors will be caught.

Additionally, this PR contains miscellaneous frontend cleanup that's tangentially related to type-checking.
This commit is contained in:
Sam Freund
2026-05-05 10:24:19 -05:00
committed by GitHub
parent d587cd19bb
commit 2372e110f9
43 changed files with 578 additions and 388 deletions

View File

@@ -91,18 +91,34 @@ jobs:
# ./gradlew build
# ./gradlew clean
typecheck-client:
name: "Typecheck Client"
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: photon-client/pnpm-lock.yaml
- name: Typecheck Client
working-directory: photon-client
run: |
pnpm install --frozen-lockfile
pnpm type-check
playwright-tests:
name: "Playwright E2E tests"
runs-on: ubuntu-24.04
needs: [validation]
steps:
# Checkout code.
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- uses: actions/setup-java@v5
with:
java-version: 25
@@ -136,7 +152,6 @@ jobs:
runs-on: ubuntu-24.04
needs: [validation]
steps:
# Checkout code.
- name: Checkout code
uses: actions/checkout@v6
with:

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare module "vue3-virtual-scroll-list";

View File

@@ -5,7 +5,7 @@ import skipFormattingConfig from "@vue/eslint-config-prettier/skip-formatting";
export default defineConfigWithVueTs(
pluginVue.configs["flat/recommended-error"],
vueTsConfigs.recommended,
vueTsConfigs.recommendedTypeChecked,
skipFormattingConfig,
{
ignores: ["**/dist/**", "playwright-report"]
@@ -42,10 +42,13 @@ export default defineConfigWithVueTs(
"vue/no-use-v-else-with-v-for": "error",
"vue/no-useless-mustaches": "error",
"vue/no-useless-v-bind": "error",
"vue/prefer-use-template-ref": "error",
"vue/require-default-prop": "off",
"vue/require-typed-ref": "error",
"vue/v-for-delimiter-style": "error",
"vue/v-on-event-hyphenation": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-object-type": "error",
"@typescript-eslint/no-explicit-any": "error",
"vue/valid-v-slot": ["error", { allowModifiers: true }]
}
}

View File

@@ -17,8 +17,8 @@
"format-ci": "prettier --check src/",
"test": "playwright test",
"test-ui": "playwright test --ui",
"test-setup": "playwright install --with-deps"
"test-setup": "playwright install --with-deps",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fontsource/prompt": "^5.2.6",
@@ -40,6 +40,7 @@
"@types/node": "^24.0.0",
"@types/three": "^0.178.0",
"@vitejs/plugin-vue": "^6.0.6",
"vue-tsc": "^3.2.5",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",

View File

@@ -90,6 +90,9 @@ importers:
vite-plugin-vuetify:
specifier: ^2.1.1
version: 2.1.1(vite@8.0.10(@types/node@24.12.2)(sass@1.89.2))(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3)
vue-tsc:
specifier: ^3.2.5
version: 3.2.5(typescript@5.8.3)
packages:
@@ -500,6 +503,15 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
'@volar/source-map@2.4.28':
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
@@ -541,6 +553,9 @@ packages:
typescript:
optional: true
'@vue/language-core@3.2.5':
resolution: {integrity: sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==}
'@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
@@ -596,6 +611,9 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -1154,6 +1172,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1184,6 +1205,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -1455,6 +1479,9 @@ packages:
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-eslint-parser@10.1.3:
resolution: {integrity: sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1466,6 +1493,12 @@ packages:
peerDependencies:
vue: ^3.2.0
vue-tsc@3.2.5:
resolution: {integrity: sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue3-virtual-scroll-list@0.2.1:
resolution: {integrity: sha512-G4KxITUOy9D4ro15zOp40D6ogmMefzjIyMsBKqN3xGbV1P6dlKYMx+BBXCKm3Nr/6iipcUKM272Sh2AJRyWMyQ==}
peerDependencies:
@@ -1874,6 +1907,18 @@ snapshots:
vite: 8.0.10(@types/node@24.12.2)(sass@1.89.2)
vue: 3.5.13(typescript@5.8.3)
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.27.2
@@ -1946,6 +1991,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vue/language-core@3.2.5':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
alien-signals: 3.1.2
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.4
'@vue/reactivity@3.5.13':
dependencies:
'@vue/shared': 3.5.13
@@ -2002,6 +2057,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
alien-signals@3.1.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -2522,6 +2579,8 @@ snapshots:
ms@2.1.3: {}
muggle-string@0.4.1: {}
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@@ -2554,6 +2613,8 @@ snapshots:
dependencies:
callsites: 3.1.0
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -2778,6 +2839,8 @@ snapshots:
fsevents: 2.3.3
sass: 1.89.2
vscode-uri@3.1.0: {}
vue-eslint-parser@10.1.3(eslint@9.31.0):
dependencies:
debug: 4.4.0
@@ -2796,6 +2859,12 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.8.3)
vue-tsc@3.2.5(typescript@5.8.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.2.5
typescript: 5.8.3
vue3-virtual-scroll-list@0.2.1(vue@3.5.13(typescript@5.8.3)):
dependencies:
vue: 3.5.13(typescript@5.8.3)

View File

@@ -11,9 +11,10 @@ import { useTheme } from "vuetify";
import { restoreThemeConfig } from "@/lib/ThemeManager";
const is_demo = import.meta.env.MODE === "demo";
const backendHost = inject<string>("backendHost");
if (!is_demo) {
const websocket = new AutoReconnectingWebsocket(
`ws://${inject("backendHost")}/websocket_data`,
`ws://${backendHost}/websocket_data`,
() => {
useStateStore().$patch({ backendConnected: true });
},

View File

@@ -3,7 +3,7 @@ import type { PhotonTarget } from "@/types/PhotonTrackingTypes";
// @ts-expect-error Intellisense says these conflict with the dynamic imports below
import type { Mesh, Object3D, PerspectiveCamera, Scene, WebGLRenderer } from "three";
// @ts-expect-error Intellisense says these conflict with the dynamic imports below
import type { TrackballControls } from "three/examples/jsm/controls/TrackballControls";
import type { TrackballControls } from "three/examples/jsm/controls/TrackballControls.js";
import { onBeforeUnmount, onMounted, watchEffect } from "vue";
const {
ArrowHelper,
@@ -20,7 +20,7 @@ const {
Scene,
WebGLRenderer
} = await import("three");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls.js");
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { createPerspectiveCamera } from "@/lib/ThreeUtils";
@@ -213,14 +213,14 @@ onMounted(async () => {
renderer.render(scene, camera);
};
drawTargets(props.targets);
await drawTargets(props.targets);
animate();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowResize);
});
watchEffect(() => {
drawTargets(props.targets);
void drawTargets(props.targets);
});
</script>

View File

@@ -1,5 +1,13 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, watchEffect, type Ref } from "vue";
import type {
Scene as SceneType,
PerspectiveCamera as PerspectiveCameraType,
WebGLRenderer as WebGLRendererType,
Group as GroupType,
Object3D
} from "three";
import type { TrackballControls as TrackballControlsType } from "three/examples/jsm/controls/TrackballControls.js";
const {
AmbientLight,
AxesHelper,
@@ -16,7 +24,7 @@ const {
SphereGeometry,
WebGLRenderer
} = await import("three");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls.js");
import type { BoardObservation, CameraCalibrationResult } from "@/types/SettingTypes";
import axios from "axios";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
@@ -31,12 +39,12 @@ const props = defineProps<{
title: string;
}>();
let scene: Scene | undefined;
let camera: PerspectiveCamera | undefined;
let renderer: WebGLRenderer | undefined;
let controls: TrackballControls | undefined;
let scene: SceneType | undefined;
let camera: PerspectiveCameraType | undefined;
let renderer: WebGLRendererType | undefined;
let controls: TrackballControlsType | undefined;
const createChessboard = (obs: BoardObservation, cal: CameraCalibrationResult): Group => {
const createChessboard = (obs: BoardObservation, cal: CameraCalibrationResult): GroupType => {
const group = new Group();
if (obs.locationInImageSpace.length === 0) return group;
@@ -194,9 +202,6 @@ const resetCamThirdPerson = () => {
let animationFrameId: number | null = null;
onMounted(async () => {
// Grab data first off
fetchCalibrationData();
scene = new Scene();
camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
@@ -256,6 +261,10 @@ onMounted(async () => {
controls.update();
// Fetch calibration only after the scene is ready so the initial draw
// can happen immediately when the data arrives.
await fetchCalibrationData();
const animate = () => {
if (!scene || !camera || !renderer || !controls) {
return;
@@ -318,7 +327,7 @@ if (import.meta.hot) {
}
watchEffect(() => {
drawCalibration(calibrationData.value);
void drawCalibration(calibrationData.value);
});
watch(
@@ -328,9 +337,9 @@ watch(
props.resolution.height,
useCameraSettingsStore().getCalibrationCoeffs(props.resolution)
],
() => {
async () => {
console.log("Camera or resolution changed, refetching calibration");
fetchCalibrationData();
await fetchCalibrationData();
}
);
</script>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, ref, onBeforeUnmount } from "vue";
import { computed, inject, onBeforeUnmount, useTemplateRef } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import type { StyleValue } from "vue";
@@ -13,6 +13,7 @@ const props = defineProps<{
cameraSettings: UiCameraConfiguration;
}>();
const backendHostname = inject<string>("backendHostname");
const emptyStreamSrc = "//:0";
const streamSrc = computed<string>(() => {
const port = props.cameraSettings.stream[props.streamType === "Raw" ? "inputPort" : "outputPort"];
@@ -21,7 +22,7 @@ const streamSrc = computed<string>(() => {
return emptyStreamSrc;
}
return `http://${inject("backendHostname")}:${port}/stream.mjpg`;
return `http://${backendHostname}:${port}/stream.mjpg`;
});
const streamDesc = computed<string>(() => `${props.streamType} Stream View`);
const streamStyle = computed<StyleValue>(() => {
@@ -67,26 +68,26 @@ const handleCaptureClick = () => {
const handlePopoutClick = () => {
window.open(streamSrc.value);
};
const handleFullscreenRequest = () => {
const handleFullscreenRequest = async () => {
const stream = document.getElementById(props.id);
if (!stream) return;
stream.requestFullscreen();
await stream.requestFullscreen();
};
const mjpgStream: any = ref(null);
const mjpgStream = useTemplateRef("mjpgStream");
const handleStreamError = () => {
if (streamSrc.value && streamSrc.value !== emptyStreamSrc) {
console.error("Error loading stream:", streamSrc.value, " Trying again.");
setTimeout(() => {
mjpgStream.value.src = streamSrc.value;
mjpgStream.value!.src = streamSrc.value;
}, 100);
}
};
onBeforeUnmount(() => {
if (!mjpgStream.value) return;
mjpgStream.value["src"] = emptyStreamSrc;
mjpgStream.value.src = emptyStreamSrc;
});
</script>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from "vue";
import { computed, inject, ref, useTemplateRef, watch } from "vue";
import { LogLevel, type LogMessage } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import LogEntry from "@/components/app/photon-log-entry.vue";
@@ -10,10 +10,10 @@ const backendHost = inject<string>("backendHost");
const searchQuery = ref("");
const timeInput = ref<string>();
const autoScroll = ref(true);
const logList = ref();
const logList = useTemplateRef<InstanceType<typeof VirtualList>>("logList"); // this needs to be typed in the definition since vue has trouble inferring it
const logKeeps = ref(40);
const exportLogFile = ref();
const selectedLogLevels = ref({
const exportLogFile = useTemplateRef("exportLogFile");
const selectedLogLevels = ref<Record<number, boolean>>({
[LogLevel.ERROR]: true,
[LogLevel.WARN]: true,
[LogLevel.INFO]: true,
@@ -48,7 +48,7 @@ watch(logs, () => {
);
autoScroll.value = bottomOffset < 50;
if (autoScroll.value) logList.value.scrollToBottom();
if (autoScroll.value) logList.value?.scrollToBottom();
});
const getLogLevelFromIndex = (index: number): string => {
@@ -56,7 +56,7 @@ const getLogLevelFromIndex = (index: number): string => {
};
const handleLogExport = () => {
exportLogFile.value.click();
exportLogFile.value?.click();
};
const handleLogClear = () => {

View File

@@ -89,7 +89,7 @@ const calibrationDivisors = computed(() =>
})
);
const uniqueVideoResolutionString = ref("");
const uniqueVideoResolutionIndex = ref(getUniqueVideoResolutionStrings()?.[0]?.value);
// Use a watchEffect so the value is populated/reacts when the stores become available or update.
// This avoids trying to index into an array that may be empty during page reload.
@@ -106,7 +106,7 @@ watchEffect(() => {
? currentFormatIndex
: names.length - 1;
useStateStore().calibrationData.videoFormatIndex = currentIndex;
uniqueVideoResolutionString.value = names[currentIndex] ?? "";
uniqueVideoResolutionIndex.value = currentIndex;
});
const squareSizeIn = ref(1);
const markerSizeIn = ref(0.75);
@@ -191,7 +191,7 @@ const downloadCalibBoard = async () => {
};
const isCalibrating = computed(
() => useCameraSettingsStore().currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.Calib3d
() => useCameraSettingsStore().currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.Calib3d.valueOf()
);
const startCalibration = () => {
@@ -310,23 +310,23 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
>
<v-form v-model="settingsValid">
<pv-select
v-model="uniqueVideoResolutionString"
v-model="uniqueVideoResolutionIndex"
label="Resolution"
:select-cols="8"
:disabled="isCalibrating"
tooltip="Resolution to calibrate at (you will have to calibrate every resolution you use 3D mode on)"
:items="getUniqueVideoResolutionStrings()"
@update:model-value="
useStateStore().calibrationData.videoFormatIndex =
getUniqueVideoResolutionStrings().find((v) => v.value === $event)?.value || 0
"
@update:model-value="(value) => (useStateStore().calibrationData.videoFormatIndex = value)"
/>
<pv-select
v-model="boardType"
label="Board Type"
tooltip="Calibration board pattern to use"
:select-cols="8"
:items="['Chessboard', 'ChArUco']"
:items="[
{ value: CalibrationBoardTypes.Charuco, name: 'ChArUco' },
{ value: CalibrationBoardTypes.Chessboard, name: 'Chessboard' }
]"
:disabled="isCalibrating"
/>
<v-alert
@@ -356,7 +356,12 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
label="Tag Family"
tooltip="Dictionary of ArUco markers on the ChArUco board"
:select-cols="8"
:items="['Dict_4X4_1000', 'Dict_5X5_1000', 'Dict_6X6_1000', 'Dict_7X7_1000']"
:items="[
{ value: CalibrationTagFamilies.Dict_4X4_1000, name: 'Dict_4X4_1000' },
{ value: CalibrationTagFamilies.Dict_5X5_1000, name: 'Dict_5X5_1000' },
{ value: CalibrationTagFamilies.Dict_6X6_1000, name: 'Dict_6X6_1000' },
{ value: CalibrationTagFamilies.Dict_7X7_1000, name: 'Dict_7X7_1000' }
]"
:disabled="isCalibrating"
/>
<pv-number-input

View File

@@ -3,7 +3,7 @@ import PhotonCalibrationVisualizer from "@/components/app/photon-calibration-vis
import type { CameraCalibrationResult, VideoFormat } from "@/types/SettingTypes";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import { computed, inject, ref } from "vue";
import { computed, inject, ref, useTemplateRef } from "vue";
import { axiosPost, getResolutionString, parseJsonFile } from "@/lib/PhotonUtils";
import { useTheme } from "vuetify";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
@@ -13,28 +13,28 @@ const props = defineProps<{
videoFormat: VideoFormat;
}>();
const confirmRemoveDialog = ref({ show: false, vf: props.videoFormat as VideoFormat });
const confirmRemoveDialog = ref({ show: false, vf: props.videoFormat });
const removeCalibration = (vf: VideoFormat) => {
axiosPost("/calibration/remove", "delete a camera calibration", {
const removeCalibration = async (vf: VideoFormat) => {
await axiosPost("/calibration/remove", "delete a camera calibration", {
cameraUniqueName: useCameraSettingsStore().currentCameraSettings.uniqueName,
width: vf.resolution.width,
height: vf.resolution.height
});
};
const exportCalibration = ref();
const exportCalibration = useTemplateRef("exportCalibration");
const openExportCalibrationPrompt = () => {
exportCalibration.value.click();
exportCalibration.value?.click();
};
const importCalibrationFromPhotonJson = ref();
const importCalibrationFromPhotonJson = useTemplateRef("importCalibrationFromPhotonJson");
const openUploadPhotonCalibJsonPrompt = () => {
importCalibrationFromPhotonJson.value.click();
importCalibrationFromPhotonJson.value?.click();
};
const importCalibration = async () => {
const files = importCalibrationFromPhotonJson.value.files;
if (files.length === 0) return;
const files = importCalibrationFromPhotonJson.value?.files;
if (!files?.length) return;
const uploadedJson = files[0];
const data = await parseJsonFile<CameraCalibrationResult>(uploadedJson);

View File

@@ -53,7 +53,7 @@ const fetchSnapshots = () => {
.get("/utils/getImageSnapshots")
.then((response) => {
imgData.value = response.data.map(
(snapshotData: { snapshotName: string; cameraUniqueName: string; snapshotData: string }, index) => {
(snapshotData: { snapshotName: string; cameraUniqueName: string; snapshotData: string }, index: number) => {
const metadata = getSnapshotMetadataFromName(snapshotData.snapshotName);
return {
@@ -99,7 +99,7 @@ const expanded = ref([]);
<v-card-text class="pt-0">
<v-btn
color="buttonPassive"
:variant="theme.global.current.value.dark ? 'outlined' : 'tonal'"
:variant="theme.global.current.value.dark ? 'outlined' : 'elevated'"
@click="fetchSnapshots"
>
<v-icon start class="open-icon" size="large"> mdi-folder </v-icon>

View File

@@ -9,6 +9,7 @@ import { computed, ref, watchEffect } from "vue";
import { type CameraSettingsChangeRequest, ValidQuirks } from "@/types/SettingTypes";
import { useTheme } from "vuetify";
import { axiosPost } from "@/lib/PhotonUtils";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
const theme = useTheme();
@@ -20,7 +21,7 @@ const focusMode = computed<boolean>({
get: () => useCameraSettingsStore().isFocusMode,
set: (v) =>
useCameraSettingsStore().changeCurrentPipelineIndex(
v ? -3 : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0,
v ? WebsocketPipelineType.FocusCamera : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0,
true
)
});
@@ -65,8 +66,8 @@ const settingsHaveChanged = (): boolean => {
const a = tempSettingsStruct.value;
const b = useCameraSettingsStore().currentCameraSettings;
for (const q in ValidQuirks) {
if (a.quirksToChange[q] !== b.cameraQuirks.quirks[q]) return true;
for (const quirk of Object.values(ValidQuirks)) {
if (a.quirksToChange[quirk] !== b.cameraQuirks.quirks[quirk]) return true;
}
return a.fov !== b.fov.value;
@@ -120,12 +121,12 @@ watchEffect(() => {
});
const showDeleteCamera = ref(false);
const deleteThisCamera = () => {
axiosPost("/utils/nukeOneCamera", "delete this camera", {
const deleteThisCamera = async () => {
await axiosPost("/utils/nukeOneCamera", "delete this camera", {
cameraUniqueName: useStateStore().currentCameraUniqueName
});
};
const wrappedCameras = computed<SelectItem[]>(() =>
const wrappedCameras = computed<SelectItem<string>[]>(() =>
Object.keys(useCameraSettingsStore().cameras).map((cameraUniqueName) => ({
name: useCameraSettingsStore().cameras[cameraUniqueName].nickname,
value: cameraUniqueName
@@ -159,7 +160,7 @@ const wrappedCameras = computed<SelectItem[]>(() =>
v-model="arducamSelectWrapper"
label="Arducam Model"
:items="[
{ name: 'None', value: 0, disabled: true },
{ name: 'None', value: 0 },
{ name: 'OV9281', value: 1 },
{ name: 'OV2311', value: 2 },
{ name: 'OV9782', value: 3 }

View File

@@ -6,6 +6,7 @@ import { PipelineType } from "@/types/PipelineTypes";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useTheme } from "vuetify";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
const theme = useTheme();
@@ -15,7 +16,7 @@ const driverMode = computed<boolean>({
get: () => useCameraSettingsStore().isDriverMode,
set: (v) =>
useCameraSettingsStore().changeCurrentPipelineIndex(
v ? -1 : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0,
v ? WebsocketPipelineType.DriverMode : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0,
true
)
});

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { PVCameraInfo } from "@/types/SettingTypes";
import { cameraInfoFor } from "@/lib/PhotonUtils";
const { camera } = defineProps({
camera: {
@@ -7,19 +8,6 @@ const { camera } = defineProps({
required: true
}
});
const cameraInfoFor: any = (camera: PVCameraInfo) => {
if (camera.PVUsbCameraInfo) {
return camera.PVUsbCameraInfo;
}
if (camera.PVCSICameraInfo) {
return camera.PVCSICameraInfo;
}
if (camera.PVFileCameraInfo) {
return camera.PVFileCameraInfo;
}
return {};
};
</script>
<template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { PVCameraInfo } from "@/types/SettingTypes";
import { cameraInfoFor } from "@/lib/PhotonUtils";
function isEqual<T>(a: T, b: T): boolean {
if (a === b) {
@@ -25,19 +26,6 @@ const { saved, current } = defineProps({
required: true
}
});
const cameraInfoFor = (camera: PVCameraInfo): any => {
if (camera.PVUsbCameraInfo) {
return camera.PVUsbCameraInfo;
}
if (camera.PVCSICameraInfo) {
return camera.PVCSICameraInfo;
}
if (camera.PVFileCameraInfo) {
return camera.PVFileCameraInfo;
}
return {};
};
</script>
<template>

View File

@@ -25,7 +25,7 @@ const emit = defineEmits<{
(e: "onEscape"): void;
}>();
const handleKeydown = ({ key }) => {
const handleKeydown = ({ key }: KeyboardEvent) => {
switch (key) {
case "Enter":
// Explicitly check that all rule props return true

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
<script setup lang="ts" generic="T extends string | number">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/pv-tooltipped-label.vue";
export interface SelectItem {
export interface SelectItem<TValue extends string | number> {
name: string | number;
value: string | number;
value: TValue;
disabled?: boolean;
}
const value = defineModel<string | number | undefined>({ required: true });
type SelectItems = SelectItem<T>[] | ReadonlyArray<T>;
const value = defineModel<T>({ required: true });
const props = withDefaults(
defineProps<{
@@ -15,7 +17,7 @@ const props = withDefaults(
tooltip?: string;
selectCols?: number;
disabled?: boolean;
items: string[] | number[] | SelectItem[];
items: SelectItems;
}>(),
{
selectCols: 9,
@@ -23,18 +25,20 @@ const props = withDefaults(
}
);
const areSelectItems = (items: SelectItems): items is SelectItem<T>[] => typeof items[0] === "object";
// Computed in case items changes
const items = computed<SelectItem[]>(() => {
const items = computed<SelectItem<T>[]>(() => {
// Trivial case for empty list; we have no data
if (!props.items.length) {
return [];
}
// Check if the prop exists on the object to infer object type
if ((props.items[0] as SelectItem).name) {
return props.items as SelectItem[];
if (areSelectItems(props.items)) {
return props.items;
}
return props.items.map((v, i) => ({ name: v, value: i }));
return props.items.map((item) => ({ name: item, value: item }));
});
</script>
@@ -49,7 +53,7 @@ const items = computed<SelectItem[]>(() => {
:items="items"
item-title="name"
item-value="value"
item-props.disabled="disabled"
item-props
:disabled="disabled"
hide-details="auto"
variant="underlined"

View File

@@ -18,11 +18,11 @@ const props = withDefaults(
const emit = defineEmits<{ (e: "update:modelValue", value: number): void }>();
// Debounce function
function debounce(func: (...args: any[]) => void, wait: number) {
function debounce(func: (...args: number[]) => void, wait: number) {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: any[]) {
return function (...args: number[]) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
timeout = setTimeout(() => func(...args), wait);
};
}

View File

@@ -13,28 +13,6 @@ import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
const theme = useTheme();
const changeCurrentCameraUniqueName = (cameraUniqueName: string) => {
useCameraSettingsStore().setCurrentCameraUniqueName(cameraUniqueName, true);
switch (useCameraSettingsStore().cameras[cameraUniqueName].pipelineSettings.pipelineType) {
case PipelineType.Reflective:
pipelineType.value = WebsocketPipelineType.Reflective;
break;
case PipelineType.ColoredShape:
pipelineType.value = WebsocketPipelineType.ColoredShape;
break;
case PipelineType.AprilTag:
pipelineType.value = WebsocketPipelineType.AprilTag;
break;
case PipelineType.Aruco:
pipelineType.value = WebsocketPipelineType.Aruco;
break;
case PipelineType.ObjectDetection:
pipelineType.value = WebsocketPipelineType.ObjectDetection;
break;
}
};
// Common RegEx used for naming both pipelines and cameras
const nameChangeRegex = /^[A-Za-z0-9_ \-)(]*[A-Za-z0-9][A-Za-z0-9_ \-)(.]*$/;
@@ -87,17 +65,17 @@ const cancelCameraNameEdit = () => {
};
// Pipeline Name Edit
const pipelineNamesWrapper = computed<SelectItem[]>(() => {
const pipelineNamesWrapper = computed(() => {
const pipelineNames = useCameraSettingsStore().pipelineNames.map((name, index) => ({ name: name, value: index }));
if (useCameraSettingsStore().isDriverMode) {
pipelineNames.push({ name: "Driver Mode", value: WebsocketPipelineType.DriverMode });
pipelineNames.push({ name: "Driver Mode", value: WebsocketPipelineType.DriverMode.valueOf() });
}
if (useCameraSettingsStore().isFocusMode) {
pipelineNames.push({ name: "Focus Mode", value: WebsocketPipelineType.FocusCamera });
pipelineNames.push({ name: "Focus Mode", value: WebsocketPipelineType.FocusCamera.valueOf() });
}
if (useCameraSettingsStore().isCalibrationMode) {
pipelineNames.push({ name: "3D Calibration Mode", value: WebsocketPipelineType.Calib3d });
pipelineNames.push({ name: "3D Calibration Mode", value: WebsocketPipelineType.Calib3d.valueOf() });
}
return pipelineNames;
@@ -240,7 +218,7 @@ useCameraSettingsStore().$subscribe((mutation, state) => {
break;
}
});
const wrappedCameras = computed<SelectItem[]>(() =>
const wrappedCameras = computed<SelectItem<string>[]>(() =>
Object.keys(useCameraSettingsStore().cameras).map((cameraUniqueName) => ({
name: useCameraSettingsStore().cameras[cameraUniqueName].nickname,
value: cameraUniqueName
@@ -257,7 +235,7 @@ const wrappedCameras = computed<SelectItem[]>(() =>
v-model="useStateStore().currentCameraUniqueName"
label="Camera"
:items="wrappedCameras"
@update:modelValue="changeCurrentCameraUniqueName"
@update:modelValue="pipelineType = useCameraSettingsStore().currentWebsocketPipelineType"
/>
<pv-input
v-else

View File

@@ -13,9 +13,9 @@ import OutputTab from "@/components/dashboard/tabs/OutputTab.vue";
import TargetsTab from "@/components/dashboard/tabs/TargetsTab.vue";
import PnPTab from "@/components/dashboard/tabs/PnPTab.vue";
import Map3DTab from "@/components/dashboard/tabs/Map3DTab.vue";
import { PipelineType } from "@/types/PipelineTypes";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
import { useDisplay } from "vuetify/lib/composables/display";
import { useTheme } from "vuetify";
import { useDisplay, useTheme } from "vuetify";
const theme = useTheme();
@@ -106,6 +106,17 @@ const tabGroups = computed<ConfigOption[][]>(() => {
.filter((it) => it.length); // Remove empty tab groups
});
// This boolean is used to satisfy type-checking requirements.
const shouldUseWideSecondTabGroup = computed(() => {
const currentPipelineSettings = useCameraSettingsStore().currentPipelineSettings;
return (
(currentPipelineSettings.pipelineType === PipelineType.AprilTag ||
currentPipelineSettings.pipelineType === PipelineType.Aruco) &&
currentPipelineSettings.doMultiTarget
);
});
const onBeforeTabUpdate = () => {
// Force the current tab to the input tab on driver mode change
if (useCameraSettingsStore().isDriverMode) {
@@ -129,7 +140,7 @@ const onBeforeTabUpdate = () => {
<v-col
v-for="(tabGroupData, tabGroupIndex) in tabGroups"
:key="tabGroupIndex"
:cols="tabGroupIndex === 1 && useCameraSettingsStore().currentPipelineSettings.doMultiTarget ? 7 : ''"
:cols="tabGroupIndex === 1 && shouldUseWideSecondTabGroup ? 7 : ''"
:class="tabGroupIndex !== tabGroups.length - 1 && 'pr-3'"
@vue:before-update="onBeforeTabUpdate"
>

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import { PipelineType } from "@/types/PipelineTypes";
import { PipelineType, type AprilTagPipelineSettings, AprilTagFamily } from "@/types/PipelineTypes";
import PvSelect from "@/components/common/pv-select.vue";
import PvSlider from "@/components/common/pv-slider.vue";
import PvSwitch from "@/components/common/pv-switch.vue";
import { computed } from "vue";
import { useStateStore } from "@/stores/StateStore";
import type { ActivePipelineSettings } from "@/types/PipelineTypes";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useDisplay } from "vuetify";
// TODO fix pipeline typing in order to fix this, the store settings call should be able to infer that only valid pipeline type settings are exposed based on pre-checks for the entire config section
// Defer reference to store access method
const currentPipelineSettings = computed<ActivePipelineSettings>(
() => useCameraSettingsStore().currentPipelineSettings
const currentPipelineSettings = computed<AprilTagPipelineSettings>(
() => useCameraSettingsStore().currentPipelineSettings as AprilTagPipelineSettings
);
const { mdAndDown } = useDisplay();
const interactiveCols = computed(() =>
@@ -25,7 +24,10 @@ const interactiveCols = computed(() =>
<pv-select
v-model="currentPipelineSettings.tagFamily"
label="Target family"
:items="['AprilTag 36h11 (6.5in)', 'AprilTag 16h5 (6in)']"
:items="[
{ value: AprilTagFamily.Family36h11, name: 'AprilTag 36h11 (6.5in)' },
{ value: AprilTagFamily.Family16h5, name: 'AprilTag 16h5 (6in)' }
]"
:select-cols="interactiveCols"
@update:modelValue="(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ tagFamily: value }, false)"
/>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType, type ActivePipelineSettings } from "@/types/PipelineTypes";
import { PipelineType, type ArucoPipelineSettings, AprilTagFamily } from "@/types/PipelineTypes";
import PvSlider from "@/components/common/pv-slider.vue";
import PvSwitch from "@/components/common/pv-switch.vue";
import PvRangeSlider from "@/components/common/pv-range-slider.vue";
@@ -11,8 +11,8 @@ import { useDisplay } from "vuetify";
// TODO fix pipeline typing in order to fix this, the store settings call should be able to infer that only valid pipeline type settings are exposed based on pre-checks for the entire config section
// Defer reference to store access method
const currentPipelineSettings = computed<ActivePipelineSettings>(
() => useCameraSettingsStore().currentPipelineSettings
const currentPipelineSettings = computed<ArucoPipelineSettings>(
() => useCameraSettingsStore().currentPipelineSettings as ArucoPipelineSettings
);
const { mdAndDown } = useDisplay();
const interactiveCols = computed(() =>
@@ -25,7 +25,10 @@ const interactiveCols = computed(() =>
<pv-select
v-model="currentPipelineSettings.tagFamily"
label="Target family"
:items="['AprilTag Family 36h11', 'AprilTag Family 16h5']"
:items="[
{ value: AprilTagFamily.Family36h11, name: 'AprilTag 36h11 (6.5in)' },
{ value: AprilTagFamily.Family16h5, name: 'AprilTag 16h5 (6in)' }
]"
:select-cols="interactiveCols"
@update:modelValue="(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ tagFamily: value }, false)"
/>

View File

@@ -1,6 +1,14 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { type ActivePipelineSettings, PipelineType } from "@/types/PipelineTypes";
import {
type ActivePipelineSettings,
PipelineType,
ContourSortMode,
ContourTargetOrientation,
ContourGroupingMode,
ContourIntersection,
ContourShape
} from "@/types/PipelineTypes";
import PvRangeSlider from "@/components/common/pv-range-slider.vue";
import PvSelect from "@/components/common/pv-select.vue";
import PvSlider from "@/components/common/pv-slider.vue";
@@ -61,7 +69,10 @@ const interactiveCols = computed(() =>
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOrientation"
label="Target Orientation"
tooltip="Used to determine how to calculate target landmarks, as well as aspect ratio"
:items="['Portrait', 'Landscape']"
:items="[
{ value: ContourTargetOrientation.Portrait, name: 'Portrait' },
{ value: ContourTargetOrientation.Landscape, name: 'Landscape' }
]"
:select-cols="interactiveCols"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourTargetOrientation: value }, false)
@@ -72,7 +83,15 @@ const interactiveCols = computed(() =>
label="Target Sort"
tooltip="Chooses the sorting mode used to determine the 'best' targets to provide to user code"
:select-cols="interactiveCols"
:items="['Largest', 'Smallest', 'Highest', 'Lowest', 'Rightmost', 'Leftmost', 'Centermost']"
:items="[
{ value: ContourSortMode.Largest, name: 'Largest' },
{ value: ContourSortMode.Smallest, name: 'Smallest' },
{ value: ContourSortMode.Highest, name: 'Highest' },
{ value: ContourSortMode.Lowest, name: 'Lowest' },
{ value: ContourSortMode.Rightmost, name: 'Rightmost' },
{ value: ContourSortMode.Leftmost, name: 'Leftmost' },
{ value: ContourSortMode.Centermost, name: 'Centermost' }
]"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourSortMode: value }, false)
"
@@ -166,7 +185,11 @@ const interactiveCols = computed(() =>
label="Target Grouping"
tooltip="Whether or not every two targets are paired with each other (good for e.g. 2019 targets)"
:select-cols="interactiveCols"
:items="['Single', 'Dual', 'Two or More']"
:items="[
{ value: ContourGroupingMode.Single, name: 'Single' },
{ value: ContourGroupingMode.Dual, name: 'Dual' },
{ value: ContourGroupingMode.TwoOrMore, name: 'Two or More' }
]"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourGroupingMode: value }, false)
"
@@ -176,7 +199,13 @@ const interactiveCols = computed(() =>
label="Target Intersection"
tooltip="If target grouping is in dual mode it will use this dropdown to decide how targets are grouped with adjacent targets"
:select-cols="interactiveCols"
:items="['None', 'Up', 'Down', 'Left', 'Right']"
:items="[
{ value: ContourIntersection.None, name: 'None' },
{ value: ContourIntersection.Up, name: 'Up' },
{ value: ContourIntersection.Down, name: 'Down' },
{ value: ContourIntersection.Left, name: 'Left' },
{ value: ContourIntersection.Right, name: 'Right' }
]"
:disabled="useCameraSettingsStore().currentPipelineSettings.contourGroupingMode === 0"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourIntersection: value }, false)
@@ -189,7 +218,12 @@ const interactiveCols = computed(() =>
label="Target Shape"
tooltip="The shape of targets to look for"
:select-cols="interactiveCols"
:items="['Circle', 'Polygon', 'Triangle', 'Quadrilateral']"
:items="[
{ value: ContourShape.Circle, name: 'Circle' },
{ value: ContourShape.Polygon, name: 'Polygon' },
{ value: ContourShape.Triangle, name: 'Triangle' },
{ value: ContourShape.Quadrilateral, name: 'Quadrilateral' }
]"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourShape: value }, false)
"

View File

@@ -30,11 +30,11 @@ const getFilteredStreamDivisors = (): number[] => {
};
const getNumberOfSkippedDivisors = () => streamDivisors.length - getFilteredStreamDivisors().length;
const cameraResolutions = computed(() =>
useCameraSettingsStore().currentCameraSettings.validVideoFormats.map(
(f) => `${getResolutionString(f.resolution)} at ${f.fps} FPS, ${f.pixelFormat}`
)
);
const cameraResolutions = (): { name: string; value: number }[] =>
useCameraSettingsStore().currentCameraSettings.validVideoFormats.map<{ name: string; value: number }>((f) => ({
name: `${getResolutionString(f.resolution)} at ${f.fps} FPS, ${f.pixelFormat}`,
value: f.index || 0 // Index won't ever be undefined
}));
const handleResolutionChange = (value: number) => {
useCameraSettingsStore().changeCurrentPipelineSetting({ cameraVideoModeIndex: value }, false);
@@ -49,20 +49,24 @@ const handleResolutionChange = (value: number) => {
const streamResolutions = computed(() => {
const streamDivisors = getFilteredStreamDivisors();
const currentResolution = useCameraSettingsStore().currentVideoFormat.resolution;
return streamDivisors.map(
(x) =>
`${getResolutionString({
width: Math.floor(currentResolution.width / x),
height: Math.floor(currentResolution.height / x)
})}`
);
return streamDivisors.map((x, i) => ({
name: `${Math.floor(currentResolution.width / x)}x${Math.floor(currentResolution.height / x)}`,
value: i
}));
});
const currentStreamResolutionIndex = computed<number>({
get: () => {
const stored = useCameraSettingsStore().currentPipelineSettings.streamingFrameDivisor;
const skipped = getNumberOfSkippedDivisors();
return stored - skipped;
},
set: (index) => {
useCameraSettingsStore().changeCurrentPipelineSetting(
{ streamingFrameDivisor: index + getNumberOfSkippedDivisors() },
false
);
}
});
const handleStreamResolutionChange = (value: number) => {
useCameraSettingsStore().changeCurrentPipelineSetting(
{ streamingFrameDivisor: value + getNumberOfSkippedDivisors() },
false
);
};
const { mdAndDown } = useDisplay();
const interactiveCols = computed(() =>
@@ -182,17 +186,16 @@ const interactiveCols = computed(() =>
v-model="useCameraSettingsStore().currentPipelineSettings.cameraVideoModeIndex"
label="Resolution"
tooltip="Resolution and FPS the camera should directly capture at"
:items="cameraResolutions"
:items="cameraResolutions()"
:select-cols="interactiveCols"
@update:modelValue="(args) => handleResolutionChange(args)"
/>
<pv-select
v-model="useCameraSettingsStore().currentPipelineSettings.streamingFrameDivisor"
v-model="currentStreamResolutionIndex"
label="Stream Resolution"
tooltip="Resolution to which camera frames are downscaled for streaming to the dashboard"
:items="streamResolutions"
:select-cols="interactiveCols"
@update:modelValue="(args) => handleStreamResolutionChange(args)"
/>
<pv-switch
v-if="useCameraSettingsStore().isDriverMode"

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { type ObjectDetectionPipelineSettings, PipelineType } from "@/types/PipelineTypes";
import {
type ObjectDetectionPipelineSettings,
PipelineType,
ContourSortMode,
ContourTargetOrientation
} from "@/types/PipelineTypes";
import PvSlider from "@/components/common/pv-slider.vue";
import PvSelect from "@/components/common/pv-select.vue";
import PvSelect, { type SelectItem } from "@/components/common/pv-select.vue";
import PvRangeSlider from "@/components/common/pv-range-slider.vue";
import { computed } from "vue";
import { useStateStore } from "@/stores/StateStore";
@@ -44,19 +49,19 @@ const supportedModels = computed<ObjectDetectionModelProperties[]>(() => {
return availableModels.filter(isSupported);
});
const selectedModel = computed({
get: () => {
const currentModel = currentPipelineSettings.value.model;
if (!currentModel) return undefined;
const modelWrapper = computed<SelectItem<string>[]>(() =>
supportedModels.value.map((model) => ({
name: model.nickname,
value: model.modelPath
}))
);
const index = supportedModels.value.findIndex((model) => model.modelPath === currentModel.modelPath);
return index === -1 ? undefined : index;
},
set: (v) => {
if (v !== undefined && v >= 0 && v < supportedModels.value.length) {
const newModel = supportedModels.value[v];
useCameraSettingsStore().changeCurrentPipelineSetting({ model: newModel }, true);
const selectedModel = computed<string>({
get: () => currentPipelineSettings.value.model?.modelPath ?? "",
set: (value) => {
const model = supportedModels.value.find((supportedModel) => supportedModel.modelPath === value);
if (model) {
useCameraSettingsStore().changeCurrentPipelineSetting({ model }, true);
}
}
});
@@ -69,7 +74,7 @@ const selectedModel = computed({
label="Model"
tooltip="The model used to detect objects in the camera feed"
:select-cols="interactiveCols"
:items="supportedModels.map((model) => model.nickname)"
:items="modelWrapper"
/>
<pv-slider
@@ -123,14 +128,13 @@ const selectedModel = computed({
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOrientation"
label="Target Orientation"
tooltip="Used to determine how to calculate target landmarks, as well as aspect ratio"
:items="['Portrait', 'Landscape']"
:items="[
{ value: ContourTargetOrientation.Portrait, name: 'Portrait' },
{ value: ContourTargetOrientation.Landscape, name: 'Landscape' }
]"
:select-cols="interactiveCols"
@update:modelValue="
(value) =>
useCameraSettingsStore().changeCurrentPipelineSetting(
{ contourTargetOrientation: typeof value === 'string' ? Number(value) : value },
false
)
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourTargetOrientation: value }, false)
"
/>
<pv-select
@@ -138,13 +142,17 @@ const selectedModel = computed({
label="Target Sort"
tooltip="Chooses the sorting mode used to determine the 'best' targets to provide to user code"
:select-cols="interactiveCols"
:items="['Largest', 'Smallest', 'Highest', 'Lowest', 'Rightmost', 'Leftmost', 'Centermost']"
:items="[
{ value: ContourSortMode.Largest, name: 'Largest' },
{ value: ContourSortMode.Smallest, name: 'Smallest' },
{ value: ContourSortMode.Highest, name: 'Highest' },
{ value: ContourSortMode.Lowest, name: 'Lowest' },
{ value: ContourSortMode.Rightmost, name: 'Rightmost' },
{ value: ContourSortMode.Leftmost, name: 'Leftmost' },
{ value: ContourSortMode.Centermost, name: 'Centermost' }
]"
@update:modelValue="
(value) =>
useCameraSettingsStore().changeCurrentPipelineSetting(
{ contourSortMode: typeof value === 'string' ? Number(value) : value },
false
)
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourSortMode: value }, false)
"
/>
</div>

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
import PvSelect from "@/components/common/pv-select.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { type ActivePipelineSettings, PipelineType, RobotOffsetPointMode } from "@/types/PipelineTypes";
import {
type ActivePipelineSettings,
PipelineType,
RobotOffsetPointMode,
ContourTargetOrientation,
ContourTargetOffsetPointEdge
} from "@/types/PipelineTypes";
import PvSwitch from "@/components/common/pv-switch.vue";
import PvSlider from "@/components/common/pv-slider.vue";
import { computed } from "vue";
@@ -108,7 +114,13 @@ const interactiveCols = computed(() =>
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOffsetPointEdge"
label="Target Offset Point"
tooltip="Changes where the 'center' of the target is (used for calculating e.g. pitch and yaw)"
:items="['Center', 'Top', 'Bottom', 'Left', 'Right']"
:items="[
{ value: ContourTargetOffsetPointEdge.Center, name: 'Center' },
{ value: ContourTargetOffsetPointEdge.Top, name: 'Top' },
{ value: ContourTargetOffsetPointEdge.Bottom, name: 'Bottom' },
{ value: ContourTargetOffsetPointEdge.Left, name: 'Left' },
{ value: ContourTargetOffsetPointEdge.Right, name: 'Right' }
]"
:select-cols="interactiveCols"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourTargetOffsetPointEdge: value }, false)
@@ -119,7 +131,10 @@ const interactiveCols = computed(() =>
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOrientation"
label="Target Orientation"
tooltip="Used to determine how to calculate target landmarks (e.g. the top, left, or bottom of the target)"
:items="['Portrait', 'Landscape']"
:items="[
{ value: ContourTargetOrientation.Portrait, name: 'Portrait' },
{ value: ContourTargetOrientation.Landscape, name: 'Landscape' }
]"
:select-cols="interactiveCols"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ contourTargetOrientation: value }, false)
@@ -129,7 +144,11 @@ const interactiveCols = computed(() =>
v-model="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode"
label="Robot Offset Mode"
tooltip="Used to add an arbitrary offset to the location of the targeting crosshair"
:items="['None', 'Single Point', 'Dual Point']"
:items="[
{ value: RobotOffsetPointMode.None, name: 'None' },
{ value: RobotOffsetPointMode.Single, name: 'Single Point' },
{ value: RobotOffsetPointMode.Dual, name: 'Dual Point' }
]"
:select-cols="interactiveCols"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ offsetRobotOffsetMode: value }, false)

View File

@@ -1,6 +1,5 @@
@ -0,0 +1,565 @@
<script setup lang="ts">
import { inject, computed, ref, watch } from "vue";
import { inject, computed, ref, watch, useTemplateRef } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import PvSelect from "@/components/common/pv-select.vue";
@@ -15,20 +14,20 @@ const theme = useTheme();
const restartProgram = async () => {
if (await axiosPost("/utils/restartProgram", "restart PhotonVision")) {
forceReloadPage();
await forceReloadPage();
}
};
const restartDevice = async () => {
if (await axiosPost("/utils/restartDevice", "restart the device")) {
forceReloadPage();
await forceReloadPage();
}
};
const address = inject<string>("backendHost");
const offlineUpdate = ref();
const offlineUpdate = useTemplateRef("offlineUpdate");
const openOfflineUpdatePrompt = () => {
offlineUpdate.value.click();
offlineUpdate.value?.click();
};
const offlineUpdateRegex = new RegExp("photonvision-((?:dev-)?v[\\w.-]+)-((?:linux|win|mac)\\w+)\\.jar");
@@ -37,8 +36,8 @@ const majorVersionRegex = new RegExp("(?:dev-)?(\\d+)\\.\\d+\\.\\d+");
const offlineUpdateDialog = ref({ show: false, confirmString: "" });
const handleOfflineUpdateRequest = async () => {
const files = offlineUpdate.value.files;
if (files.length === 0) return;
const files = offlineUpdate.value?.files;
if (!files?.length) return;
const match = files[0].name.match(offlineUpdateRegex);
if (!match) {
@@ -68,7 +67,7 @@ const handleOfflineUpdateRequest = async () => {
});
return;
} else if (versionMatch && !dev) {
handleOfflineUpdate(files[0]);
await handleOfflineUpdate(files[0]);
} else if (!versionMatch && !dev) {
offlineUpdateDialog.value = {
show: true,
@@ -99,7 +98,7 @@ const handleOfflineUpdate = async (file: File) => {
if (
await axiosPost("/utils/offlineUpdate", "upload new software", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
onUploadProgress: ({ progress }: { progress?: number }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
@@ -118,18 +117,18 @@ const handleOfflineUpdate = async (file: File) => {
color: "secondary",
timeout: -1
});
forceReloadPage();
await forceReloadPage();
}
};
const exportLogFile = ref();
const exportLogFile = useTemplateRef("exportLogFile");
const openExportLogsPrompt = () => {
exportLogFile.value.click();
exportLogFile.value?.click();
};
const exportSettings = ref();
const exportSettings = useTemplateRef("exportSettings");
const openExportSettingsPrompt = () => {
exportSettings.value.click();
exportSettings.value?.click();
};
enum ImportType {
@@ -141,10 +140,10 @@ enum ImportType {
}
const showImportDialog = ref(false);
const importType = ref<ImportType | undefined>(undefined);
const importType = ref<ImportType>(ImportType.AllSettings);
const importFile = ref<File | null>(null);
const handleSettingsImport = () => {
const handleSettingsImport = async () => {
if (importType.value === undefined || importFile.value === null) return;
const formData = new FormData();
formData.append("data", importFile.value);
@@ -167,18 +166,18 @@ const handleSettingsImport = () => {
settingsEndpoint = "";
break;
}
axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
await axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
showImportDialog.value = false;
importType.value = undefined;
importType.value = ImportType.AllSettings;
importFile.value = null;
};
const showFactoryReset = ref(false);
const nukePhotonConfigDirectory = async () => {
if (await axiosPost("/utils/nukeConfigDirectory", "delete the config directory")) {
forceReloadPage();
await forceReloadPage();
}
};
@@ -503,7 +502,7 @@ watch(metricsHistorySnapshot, () => {
width="600"
@update:modelValue="
() => {
importType = undefined;
importType = ImportType.AllSettings;
importFile = null;
}
"
@@ -517,7 +516,13 @@ watch(metricsHistorySnapshot, () => {
v-model="importType"
label="Type"
tooltip="Select the type of settings file you are trying to upload"
:items="['All Settings', 'Hardware Config', 'Hardware Settings', 'Network Config', 'Apriltag Layout']"
:items="[
{ value: ImportType.AllSettings, name: 'All Settings' },
{ value: ImportType.HardwareConfig, name: 'Hardware Config' },
{ value: ImportType.HardwareSettings, name: 'Hardware Settings' },
{ value: ImportType.NetworkConfig, name: 'Network Config' },
{ value: ImportType.ApriltagFieldLayout, name: 'AprilTag Field Layout' }
]"
:select-cols="10"
style="width: 100%"
/>
@@ -558,7 +563,9 @@ watch(metricsHistorySnapshot, () => {
:variant="theme.global.current.value.dark ? 'outlined' : 'elevated'"
@click="
offlineUpdateDialog.show = false;
handleOfflineUpdate(offlineUpdate.files[0]);
if (offlineUpdate?.files?.length) {
handleOfflineUpdate(offlineUpdate.files[0]);
}
"
>
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>

View File

@@ -106,6 +106,7 @@ const saveGeneralSettings = async () => {
// Update the local settings cause the backend checked their validity. Assign is to deref value
useSettingsStore().network = { ...useSettingsStore().network, ...Object.assign({}, tempSettingsStruct.value) };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
resetTempSettingsStruct();
if (error.response) {
@@ -150,14 +151,11 @@ const saveGeneralSettings = async () => {
}
};
const currentNetworkInterfaceIndex = computed<number | undefined>({
get: () => {
const index = useSettingsStore().networkInterfaceNames.indexOf(
useSettingsStore().network.networkManagerIface || ""
);
return index === -1 ? undefined : index;
},
set: (v) => v && (tempSettingsStruct.value.networkManagerIface = useSettingsStore().networkInterfaceNames[v])
const currentNetworkInterface = computed<string>({
get: () => useSettingsStore().network.networkManagerIface || "",
set: (v) => {
tempSettingsStruct.value.networkManagerIface = v;
}
});
watchEffect(() => {
@@ -256,7 +254,7 @@ watchEffect(() => {
/>
<pv-select
v-show="!useSettingsStore().network.networkingDisabled"
v-model="currentNetworkInterfaceIndex"
v-model="currentNetworkInterface"
label="NetworkManager interface"
:disabled="
!tempSettingsStruct.shouldManage ||

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount, watch } from "vue";
import { onMounted, onBeforeUnmount, watch, useTemplateRef } from "vue";
import { useTheme } from "vuetify";
// Color - original (adjusted)
@@ -8,14 +8,14 @@ import { useTheme } from "vuetify";
// green - 65, 181, 127 (r: 75, g: 209, b: 147)
// red - 238, 102, 102 (r: 238, g: 102, b: 102)
const colors = {
"blue-LightTheme": { r: 255, g: 216, b: 67 },
"blue-DarkTheme": { r: 92, g: 154, b: 255 },
"purple-LightTheme": { r: 255, g: 216, b: 67 },
"purple-DarkTheme": { r: 167, g: 104, b: 196 },
"red-LightTheme": { r: 255, g: 216, b: 67 },
"red-DarkTheme": { r: 238, g: 102, b: 102 },
"green-LightTheme": { r: 255, g: 216, b: 67 },
"green-DarkTheme": { r: 75, g: 209, b: 147 }
"blue-light": { r: 255, g: 216, b: 67 },
"blue-dark": { r: 92, g: 154, b: 255 },
"purple-light": { r: 255, g: 216, b: 67 },
"purple-dark": { r: 167, g: 104, b: 196 },
"red-light": { r: 255, g: 216, b: 67 },
"red-dark": { r: 238, g: 102, b: 102 },
"green-light": { r: 255, g: 216, b: 67 },
"green-dark": { r: 75, g: 209, b: 147 }
};
const DEFAULT_COLOR = "blue";
@@ -26,9 +26,13 @@ const typeLabels = {
};
const theme = useTheme();
const chartRef = ref(null);
const chartRef = useTemplateRef("chartRef");
let chart: echarts.ECharts | null = null;
interface TooltipSeriesParam {
value: [number, number];
}
const getOptions = (data: ChartData[] = []) => {
const now = Date.now();
return {
@@ -37,7 +41,7 @@ const getOptions = (data: ChartData[] = []) => {
},
tooltip: {
trigger: "axis",
formatter: (params: any) => {
formatter: (params: TooltipSeriesParam[]) => {
const p = params[0];
const append = typeLabels[props.type];
const fmsLimitLabel = "FMS Limit - 7.000 Mb/s";
@@ -80,12 +84,12 @@ const getOptions = (data: ChartData[] = []) => {
min: now - 55 * 1000,
axisLine: {
lineStyle: {
color: theme.global.name.value === "LightTheme" ? "#aaa" : "#777"
color: theme.global.current.value.dark ? "#777" : "#aaa"
}
},
axisLabel: {
align: "left",
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd",
color: theme.global.current.value.dark ? "#ddd" : "#fff",
formatter: (value: number) => {
const date = new Date(value);
return date.toLocaleTimeString([], {
@@ -102,12 +106,12 @@ const getOptions = (data: ChartData[] = []) => {
position: "right",
min:
props.min ??
function (value) {
function (value: { min: number; max: number }) {
return Math.max(0, (value.min - 10) | 0);
},
max:
props.max ??
function (value) {
function (value: { min: number; max: number }) {
return (value.max + 10) | 0;
},
splitNumber: 2,
@@ -118,7 +122,7 @@ const getOptions = (data: ChartData[] = []) => {
}
},
axisLabel: {
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd"
color: theme.global.current.value.dark ? "#ddd" : "#fff"
}
},
series: getSeries(data),
@@ -127,7 +131,7 @@ const getOptions = (data: ChartData[] = []) => {
};
const getSeries = (data: ChartData[] = []) => {
const color = colors[`${props.color ?? DEFAULT_COLOR}-${theme.global.name.value}`];
const color = colors[`${props.color ?? DEFAULT_COLOR}-${theme.global.current.value.dark ? "dark" : "light"}`];
return [
{
type: "line",
@@ -188,10 +192,10 @@ interface ChartData {
// Type options: "percentage", "temperature", "mb"
const props = defineProps<{
data: ChartData[];
type: string;
type: keyof typeof typeLabels;
min?: number;
max?: number;
color?: string;
color?: "red" | "green" | "blue" | "purple";
}>();
onMounted(async () => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { ref, computed, inject, useTemplateRef } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { type ObjectDetectionModelProperties } from "@/types/SettingTypes";
@@ -46,7 +46,7 @@ const handleImport = async () => {
if (
await axiosPost("/objectdetection/import", "import an object detection model", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
onUploadProgress: ({ progress }: { progress?: number }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
@@ -74,20 +74,20 @@ const handleImport = async () => {
importVersion.value = null;
};
const deleteModel = (model: ObjectDetectionModelProperties) => {
axiosPost("/objectdetection/delete", "delete an object detection model", {
const deleteModel = async (model: ObjectDetectionModelProperties) => {
await axiosPost("/objectdetection/delete", "delete an object detection model", {
modelPath: model.modelPath
});
};
const renameModel = (model: ObjectDetectionModelProperties, newName: string) => {
const renameModel = async (model: ObjectDetectionModelProperties, newName: string) => {
useStateStore().showSnackbarMessage({
message: "Renaming Object Detection Model...",
color: "secondary",
timeout: -1
});
axiosPost("/objectdetection/rename", "rename an object detection model", {
await axiosPost("/objectdetection/rename", "rename an object detection model", {
modelPath: model.modelPath,
newName: newName
});
@@ -97,7 +97,7 @@ const renameModel = (model: ObjectDetectionModelProperties, newName: string) =>
// Filters out models that are not supported by the current backend, and returns a flattened list.
const supportedModels = computed(() => {
const { availableModels, supportedBackends } = useSettingsStore().general;
const isSupported = (model: any) => {
const isSupported = (model: ObjectDetectionModelProperties) => {
// Check if model's family is in the list of supported backends
return supportedBackends.some((backend: string) => backend.toLowerCase() === model.family.toLowerCase());
};
@@ -106,19 +106,19 @@ const supportedModels = computed(() => {
return availableModels.filter(isSupported);
});
const exportModels = ref();
const exportModels = useTemplateRef("exportModels");
const openExportPrompt = () => {
exportModels.value.click();
exportModels.value?.click();
};
const exportIndividualModel = ref();
const exportIndividualModel = useTemplateRef("exportIndividualModel");
const openExportIndividualModelPrompt = () => {
exportIndividualModel.value.click();
exportIndividualModel.value?.click();
};
const showNukeDialog = ref(false);
const nukeModels = () => {
axiosPost("/objectdetection/nuke", "clear and reset object detection models");
const nukeModels = async () => {
await axiosPost("/objectdetection/nuke", "clear and reset object detection models");
};
const showBulkImportDialog = ref(false);
@@ -132,7 +132,7 @@ const handleBulkImport = async () => {
if (
await axiosPost("/objectdetection/bulkimport", "import object detection models", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
onUploadProgress: ({ progress }: { progress?: number }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({

View File

@@ -40,18 +40,13 @@ export class AutoReconnectingWebsocket {
* Send data over the websocket. This is a no-op if the websocket is not in the OPEN state.
*
* @param data data to send
* @param encodeData whether or not to encode the data using msgpack (defaults to true)
* @see isConnected
*
*/
send(data, encodeData = true) {
send(data: unknown) {
// Only send data if the websocket is open
if (this.isConnected()) {
if (encodeData) {
this.websocket?.send(encode(data));
} else {
this.websocket?.send(data);
}
this.websocket?.send(encode(data));
}
}

View File

@@ -1,6 +1,6 @@
import { useStateStore } from "@/stores/StateStore";
import type { Resolution } from "@/types/SettingTypes";
import axios from "axios";
import type { PVCameraInfo, Resolution } from "@/types/SettingTypes";
import axios, { type AxiosRequestConfig } from "axios";
export const resolutionsAreEqual = (a: Resolution, b: Resolution) => {
return a.height === b.height && a.width === b.width;
@@ -51,15 +51,16 @@ export const forceReloadPage = async () => {
export const getResolutionString = (resolution: Resolution): string => `${resolution.width}x${resolution.height}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const parseJsonFile = async <T extends Record<string, any>>(file: File): Promise<T> => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (event) => {
const target: FileReader | null = event.target;
if (target === null) reject();
if (target === null) reject(new Error("FileReader event target is null"));
else resolve(JSON.parse(target.result as string) as T);
};
fileReader.onerror = (error) => reject(error);
fileReader.onerror = () => reject(new Error("Error reading file"));
fileReader.readAsText(file);
});
};
@@ -73,7 +74,13 @@ export const parseJsonFile = async <T extends Record<string, any>>(file: File):
* @param config Optional axios request configuration
* @returns A promise that resolves to true if the POST request is successful, or false if an error occurs.
*/
export const axiosPost = async (url: string, description: string, data?: any, config?: any): Promise<boolean> => {
export const axiosPost = async (
url: string,
description: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any,
config?: AxiosRequestConfig
): Promise<boolean> => {
try {
await axios.post(url, data, config);
useStateStore().showSnackbarMessage({
@@ -81,6 +88,7 @@ export const axiosPost = async (url: string, description: string, data?: any, co
color: "success"
});
return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error.response) {
useStateStore().showSnackbarMessage({
@@ -101,3 +109,23 @@ export const axiosPost = async (url: string, description: string, data?: any, co
return false;
}
};
type CameraInfoDetails = Partial<
NonNullable<PVCameraInfo["PVUsbCameraInfo"]> &
NonNullable<PVCameraInfo["PVCSICameraInfo"]> &
NonNullable<PVCameraInfo["PVFileCameraInfo"]>
>;
export const cameraInfoFor = (camera: PVCameraInfo | null): CameraInfoDetails => {
if (!camera) return {};
if (camera.PVUsbCameraInfo) {
return camera.PVUsbCameraInfo;
}
if (camera.PVCSICameraInfo) {
return camera.PVCSICameraInfo;
}
if (camera.PVFileCameraInfo) {
return camera.PVFileCameraInfo;
}
return {};
};

View File

@@ -11,7 +11,7 @@ const runtimeMode: PhotonClientRuntimeMode = process.env.NODE_ENV as PhotonClien
let backendHost: string;
let backendHostname: string;
switch (runtimeMode as PhotonClientRuntimeMode) {
switch (runtimeMode) {
case "development":
backendHost = `${location.hostname}:5800`;
backendHostname = location.hostname;

View File

@@ -1,6 +1,6 @@
import "vuetify/styles";
import("@mdi/font/css/materialdesignicons.css");
import type { ThemeDefinition } from "vuetify/lib/composables/theme";
void import("@mdi/font/css/materialdesignicons.css");
import type { ThemeDefinition } from "vuetify";
import { createVuetify } from "vuetify";
const CommonColors = {

View File

@@ -31,7 +31,8 @@ interface StateStore {
currentCameraUniqueName: string;
networkUsageHistory: NetworkUsageEntry[];
backendResults: Record<number, PipelineResult>;
// Key is a string, although often used as an index, because we need to reference using the camera unique name at times.
backendResults: Record<string, PipelineResult>;
multitagResultBuffer: Record<string, MultitagResult[]>;
colorPickingMode: boolean;

View File

@@ -64,17 +64,14 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
pipelineNames(): string[] {
return this.currentCameraSettings.pipelineNicknames;
},
currentPipelineName(): string {
return this.pipelineNames[useStateStore().currentCameraUniqueName];
},
isDriverMode(): boolean {
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.DriverMode;
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.DriverMode.valueOf();
},
isCalibrationMode(): boolean {
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.Calib3d;
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.Calib3d.valueOf();
},
isFocusMode(): boolean {
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.FocusCamera;
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.FocusCamera.valueOf();
},
isCSICamera(): boolean {
return this.currentCameraSettings.isCSICamera;
@@ -196,7 +193,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
addNewPipeline: [newPipelineName, pipelineType],
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Modify the settings of the currently selected pipeline of the provided camera.
@@ -220,15 +217,13 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
if (updateStore) {
this.changePipelineSettingsInStore(settings, cameraUniqueName);
}
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
changePipelineSettingsInStore(
settings: Partial<ActivePipelineSettings>,
cameraUniqueName: string = useStateStore().currentCameraUniqueName
) {
Object.entries(settings).forEach(([k, v]) => {
this.cameras[cameraUniqueName].pipelineSettings[k] = v;
});
Object.assign(this.cameras[cameraUniqueName].pipelineSettings, settings);
},
/**
* Change the nickname of the currently selected pipeline of the provided camera.
@@ -249,7 +244,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
if (updateStore) {
this.cameras[cameraUniqueName].pipelineSettings.pipelineNickname = newName;
}
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Modify the Pipeline type of the currently selected pipeline of the provided camera. This overwrites the current pipeline's settings when the backend resets the current pipeline settings.
@@ -265,7 +260,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
pipelineType: type,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Change the index of the pipeline of the currently selected camera.
@@ -285,21 +280,22 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
};
if (updateStore) {
if (
this.cameras[cameraUniqueName].currentPipelineIndex !== -1 &&
this.cameras[cameraUniqueName].currentPipelineIndex !== -2
this.cameras[cameraUniqueName].currentPipelineIndex !== WebsocketPipelineType.DriverMode.valueOf() &&
this.cameras[cameraUniqueName].currentPipelineIndex !== WebsocketPipelineType.Calib3d.valueOf() &&
this.cameras[cameraUniqueName].currentPipelineIndex !== WebsocketPipelineType.FocusCamera.valueOf()
) {
this.cameras[cameraUniqueName].lastPipelineIndex = this.cameras[cameraUniqueName].currentPipelineIndex;
}
this.cameras[cameraUniqueName].currentPipelineIndex = index;
}
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
setDriverMode(isDriverMode: boolean, cameraUniqueName: string = useStateStore().currentCameraUniqueName) {
const payload = {
driverMode: isDriverMode,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Change the currently selected pipeline of the provided camera.
@@ -311,7 +307,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
deleteCurrentPipeline: {},
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Duplicate the pipeline at the provided index.
@@ -324,7 +320,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
duplicatePipeline: pipelineIndex,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Change the currently set camera
@@ -339,7 +335,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
if (updateStore) {
useStateStore().currentCameraUniqueName = cameraUniqueName;
}
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Change the nickname of the provided camera.
@@ -392,7 +388,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
},
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* End the 3D calibration process for the provided camera.
@@ -424,7 +420,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
takeCalibrationSnapshot: true,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Save a snapshot of the input frame of the camera.
@@ -436,7 +432,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
saveInputSnapshot: true,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Save a snapshot of the output frame of the camera.
@@ -448,7 +444,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
saveOutputSnapshot: true,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
/**
* Set the robot offset mode type.
@@ -461,7 +457,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
robotOffsetPoint: type,
cameraUniqueName: cameraUniqueName
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
},
getCalibrationCoeffs(
resolution: Resolution,

View File

@@ -10,6 +10,7 @@ import { NetworkConnectionType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import axios from "axios";
import type { WebsocketSettingsUpdate } from "@/types/WebsocketDataTypes";
import type { AprilTagFieldLayout } from "@/types/PhotonTrackingTypes";
import { ref } from "vue";
interface GeneralSettingsStore {
@@ -17,7 +18,7 @@ interface GeneralSettingsStore {
network: NetworkSettings;
lighting: LightingSettings;
metrics: MetricData;
currentFieldLayout;
currentFieldLayout: AprilTagFieldLayout;
}
interface MetricsEntry {
@@ -184,7 +185,7 @@ export const useSettingsStore = defineStore("settings", {
const payload = {
enabledLEDPercentage: brightness
};
useStateStore().websocket?.send(payload, true);
useStateStore().websocket?.send(payload);
}
}
});

View File

@@ -5,6 +5,7 @@ import type { ObjectDetectionModelProperties } from "@/types/SettingTypes";
* The on-wire form of PipelineType.java (the enum is serialized with `ordinal()`)
*/
export enum PipelineType {
Calibration3d = 1,
DriverMode = 2,
Reflective = 3,
ColoredShape = 4,
@@ -35,18 +36,62 @@ export enum TargetModel {
ReefscapeAlgae = 7
}
export enum ContourSortMode {
Largest = 0,
Smallest = 1,
Highest = 2,
Lowest = 3,
Leftmost = 4,
Rightmost = 5,
Centermost = 6
}
export enum ContourTargetOrientation {
Portrait = 0,
Landscape = 1
}
export enum ContourGroupingMode {
Single = 0,
Dual = 1,
TwoOrMore = 2
}
export enum ContourIntersection {
None = 0,
Up = 1,
Down = 2,
Left = 3,
Right = 4
}
export enum ContourShape {
Circle = 0,
Polygon = 1,
Triangle = 2,
Quadrilateral = 3
}
export enum ContourTargetOffsetPointEdge {
Center = 0,
Top = 1,
Bottom = 2,
Left = 3,
Right = 4
}
export interface PipelineSettings {
offsetRobotOffsetMode: RobotOffsetPointMode;
streamingFrameDivisor: number;
offsetDualPointBArea: number;
contourGroupingMode: number;
contourGroupingMode: ContourGroupingMode;
hsvValue: WebsocketNumberPair | [number, number];
cameraGain: number;
cameraBlueGain: number;
cameraRedGain: number;
cornerDetectionSideCount: number;
contourRatio: WebsocketNumberPair | [number, number];
contourTargetOffsetPointEdge: number;
contourTargetOffsetPointEdge: ContourTargetOffsetPointEdge;
pipelineNickname: string;
inputImageRotationMode: number;
contourArea: WebsocketNumberPair | [number, number];
@@ -56,7 +101,7 @@ export interface PipelineSettings {
inputShouldShow: boolean;
cameraAutoExposure: boolean;
contourSpecklePercentage: number;
contourTargetOrientation: number;
contourTargetOrientation: ContourTargetOrientation;
targetModel: TargetModel;
cornerDetectionUseConvexHulls: boolean;
outputShouldShow: boolean;
@@ -67,7 +112,7 @@ export interface PipelineSettings {
ledMode: boolean;
hueInverted: boolean;
outputMaximumTargets: number;
contourSortMode: number;
contourSortMode: ContourSortMode;
cameraExposureRaw: number;
cameraMinExposureRaw: number;
cameraMaxExposureRaw: number;
@@ -80,11 +125,13 @@ export interface PipelineSettings {
cornerDetectionAccuracyPercentage: number;
hsvSaturation: WebsocketNumberPair | [number, number];
pipelineType: PipelineType;
contourIntersection: number;
contourIntersection: ContourIntersection;
cameraAutoWhiteBalance: boolean;
cameraWhiteBalanceTemp: number;
crosshair: boolean;
blockForFrames: boolean;
}
export type ConfigurablePipelineSettings = Partial<
@@ -113,13 +160,13 @@ export const DefaultPipelineSettings: Omit<
offsetRobotOffsetMode: RobotOffsetPointMode.None,
streamingFrameDivisor: 0,
offsetDualPointBArea: 0,
contourGroupingMode: 0,
contourGroupingMode: ContourGroupingMode.Single,
hsvValue: { first: 50, second: 255 },
cameraBlueGain: 20,
cameraRedGain: 11,
cornerDetectionSideCount: 4,
contourRatio: { first: 0, second: 20 },
contourTargetOffsetPointEdge: 0,
contourTargetOffsetPointEdge: ContourTargetOffsetPointEdge.Center,
pipelineNickname: "Placeholder Pipeline",
inputImageRotationMode: 0,
contourArea: { first: 0, second: 100 },
@@ -129,7 +176,7 @@ export const DefaultPipelineSettings: Omit<
inputShouldShow: false,
cameraAutoExposure: false,
contourSpecklePercentage: 5,
contourTargetOrientation: 1,
contourTargetOrientation: ContourTargetOrientation.Landscape,
cornerDetectionUseConvexHulls: true,
outputShouldShow: true,
outputShouldDraw: true,
@@ -138,7 +185,7 @@ export const DefaultPipelineSettings: Omit<
hsvHue: { first: 50, second: 180 },
hueInverted: false,
outputMaximumTargets: 20,
contourSortMode: 0,
contourSortMode: ContourSortMode.Largest,
offsetSinglePoint: { x: 0, y: 0 },
cameraBrightness: 50,
offsetDualPointAArea: 0,
@@ -147,11 +194,12 @@ export const DefaultPipelineSettings: Omit<
cornerDetectionStrategy: 0,
cornerDetectionAccuracyPercentage: 10,
hsvSaturation: { first: 50, second: 255 },
contourIntersection: 1,
contourIntersection: ContourIntersection.Up,
cameraAutoWhiteBalance: false,
cameraWhiteBalanceTemp: 4000,
cameraMinExposureRaw: 1,
cameraMaxExposureRaw: 2,
crosshair: true,
blockForFrames: true
};
@@ -184,7 +232,7 @@ export interface ColoredShapePipelineSettings extends PipelineSettings {
contourRadius: WebsocketNumberPair | [number, number];
circleDetectThreshold: number;
accuracyPercentage: number;
contourShape: number;
contourShape: ContourShape;
contourPerimeter: WebsocketNumberPair | [number, number];
minDist: number;
maxCannyThresh: number;
@@ -209,7 +257,7 @@ export const DefaultColoredShapePipelineSettings: ColoredShapePipelineSettings =
contourRadius: { first: 0, second: 100 },
circleDetectThreshold: 5,
accuracyPercentage: 10,
contourShape: 2,
contourShape: ContourShape.Triangle,
contourPerimeter: { first: 0, second: 1.7976931348623157e308 },
minDist: 20,
maxCannyThresh: 90
@@ -324,13 +372,14 @@ export const DefaultObjectDetectionPipelineSettings: ObjectDetectionPipelineSett
};
export interface Calibration3dPipelineSettings extends PipelineSettings {
pipelineType: PipelineType.Calibration3d;
drawAllSnapshots: boolean;
}
export type ConfigurableCalibration3dPipelineSettings = Partial<Omit<Calibration3dPipelineSettings, "pipelineType">> &
ConfigurablePipelineSettings;
export const DefaultCalibration3dPipelineSettings: Calibration3dPipelineSettings = {
...DefaultPipelineSettings,
pipelineType: PipelineType.ObjectDetection,
pipelineType: PipelineType.Calibration3d,
cameraGain: 20,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,

View File

@@ -2,14 +2,8 @@
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
} from "@/types/SettingTypes";
import { axiosPost, getResolutionString } from "@/lib/PhotonUtils";
import { PlaceholderCameraSettings, PVCameraInfo } from "@/types/SettingTypes";
import { axiosPost, getResolutionString, cameraInfoFor } from "@/lib/PhotonUtils";
import PhotonCameraStream from "@/components/app/photon-camera-stream.vue";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
import PvCameraInfoCard from "@/components/common/pv-camera-info-card.vue";
@@ -18,20 +12,22 @@ import { useTheme } from "vuetify";
const theme = useTheme();
const formatUrl = (port) => `http://${inject("backendHostname")}:${port}/stream.mjpg`;
const backendHostname = inject<string>("backendHostname");
const formatUrl = (port: number) => `http://${backendHostname}:${port}/stream.mjpg`;
const activatingModule = ref(false);
const activateModule = (moduleUniqueName: string) => {
const activateModule = async (moduleUniqueName: string) => {
if (activatingModule.value) return;
activatingModule.value = true;
axiosPost("/utils/activateMatchedCamera", "activate a matched camera", {
await axiosPost("/utils/activateMatchedCamera", "activate a matched camera", {
cameraUniqueName: moduleUniqueName
}).finally(() => (activatingModule.value = false));
});
activatingModule.value = false;
};
const assigningCamera = ref(false);
const assignCamera = (cameraInfo: PVCameraInfo) => {
const assignCamera = async (cameraInfo: PVCameraInfo) => {
if (assigningCamera.value) return;
assigningCamera.value = true;
@@ -39,32 +35,30 @@ const assignCamera = (cameraInfo: PVCameraInfo) => {
cameraInfo: cameraInfo
};
axiosPost("/utils/assignUnmatchedCamera", "assign an unmatched camera", payload).finally(
() => (assigningCamera.value = false)
);
await axiosPost("/utils/assignUnmatchedCamera", "assign an unmatched camera", payload);
assigningCamera.value = false;
};
const deactivatingModule = ref(false);
const deactivateModule = (cameraUniqueName: string) => {
const deactivateModule = async (cameraUniqueName: string) => {
if (deactivatingModule.value) return;
deactivatingModule.value = true;
axiosPost("/utils/unassignCamera", "unassign a camera", { cameraUniqueName: cameraUniqueName }).finally(
() => (deactivatingModule.value = false)
);
await axiosPost("/utils/unassignCamera", "unassign a camera", { cameraUniqueName: cameraUniqueName });
deactivatingModule.value = false;
};
const confirmDeleteDialog = ref({ show: false, nickname: "", cameraUniqueName: "" });
const deletingCamera = ref<string | null>(null);
const deleteThisCamera = (cameraUniqueName: string) => {
const deleteThisCamera = async (cameraUniqueName: string) => {
if (deletingCamera.value) return;
deletingCamera.value = cameraUniqueName;
axiosPost("/utils/nukeOneCamera", "delete a camera", { cameraUniqueName: cameraUniqueName }).finally(() => {
deletingCamera.value = null;
});
await axiosPost("/utils/nukeOneCamera", "delete a camera", { cameraUniqueName: cameraUniqueName });
deletingCamera.value = null;
};
const cameraConnected = (uniquePath: string): boolean => {
const cameraConnected = (uniquePath: string | undefined): boolean => {
if (!uniquePath) return false;
return (
useStateStore().vsmState.allConnectedCameras.find((it) => cameraInfoFor(it).uniquePath === uniquePath) !== undefined
);
@@ -105,23 +99,6 @@ const setCameraView = (camera: PVCameraInfo | null, isConnected: boolean | null)
viewingCamera.value = [camera, isConnected];
};
/**
* 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
*/

View File

@@ -1,8 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

View File

@@ -1,9 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "vite.config.ts", "playwright.config.ts", "src/**/*", "src/**/*.vue", "tests/**/*"],
"compilerOptions": {
"moduleResolution": "node",
"noImplicitAny": false,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"noImplicitAny": true,
"strict": true,
"removeComments": true,
"sourceMap": true,
@@ -14,9 +15,4 @@
"@/*": ["./src/*"]
}
},
"references": [
{
"path": "./tsconfig.config.json"
}
]
}