Implement Commanding (#6)

Reviewed-on: #6
Co-authored-by: Sergey Savelyev <sergeysav.nn@gmail.com>
Co-committed-by: Sergey Savelyev <sergeysav.nn@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
2025-12-28 13:39:12 -08:00
committed by sergeysav
parent 8cfaf468e9
commit f658b55586
33 changed files with 1389 additions and 98 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

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { type CommandDefinition, useAllCommands } from '@/composables/command';
import { computed } from 'vue';
const props = defineProps<{
search?: string;
}>();
const selected = defineModel<CommandDefinition | null>();
const search_value = computed(() => (props.search || '').toLowerCase());
const { data: command_data } = useAllCommands();
const sorted_cmd_data = computed(() => {
const cmd_data = command_data.value;
if (cmd_data != null) {
return cmd_data
.filter((entry) =>
entry.name.toLowerCase().includes(search_value.value),
)
.sort((a, b) => a.name.localeCompare(b.name));
}
return [];
});
function onClick(cmd_entry: CommandDefinition) {
selected.value = cmd_entry;
}
</script>
<template>
<template v-if="sorted_cmd_data.length > 0">
<div
v-for="cmd_entry in sorted_cmd_data"
:class="`row data ${selected?.name == cmd_entry.name ? 'selected' : ''}`"
:key="cmd_entry.name"
@click="() => onClick(cmd_entry)"
>
<span>
{{ cmd_entry.name }}
</span>
</div>
</template>
<template v-else>
<div class="row">
<span> No Matches Found </span>
</div>
</template>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
div {
padding: 0.3em;
border: 0;
border-bottom: variables.$gray-3 solid 1px;
border-top: 0;
}
.data.selected:has(~ .data:hover),
.data:hover ~ .data.selected {
background-color: variables.$light-background-color;
}
.data.selected,
.data:hover {
background-color: variables.$light2-background-color;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { CommandParameterDefinition } from '@/composables/command.ts';
import { type DynamicDataType } from '@/composables/dynamic.ts';
import CommandInput from '@/components/CommandInput.vue';
defineProps<{
parameter: CommandParameterDefinition;
}>();
const model = defineModel<DynamicDataType>();
</script>
<template>
<div class="row">
<label> {{ parameter.name }} </label>
<CommandInput
:type="parameter.data_type"
v-model="model"
></CommandInput>
</div>
</template>
<style scoped lang="scss"></style>

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

@@ -0,0 +1,68 @@
<script setup lang="ts">
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<{ [key: string]: DynamicDataType }>({});
const busy = ref(false);
const result = ref('');
async function sendCommand() {
const command = props.command;
const params = parameters.value;
if (!command) {
return;
}
busy.value = true;
result.value = 'Loading...';
const response = await fetch(`/api/cmd/${command.name}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (response.ok) {
result.value = await response.json();
} else {
result.value = await response.text();
}
busy.value = false;
}
</script>
<template>
<div class="row" v-if="!command">
<span> No Command Selected </span>
</div>
<template v-else>
<div class="row">
<span>
{{ command.name }}
</span>
</div>
<FlexDivider></FlexDivider>
<CommandParameter
v-for="param in command.parameters"
:key="param.name"
:parameter="param"
v-model="parameters[param.name]"
></CommandParameter>
<div class="row">
<button :disabled="busy" @click.stop.prevent="sendCommand">
Send
</button>
</div>
<div class="row shrink grow"></div>
<div class="row">{{ result }}</div>
</template>
</template>
<style scoped lang="scss"></style>

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 = {