allow panels to hold commands

This commit is contained in:
2025-12-28 11:48:11 -05:00
parent 59a0c81eb4
commit c3253f3204
11 changed files with 385 additions and 51 deletions

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import {
type AnyTypeId,
type DynamicDataType,
isBooleanType,
isNumericType,
} from '@/composables/dynamic.ts';
const props = defineProps<{
type: AnyTypeId;
}>();
const model = defineModel<DynamicDataType>();
const is_numeric = computed(() => {
return isNumericType(props.type);
});
const is_boolean = computed(() => {
return isBooleanType(props.type);
});
// Initialize the parameter to some value:
onMounted(() => {
if (is_numeric.value) {
model.value = 0.0;
} else if (is_boolean.value) {
model.value = false;
}
});
</script>
<template>
<input v-if="is_numeric" type="number" v-model="model" />
<input v-else-if="is_boolean" type="checkbox" v-model="model" />
<span v-else>UNKNOWN INPUT</span>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,36 +1,22 @@
<script setup lang="ts">
import type { CommandParameterDefinition } from '@/composables/command.ts';
import { computed, onMounted } from 'vue';
import { type DynamicDataType } from '@/composables/dynamic.ts';
import CommandInput from '@/components/CommandInput.vue';
const props = defineProps<{
defineProps<{
parameter: CommandParameterDefinition;
}>();
const model = defineModel<any>(); // eslint-disable-line @typescript-eslint/no-explicit-any
const is_numeric = computed(() => {
return ['Float32', 'Float64'].some((x) => x == props.parameter.data_type);
});
const is_boolean = computed(() => {
return 'Boolean' == props.parameter.data_type;
});
onMounted(() => {
if (is_numeric.value) {
model.value = 0.0;
} else if (is_boolean.value) {
model.value = false;
}
});
const model = defineModel<DynamicDataType>();
</script>
<template>
<div class="row">
<label> {{ parameter.name }} </label>
<input v-if="is_numeric" type="number" v-model="model" />
<input v-else-if="is_boolean" type="checkbox" v-model="model" />
<span v-else>UNKNOWN INPUT</span>
<CommandInput
:type="parameter.data_type"
v-model="model"
></CommandInput>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { type CommandParameterData } from '@/composables/dynamic.ts';
import { type CommandParameterDefinition } from '@/composables/command.ts';
import CommandInput from '@/components/CommandInput.vue';
defineProps<{
param: CommandParameterDefinition;
}>();
const model = defineModel<CommandParameterData>({
required: true,
});
</script>
<template>
<div v-if="model.type == 'constant'" class="row">
<label>Value:</label>
<CommandInput
:type="param.data_type"
v-model="model.value"
></CommandInput>
</div>
<div v-if="model.type == 'input'" class="row">
<label>ID:</label>
<input type="text" v-model="model.id" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import {
type CommandParameterData,
type DynamicDataType,
isBooleanType,
isNumericType,
} from '@/composables/dynamic.ts';
import { useCommand } from '@/composables/command.ts';
import { watch } from 'vue';
import FlexDivider from '@/components/FlexDivider.vue';
import CommandParameterDataConfigurator from '@/components/CommandParameterDataConfigurator.vue';
const props = defineProps<{
command_name: string;
}>();
const model = defineModel<{ [key: string]: CommandParameterData }>({
required: true,
});
const { data: command_info } = useCommand(props.command_name);
watch([command_info], ([cmd_info]) => {
if (cmd_info == null) {
return;
}
const model_value = model.value;
for (const key in model_value) {
const is_valid_param = cmd_info.parameters.some(
(param) => param.name == key,
);
if (!is_valid_param) {
delete model_value[key];
}
}
for (const param of cmd_info.parameters) {
let model_param_value: CommandParameterData | undefined =
model_value[param.name];
if (model_param_value) {
switch (model_param_value.type) {
case 'constant':
if (
typeof model_param_value.value == 'number' &&
!isNumericType(param.data_type)
) {
model_param_value = undefined;
} else if (
typeof model_param_value.value == 'boolean' &&
!isBooleanType(param.data_type)
) {
model_param_value = undefined;
}
break;
case 'input':
// Nothing to do
break;
}
}
if (!model_param_value) {
let default_value: DynamicDataType = 0;
if (isNumericType(param.data_type)) {
default_value = 0;
} else if (isBooleanType(param.data_type)) {
default_value = false;
}
model_param_value = {
type: 'constant',
value: default_value,
};
}
model_value[param.name] = model_param_value;
}
model.value = model_value;
});
</script>
<template>
<template v-if="command_info">
<template v-for="param in command_info.parameters" :key="param.name">
<FlexDivider></FlexDivider>
<div class="row">
<label>{{ param.name }}</label>
<select v-model="model[param.name].type">
<option value="constant">Constant</option>
<option value="input">Input</option>
</select>
</div>
<CommandParameterDataConfigurator
:param="param"
v-model="model[param.name]"
></CommandParameterDataConfigurator>
</template>
</template>
<template v-else>
<span> Loading... </span>
</template>
</template>
<style scoped lang="scss"></style>

View File

@@ -3,12 +3,13 @@ import type { CommandDefinition } from '@/composables/command.ts';
import { ref } from 'vue';
import CommandParameter from '@/components/CommandParameter.vue';
import FlexDivider from '@/components/FlexDivider.vue';
import type { DynamicDataType } from '@/composables/dynamic.ts';
const props = defineProps<{
command: CommandDefinition | null;
}>();
const parameters = ref<any>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const parameters = ref<{ [key: string]: DynamicDataType }>({});
const busy = ref(false);
const result = ref('');

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, defineAsyncComponent } from 'vue';
import {
AnyTypes,
type CommandParameterData,
type DynamicDataType,
type OptionalDynamicComponentData,
} from '@/composables/dynamic.ts';
import { computed, defineAsyncComponent, inject, type Ref, ref } from 'vue';
import CommandParameterListConfigurator from '@/components/CommandParameterListConfigurator.vue';
const TelemetryValue = defineAsyncComponent(
() => import('@/components/TelemetryValue.vue'),
@@ -8,6 +14,9 @@ const TelemetryValue = defineAsyncComponent(
const GridLayout = defineAsyncComponent(
() => import('@/components/layout/GridLayout.vue'),
);
const CommandInput = defineAsyncComponent(
() => import('@/components/CommandInput.vue'),
);
const model = defineModel<OptionalDynamicComponentData>('data', {
required: true,
@@ -19,13 +28,26 @@ const props = defineProps<{
editable: boolean;
}>();
const busy = ref(false);
// Provide a fallback option
const inputs = inject<Ref<{ [id: string]: DynamicDataType }>>(
'inputs',
ref({}),
);
const thisSymbol = Symbol();
const isSelected = computed(() => {
return selection.value == thisSymbol && props.editable;
});
function selectThis() {
function selectThis(e: Event) {
if (props.editable) {
// Only do this when we are editable
e.stopPropagation();
e.preventDefault();
}
selection.value = thisSymbol;
}
@@ -100,6 +122,54 @@ function deleteColumn() {
model.value = grid;
}
}
function makeInput() {
model.value = {
type: 'input',
id: [...Array(32)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join(''),
data_type: 'Float32',
};
}
function makeCommandButton() {
model.value = {
type: 'command_button',
text: 'Button Text',
command_name: '',
parameters: {},
};
}
async function sendCommand(command: {
command_name: string;
parameters: { [key: string]: CommandParameterData };
}) {
busy.value = true;
const params: { [key: string]: DynamicDataType } = {};
for (const param_name in command.parameters) {
const parameter = command.parameters[param_name];
switch (parameter.type) {
case 'constant':
params[param_name] = parameter.value;
break;
case 'input':
params[param_name] = inputs.value[parameter.id];
break;
}
}
await fetch(`/api/cmd/${command.command_name}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
busy.value = false;
}
</script>
<template>
@@ -123,20 +193,30 @@ function deleteColumn() {
<button v-if="model.type != 'grid'" @click.stop.prevent="makeGrid">
Make Grid
</button>
<button
v-if="model.type != 'input'"
@click.stop.prevent="makeInput"
>
Make Input
</button>
<button
v-if="model.type != 'command_button'"
@click.stop.prevent="makeCommandButton"
>
Make Command Button
</button>
</div>
</Teleport>
<template v-if="model.type == 'none'">
<!-- Intentionally Left Empty -->
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
@click="selectThis"
></span>
</template>
<template v-else-if="model.type == 'text'">
<span
:class="`${model.justify_right ? 'justify-right' : ''} ${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
@click="selectThis"
>
{{ model.text }}
</span>
@@ -155,7 +235,7 @@ function deleteColumn() {
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
@click="selectThis"
>
{{ '{' }} {{ model.data }} {{ '}' }}
</span>
@@ -163,7 +243,7 @@ function deleteColumn() {
v-else
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:data="model.data"
@click.stop.prevent="selectThis"
@click="selectThis"
></TelemetryValue>
<Teleport v-if="isSelected" to="#inspector">
<label>Telemetry Item: </label>
@@ -175,7 +255,7 @@ function deleteColumn() {
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:cols="model.columns"
:equal_col_width="model.equal_width"
@click.stop.prevent="selectThis"
@click="selectThis"
>
<template v-for="x in model.cells.length" :key="x">
<template v-for="y in model.columns" :key="y">
@@ -210,6 +290,60 @@ function deleteColumn() {
</div>
</Teleport>
</template>
<template v-else-if="model.type == 'input'">
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click="selectThis"
>
{{ '[' }} {{ model.id }} {{ ']' }}
</span>
<CommandInput
v-else
:type="model.data_type"
v-model="inputs[model.id]"
></CommandInput>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Input ID: </label>
<input v-model="model.id" />
</div>
<div class="row">
<label>Data Type: </label>
<select v-model="model.data_type">
<option v-for="type in AnyTypes" :key="type" :value="type">
{{ type }}
</option>
</select>
</div>
</Teleport>
</template>
<template v-else-if="model.type == 'command_button'">
<button
:disabled="busy"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="
(e) => (editable ? selectThis(e) : sendCommand(model as any))
"
>
{{ model.text }}
</button>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Button Text: </label>
<input v-model="model.text" />
</div>
<div class="row">
<label>Command: </label>
<input v-model="model.command_name" />
</div>
<CommandParameterListConfigurator
:key="model.command_name"
:command_name="model.command_name"
v-model="model.parameters"
></CommandParameterListConfigurator>
</Teleport>
</template>
<template v-else> ERROR: Unknown data: {{ model }} </template>
</template>

View File

@@ -23,6 +23,11 @@ import { AXIS_DATA, type AxisData } from '@/graph/axis';
import ValueLabel from '@/components/ValueLabel.vue';
import { type Point, PointLine } from '@/graph/line';
import TooltipDialog from '@/components/TooltipDialog.vue';
import {
type DynamicDataType,
isBooleanType,
isNumericType,
} from '@/composables/dynamic.ts';
const props = defineProps<{
data: string;
@@ -98,13 +103,9 @@ watch([value], ([val]) => {
if (val_t >= min_x) {
const raw_item_val = val.value[telemetry_data.value!.data_type];
let item_val = 0;
if (
['Float32', 'Float64'].some(
(e) => e == telemetry_data.value!.data_type,
)
) {
if (isNumericType(telemetry_data.value!.data_type)) {
item_val = raw_item_val as number;
} else if (telemetry_data.value!.data_type == 'Boolean') {
} else if (isBooleanType(telemetry_data.value!.data_type)) {
item_val = (raw_item_val as boolean) ? 1 : 0;
}
const new_item = {
@@ -140,15 +141,13 @@ watch(
const response = (await res.json()) as TelemetryDataItem[];
for (const data_item of response) {
const val_t = Date.parse(data_item.timestamp);
const raw_item_val = data_item.value[type];
const raw_item_val = data_item.value[
type
] as DynamicDataType;
let item_val = 0;
if (
['Float32', 'Float64'].some(
(e) => e == telemetry_data.value!.data_type,
)
) {
if (isNumericType(type)) {
item_val = raw_item_val as number;
} else if (type == 'Boolean') {
} else if (isBooleanType(type)) {
item_val = (raw_item_val as boolean) ? 1 : 0;
}
const new_item = {

View File

@@ -1,9 +1,10 @@
import { ref, toValue, watchEffect } from 'vue';
import { type MaybeRefOrGetter } from 'vue';
import type { AnyTypeId } from '@/composables/dynamic.ts';
export interface CommandParameterDefinition {
name: string;
data_type: string;
data_type: AnyTypeId;
}
export interface CommandDefinition {
@@ -30,7 +31,7 @@ export function useAllCommands() {
return { data, error };
}
export function ueCommand(name: MaybeRefOrGetter<string>) {
export function useCommand(name: MaybeRefOrGetter<string>) {
const data = ref<CommandDefinition | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = ref<any | null>(null);

View File

@@ -1,3 +1,29 @@
export const NumericTypes = ['Float32', 'Float64'] as const;
export type NumericTypeId = (typeof NumericTypes)[number];
export const BooleanTypes = ['Boolean'] as const;
export type BooleanTypeId = (typeof BooleanTypes)[number];
export const AnyTypes = [...NumericTypes, ...BooleanTypes] as const;
export type AnyTypeId = (typeof AnyTypes)[number];
export function isNumericType(type: AnyTypeId): type is NumericTypeId {
return NumericTypes.some((it) => it == type);
}
export function isBooleanType(type: AnyTypeId): type is BooleanTypeId {
return BooleanTypes.some((it) => it == type);
}
export type DynamicDataType = number | boolean;
export type CommandParameterData =
| {
type: 'constant';
value: DynamicDataType;
}
| {
type: 'input';
id: string;
};
export type DynamicComponentData =
| { type: 'text'; text: string; justify_right: boolean }
| { type: 'telemetry'; data: string }
@@ -6,6 +32,17 @@ export type DynamicComponentData =
columns: number;
equal_width: boolean;
cells: OptionalDynamicComponentData[][];
}
| {
type: 'input';
id: string;
data_type: AnyTypeId;
}
| {
type: 'command_button';
text: string;
command_name: string;
parameters: { [key: string]: CommandParameterData };
};
export type OptionalDynamicComponentData =

View File

@@ -1,10 +1,11 @@
import { ref, toValue, watchEffect } from 'vue';
import { type MaybeRefOrGetter } from 'vue';
import type { AnyTypeId } from '@/composables/dynamic.ts';
export interface TelemetryDefinition {
uuid: string;
name: string;
data_type: string;
data_type: AnyTypeId;
}
export function useAllTelemetry() {

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import DynamicComponent from '@/components/DynamicComponent.vue';
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, ref, watchEffect } from 'vue';
import type {
DynamicDataType,
OptionalDynamicComponentData,
} from '@/composables/dynamic.ts';
import { computed, provide, ref, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
@@ -12,6 +15,10 @@ const panel = ref<OptionalDynamicComponentData>({
type: 'none',
});
const inputs = ref<{ [id: string]: DynamicDataType }>({});
provide('inputs', inputs);
watchEffect(async () => {
const panel_data = await fetch(`/api/panel/${id.value}`);
const panel_json_value = await panel_data.json();