264 lines
6.6 KiB
Vue
264 lines
6.6 KiB
Vue
<script setup>
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import MQTTService from '../../services/mqtt.js'
|
|
|
|
import Accordion from 'primevue/accordion';
|
|
import AccordionPanel from 'primevue/accordionpanel';
|
|
import AccordionHeader from 'primevue/accordionheader';
|
|
import AccordionContent from 'primevue/accordioncontent';
|
|
import Dialog from 'primevue/dialog'
|
|
|
|
import { JsonForms } from "@jsonforms/vue";
|
|
import { createAjv } from '@jsonforms/core'
|
|
import { primeVueRenderers } from 'jsonforms-primevue'
|
|
|
|
import { useToast } from 'primevue/usetoast'
|
|
|
|
const toast = useToast()
|
|
|
|
const renderers = Object.freeze([
|
|
...primeVueRenderers,
|
|
// here you can add custom renderers
|
|
]);
|
|
|
|
const props = defineProps({
|
|
deviceId: String
|
|
})
|
|
|
|
const mqtt2 = new MQTTService()
|
|
|
|
const filters = ref({})
|
|
const commands = ref([])
|
|
const commandMap = reactive({})
|
|
const commandByResponseId = reactive({})
|
|
|
|
const defaultsAjv = createAjv({ useDefaults: true })
|
|
// Suppress Ajv unknown format warning by registering a no-op validator for "textarea"
|
|
defaultsAjv.addFormat('textarea', true)
|
|
|
|
function rebuildList() {
|
|
commands.value = Object.values(commandMap)
|
|
}
|
|
|
|
function updateCommand(device, name, field, value) {
|
|
if (!commandMap[name]) {
|
|
commandMap[name] = {
|
|
name,
|
|
description: "No description provided.",
|
|
schema: {},
|
|
input: {},
|
|
response: null
|
|
}
|
|
}
|
|
|
|
commandMap[name][field] = value
|
|
rebuildList()
|
|
}
|
|
|
|
function sendCommand(cmd) {
|
|
cmd.responseId = null
|
|
cmd.response = {}
|
|
|
|
const topic = `device/${props.deviceId}/command/${cmd.name}`
|
|
var payload = JSON.stringify(cmd.input, null, 2)
|
|
|
|
var responseId = generateId()
|
|
commandByResponseId[responseId] = cmd
|
|
|
|
mqtt2.publish(topic, payload || '{}', {
|
|
qos: 1,
|
|
properties: {
|
|
responseTopic: `client/${mqtt2.clientId}/responses`,
|
|
correlationData: new TextEncoder().encode(responseId)
|
|
}
|
|
})
|
|
}
|
|
|
|
function generateId() {
|
|
return Math.random().toString(16).substr(2, 8)
|
|
}
|
|
|
|
const schemaDialog = reactive({
|
|
visible: false,
|
|
content: ''
|
|
})
|
|
|
|
function showSchema(cmd) {
|
|
try {
|
|
schemaDialog.content = JSON.stringify(cmd.schema, null, 2)
|
|
} catch {
|
|
schemaDialog.content = cmd.schema || 'No schema available'
|
|
}
|
|
schemaDialog.visible = true
|
|
}
|
|
|
|
onMounted(() => {
|
|
|
|
mqtt2.subscribe('device/+/command/#', (payload, topic) => {
|
|
|
|
const parts = topic.split('/')
|
|
|
|
const device = parts[1]
|
|
if (device !== props.deviceId) return
|
|
|
|
const command = parts[3]
|
|
const field = parts[4]
|
|
|
|
if (field === '$description') {
|
|
updateCommand(device, command, 'description', payload)
|
|
}
|
|
if (field === '$schema') {
|
|
const schema = JSON.parse(payload)
|
|
updateCommand(device, command, 'schema', schema)
|
|
}
|
|
|
|
})
|
|
|
|
mqtt2.subscribe(`client/${mqtt2.clientId}/responses`, (payload, topic) => {
|
|
let response = JSON.parse(payload)
|
|
|
|
const responseId = response.correlation
|
|
const cmd = commandByResponseId[responseId]
|
|
|
|
if (cmd) {
|
|
cmd.response = response
|
|
|
|
toast.add({
|
|
severity: response.success ? 'success' : 'error',
|
|
summary: cmd.name,
|
|
detail: response.message !== 'None' ? response.message : (response.success ? 'Success' : 'Failed'),
|
|
life: 4000
|
|
})
|
|
|
|
delete commandByResponseId[responseId]
|
|
}
|
|
})
|
|
})
|
|
|
|
const jsonFormsConfig = {
|
|
validationMode: 'ValidateOnTouched',
|
|
showAllErrors: false,
|
|
showErrorsOnTouched: true,
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog
|
|
v-model:visible="schemaDialog.visible"
|
|
header="Command Schema"
|
|
:modal="true"
|
|
:closable="true"
|
|
:style="{width: '700px'}"
|
|
>
|
|
<pre>{{ schemaDialog.content }}</pre>
|
|
</Dialog>
|
|
<div class="layout-card col-item-2">
|
|
<DataView :value="commands">
|
|
<template #header>
|
|
<div class="flex products-header">
|
|
<span class="products-title">Commands</span>
|
|
<IconField class="search-field">
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText v-model="filters['global']" placeholder="Filter by..." />
|
|
</IconField>
|
|
</div>
|
|
</template>
|
|
<template #empty> No commands available.</template>
|
|
<template #list="slotProps">
|
|
<Accordion >
|
|
|
|
<AccordionPanel
|
|
v-for="cmd in slotProps.items"
|
|
:key="cmd.name"
|
|
:value="cmd.name"
|
|
>
|
|
|
|
<AccordionHeader>
|
|
<div class="command-header">
|
|
<span class="command-name">{{ cmd.name }}</span>
|
|
<span class="command-desc">{{ cmd.description }}</span>
|
|
</div>
|
|
</AccordionHeader>
|
|
<AccordionContent>
|
|
<div class="command-content">
|
|
<!-- Left: Code editor -->
|
|
<JsonForms
|
|
:data="cmd.input"
|
|
:schema="cmd.schema"
|
|
:renderers="renderers"
|
|
:config="jsonFormsConfig"
|
|
:onChange="({ data, errors }) => cmd.input = data"
|
|
:ajv="defaultsAjv"
|
|
/>
|
|
|
|
<!-- Right: Buttons -->
|
|
<div class="button-container">
|
|
<Button
|
|
v-if="cmd.response"
|
|
:icon="cmd.response.success ? 'pi pi-check' : 'pi pi-times'"
|
|
class="p-button-rounded p-button-text"
|
|
:class="cmd.response.success ? 'p-button-success' : 'p-button-danger'"
|
|
v-tooltip="cmd.response.message !== 'None' ? cmd.response.message : null"
|
|
/>
|
|
<Button
|
|
icon="pi pi-question-circle"
|
|
class="p-mb-2"
|
|
label="Help"
|
|
severity="secondary"
|
|
@click="showSchema(cmd)"
|
|
tooltip="Show schema"
|
|
/>
|
|
<Button
|
|
icon="pi pi-send"
|
|
label="Send"
|
|
@click="sendCommand(cmd)"
|
|
tooltip="Send command"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
|
|
</AccordionPanel>
|
|
|
|
</Accordion>
|
|
</template>
|
|
</DataView>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
.command-header {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.command-name {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.command-desc {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
|
|
.command-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.editor-container {
|
|
flex: 1;
|
|
}
|
|
|
|
.button-container {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
</style> |