optimize subscription model bug fixes

This commit is contained in:
Adrian Zuercher
2025-07-12 23:29:44 +02:00
parent 5b905bac24
commit ffb8e4994e
22 changed files with 640 additions and 457 deletions

4
.gitignore vendored
View File

@@ -14,6 +14,7 @@ node_modules
/src-cordova/www /src-cordova/www
# Capacitor related directories and files # Capacitor related directories and files
/src-capacitor
/src-capacitor/www /src-capacitor/www
/src-capacitor/node_modules /src-capacitor/node_modules
@@ -38,3 +39,6 @@ yarn-error.log*
# local .log files # local .log files
*.log *.log
# golang quasar websever executable
backend/server-linux-arm64

View File

@@ -1,6 +1,6 @@
{ {
"name": "lightcontrol", "name": "lightcontrol",
"version": "0.0.17", "version": "0.0.18",
"description": "A Tecamino App", "description": "A Tecamino App",
"productName": "Light Control", "productName": "Light Control",
"author": "A. Zuercher", "author": "A. Zuercher",

View File

@@ -16,101 +16,56 @@
<template v-slot:[`default-header`]="props"> <template v-slot:[`default-header`]="props">
<div <div
class="row items-center text-blue" class="row items-center text-blue"
@contextmenu.prevent.stop="openContextMenu($event, props.node)" @contextmenu.prevent.stop="openSubMenu($event, props.node.key)"
> >
<div class="row items-center text-blue"></div> <div class="row items-center text-blue"></div>
<div>{{ props.node.path }}</div> <div>{{ props.node.path }}</div>
</div> </div>
<q-popup-edit
v-if="props.node.value !== undefined && props.node.value !== ''"
v-model="props.node.value"
class="q-ml-xl bg-grey text-white"
@save="(val) => onValueEdit(val, props.node)"
>
<template v-slot="scope">
<q-input
dark
color="white"
v-model="scope.value"
dense
autofocus
counter
@keyup.enter="scope.set"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</template>
</q-popup-edit>
</template> </template>
</q-tree> </q-tree>
<sub-menu :node="selectedNode"></sub-menu> <!-- not implemented yet <sub-menu ref="subMenuRef"></sub-menu> -->
</q-card-section> </q-card-section>
<DataTable :rows="Subscriptions" class="col-8" /> <dataTable class="col-8" :rows="getRows()" />
</div> </div>
</q-card> </q-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import DataTable from './dataTable.vue';
import type { TreeNode } from 'src/composables/dbm/dbmTree';
import { import {
dbmData, type TreeNode,
buildTree, buildTree,
getSubscriptionsByUuid, dbmData,
addChildrentoTree, removeSubtreeByParentKey,
getAllSubscriptions,
} from 'src/composables/dbm/dbmTree'; } from 'src/composables/dbm/dbmTree';
import DataTable from './DataTable.vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { openContextMenu } from 'src/composables/dbm/useContextMenu';
import { NotifyResponse } from 'src/composables/notify'; import { NotifyResponse } from 'src/composables/notify';
import SubMenu from 'src/components/dbm/SubMenu.vue';
import { QCard } from 'quasar'; import { QCard } from 'quasar';
import { subscribe, unsubscribe, setValues } from 'src/services/websocket'; import { subscribe, unsubscribe } from 'src/services/websocket';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
import { api } from 'boot/axios'; import { api } from 'boot/axios';
import type { Subs } from 'src/models/Subscribe'; // not implemented yet import SubMenu from './SubMenu.vue';
import { reactive } from 'vue'; import { type Subs, type RawSubs, convertToSubscribes } from 'src/models/Subscribe';
import { addSubscriptions, getRows, removeAllSubscriptions } from 'src/models/Subscriptions';
const $q = useQuasar(); const $q = useQuasar();
const expanded = ref<string[]>([]); const expanded = ref<string[]>([]);
const selectedNode = ref<TreeNode | null>(null);
const ZERO_UUID = '00000000-0000-0000-0000-000000000000'; const ZERO_UUID = '00000000-0000-0000-0000-000000000000';
const Subscriptions = reactive<Subs>([]);
let lastExpanded: string[] = []; let lastExpanded: string[] = [];
const postQuery = '/json_data';
onMounted(() => { onMounted(() => {
const payload = {
get: [
{
path: '.*',
query: { depth: 1 },
},
],
};
api api
.post('/json_data', payload) .post(postQuery, { get: [{ path: '.*', query: { depth: 1 } }] })
.then((res) => { .then((res) => {
if (res.data.get) { if (res.data.get) buildTree(convertToSubscribes(res.data.get as RawSubs));
dbmData.splice(0, dbmData.length, ...buildTree(res.data.get));
}
}) })
.catch((err) => { .catch((err) => NotifyResponse($q, err, 'error'));
NotifyResponse($q, err, 'error');
});
}); });
onBeforeRouteLeave(() => { onBeforeRouteLeave(() => {
unsubscribe([ unsubscribe([{ path: '.*', depth: 0 }]).catch((err) => NotifyResponse($q, err, 'error'));
{
path: '.*',
depth: 0,
},
]).catch((err) => {
NotifyResponse($q, err, 'error');
});
}); });
function onLazyLoad({ function onLazyLoad({
@@ -122,47 +77,11 @@ function onLazyLoad({
done: (children: TreeNode[]) => void; done: (children: TreeNode[]) => void;
fail: () => void; fail: () => void;
}): void { }): void {
//first unsubsrice nodes api
unsubscribe([ .post(postQuery, { get: [{ uuid: node.key ?? ZERO_UUID, path: '', query: { depth: 2 } }] })
{
path: '.*',
depth: 0,
},
])
.then(() => {
Subscriptions.length = 0;
})
.catch((err) => {
NotifyResponse($q, err, 'error');
});
// now subscribe nodes
subscribe([
{
uuid: node.key ?? ZERO_UUID,
path: '',
depth: 2,
},
])
.then((resp) => { .then((resp) => {
if (resp?.subscribe) { if (resp?.data.get) done(buildTree(convertToSubscribes(resp?.data.get as RawSubs)));
// Optional: update your internal store too else done([]); // no children returned
addChildrentoTree(resp?.subscribe);
const toRemove = new Set(
resp.subscribe.filter((sub) => sub.uuid !== ZERO_UUID).map((sub) => sub.uuid),
);
Subscriptions.splice(
0,
Subscriptions.length,
...getAllSubscriptions().filter((sub) => toRemove.has(sub.uuid)),
);
done(dbmData);
} else {
done([]); // no children returned
}
}) })
.catch((err) => { .catch((err) => {
NotifyResponse($q, err, 'error'); NotifyResponse($q, err, 'error');
@@ -170,87 +89,61 @@ function onLazyLoad({
}); });
} }
function onValueEdit(newValue: undefined, node: TreeNode) { async function onExpandedChange(newExpanded: readonly string[]) {
console.log(node.value, node.value === undefined);
if (!node.key) return;
const sub = getSubscriptionsByUuid(node.key);
if (sub) {
setValues([
{
path: sub.value?.path ?? '',
value: newValue,
},
]).catch((err) => {
NotifyResponse($q, err, 'error');
});
}
}
function onExpandedChange(newExpanded: readonly string[]) {
const collapsed = lastExpanded.filter((k) => !newExpanded.includes(k)); const collapsed = lastExpanded.filter((k) => !newExpanded.includes(k));
const newlyExpanded = newExpanded.filter((k) => !lastExpanded.includes(k)); const newlyExpanded = newExpanded.filter((k) => !lastExpanded.includes(k));
try {
await unsubscribe([{ path: '.*', depth: 0 }])
.then(() => {
removeAllSubscriptions();
})
.catch((err) => NotifyResponse($q, err, 'error'));
if (collapsed.length) { if (collapsed.length) {
collapsed.forEach((key: string) => { collapsed.forEach((key) => {
subscribe([ removeSubtreeByParentKey(key);
{ api
uuid: key, .post(postQuery, { get: [{ uuid: key, path: '', query: { depth: 2 } }] })
path: '',
depth: 2,
},
])
.then((resp) => { .then((resp) => {
if (resp?.subscribe) { if (resp?.data.get) {
// Optional: update your internal store too buildTree(resp?.data.get as Subs);
addChildrentoTree(resp?.subscribe); subscribe([{ uuid: key, path: '', depth: 2 }]).catch((err) =>
NotifyResponse($q, err, 'error'),
const toRemove = new Set(
resp.subscribe.filter((sub) => sub.uuid !== ZERO_UUID).map((sub) => sub.uuid),
);
Subscriptions.splice(
0,
Subscriptions.length,
...getAllSubscriptions().filter((sub) => toRemove.has(sub.uuid)),
); );
addSubscriptions(resp?.data.get as RawSubs);
} }
}) })
.catch((err) => { .catch((err) => NotifyResponse($q, err, 'error'));
NotifyResponse($q, err, 'error');
}); });
}); }
} else if (newlyExpanded.length) { if (newlyExpanded.length) {
newlyExpanded.forEach((key: string) => { newlyExpanded.forEach((key) => {
subscribe([ api
{ .post(postQuery, { get: [{ uuid: key, path: '', query: { depth: 2 } }] })
uuid: key,
path: '',
depth: 2,
},
])
.then((resp) => { .then((resp) => {
if (resp?.subscribe) { if (resp?.data.get) {
// Optional: update your internal store too buildTree(resp?.data.get as Subs);
addChildrentoTree(resp?.subscribe); subscribe([{ uuid: key, path: '', depth: 2 }]).catch((err) =>
NotifyResponse($q, err, 'error'),
const toRemove = new Set(
resp.subscribe.filter((sub) => sub.uuid !== ZERO_UUID).map((sub) => sub.uuid),
);
Subscriptions.splice(
0,
Subscriptions.length,
...getAllSubscriptions().filter((sub) => toRemove.has(sub.uuid)),
); );
addSubscriptions(resp?.data.get as RawSubs);
} }
}) })
.catch((err) => { .catch((err) => NotifyResponse($q, err, 'error'));
NotifyResponse($q, err, 'error');
});
}); });
} }
lastExpanded = [...newExpanded]; lastExpanded = [...newExpanded];
} catch {
console.error('error in expand function');
}
}
const subMenuRef = ref();
function openSubMenu(event: MouseEvent, uuid: string) {
subMenuRef.value?.open(event, uuid);
} }
</script> </script>

View File

@@ -4,50 +4,49 @@
<q-item clickable v-close-popup @click="handleAction('Add')"> <q-item clickable v-close-popup @click="handleAction('Add')">
<q-item-section>Add Datapoint</q-item-section> <q-item-section>Add Datapoint</q-item-section>
</q-item> </q-item>
<q-item clickable v-close-popup @click="handleAction('Delete')"> <q-item
:class="disable ? 'text-grey-5' : ''"
:clickable="!disable"
v-close-popup
@click="handleAction('Delete')"
>
<q-item-section>Delete Datapoint</q-item-section> <q-item-section>Delete Datapoint</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-menu> </q-menu>
<AddDialog :dialogLabel="label" width="700px" button-ok-label="Add" ref="addDialog" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
//import { useQuasar } from 'quasar'; import AddDialog from 'src/components/dialog/AddDatapoint.vue';
//import { NotifyResponse, NotifyError } from 'src/composables/notify'; import { ref } from 'vue';
import { contextMenuState, contextMenuRef } from 'src/composables/dbm/useContextMenu';
//import AddDatapoint from 'src/components/dbm/AddDatapoint.vue';
//import { send } from 'src/services/websocket';
//const $q = useQuasar(); const ZERO_UUID = '00000000-0000-0000-0000-000000000000';
const addDialog = ref();
const datapointUuid = ref('');
const contextMenuRef = ref();
const label = ref('');
const disable = ref(false);
function handleAction(action: string) { function handleAction(action: string) {
console.log(`Action '${action}' on node:`, contextMenuState.value);
// Add your actual logic here
switch (action) { switch (action) {
case 'Add': case 'Add':
// send({ label.value = 'Add New Datapoint';
// set: [ addDialog.value?.open(datapointUuid.value);
// { break;
// uuid: contextMenuState.value?.key, case 'Delete':
// path: 'New', label.value = 'Remove Datapoint';
// type: 'BIT', addDialog.value?.open(datapointUuid.value);
// value: true, break;
// create: true,
// },
// ],
// })
// .then((response) => {
// if (response?.set) {
// console.log(response);
// } else {
// NotifyResponse($q, response);
// }
// })
// .catch((err) => {
// NotifyError($q, err);
// });
console.log(4);
} }
} }
const open = (event: MouseEvent, uuid: string) => {
if (uuid === ZERO_UUID) disable.value = true;
event.preventDefault();
datapointUuid.value = uuid;
contextMenuRef.value?.show(event);
};
defineExpose({ open });
</script> </script>

View File

@@ -1,13 +0,0 @@
<template>
<QCard v-if="props.display"> Test </QCard>
</template>
<script setup lang="ts">
const props = defineProps({
display: {
type: Boolean,
default: true,
required: true,
},
});
</script>

View File

@@ -14,21 +14,42 @@
> >
<template v-slot:body-cell-value="props"> <template v-slot:body-cell-value="props">
<q-td :props="props" @click="openDialog(props.row)"> <q-td :props="props" @click="openDialog(props.row)">
<span :class="['cursor-pointer', open ? 'text-green' : '']"> {{ props.row.value }}</span> <div :class="['cursor-pointer', 'q-mx-sm']">
{{ props.row.value }}
</div>
</q-td>
</template>
<template v-slot:body-cell-drivers="props">
<q-td :props="props" @click="openDialog(props.row, 'driver')">
<div v-if="props.row.type !== 'none'" :class="['cursor-pointer']">
<q-icon size="sm" name="cell_tower" :color="props.row.drivers ? 'blue-5' : 'grey-4'" />
</div>
</q-td> </q-td>
</template> </template>
</q-table> </q-table>
<Dialog dialogLabel="Update Value" :show-dialog="open" /> <UpdateDialog
dialogLabel="Update Value"
width="400px"
button-ok-label="Write"
ref="updateDialog"
v-model:datapoint="dialogValue"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'src/components/dialog/UpdateValueDialog.vue'; import UpdateDialog from 'src/components/dialog/UpdateValueDialog.vue';
import type { QTableProps } from 'quasar'; import type { QTableProps } from 'quasar';
import type { Subs, Subscribe } from 'src/models/Subscribe'; import type { Subscribe } from 'src/models/Subscribe';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { Subs } from 'src/models/Subscribe';
const open = ref(false); const updateDialog = ref();
const dialogValue = ref();
const openDialog = (sub: Subscribe, type?: string) => {
updateDialog.value?.open(sub, type);
};
// we generate lots of rows here // we generate lots of rows here
const props = defineProps<{ const props = defineProps<{
@@ -39,22 +60,23 @@ const tableRows = computed(() => [...props.rows]);
const columns = [ const columns = [
{ name: 'path', label: 'Path', field: 'path', align: 'left' }, { name: 'path', label: 'Path', field: 'path', align: 'left' },
{
name: 'type',
label: 'Type',
field: 'type',
align: 'left',
},
{ {
name: 'value', name: 'value',
label: 'Value', label: 'Value',
field: 'value', field: 'value',
align: 'left', align: 'left',
}, },
{ // {. not implemented yet
name: 'test', // name: 'drivers',
label: '', // label: 'Drivers',
field: 'test', // field: 'drivers',
align: 'left', // align: 'center',
}, // },
] as QTableProps['columns']; ] as QTableProps['columns'];
function openDialog(item: Subscribe) {
console.log(77, item);
open.value = true;
}
</script> </script>

View File

@@ -0,0 +1,101 @@
<template>
<DialogFrame ref="Dialog" :width="props.width">
<q-card-section
v-if="props.dialogLabel"
class="text-bold text-left q-mb-none q-pb-none"
:class="'text-' + props.labelColor"
>{{ props.dialogLabel }}
</q-card-section>
<q-form ref="addForm" @submit="onSubmit" class="q-gutter-md">
<q-input
class="q-pa-md"
filled
v-model="path"
label=""
:rules="[(val) => !!val || 'Path is required']"
>
<template #prepend>
<div class="column">
<span class="text-caption text-primary non-editable-prefix">Path *</span>
<span class="text-body2 text-grey-6 non-editable-prefix">{{ staticPrefix }}</span>
</div>
</template>
</q-input>
<DataTypes class="q-ma-md" flat></DataTypes>
<q-btn no-caps class="q-ma-lg" type="submit" color="primary">{{ props.buttonOkLabel }}</q-btn>
</q-form>
</DialogFrame>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { ref } from 'vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { api } from 'boot/axios';
import { NotifyResponse } from 'src/composables/notify';
import DataTypes from 'src/vueLib/buttons/DataTypes.vue';
//import datatype from 'src/vueLib/buttons/DataType.vue';
const $q = useQuasar();
const Dialog = ref();
const path = ref('');
const staticPrefix = ref('');
//const radio = ref('bool');
const addForm = ref();
const open = (uuid: string) => {
Dialog.value?.open();
getDatapoint(uuid);
};
function validate() {
addForm.value.validate().then((success: undefined) => {
if (success) {
console.log(909);
// yay, models are correct
} else {
console.log(910);
// oh no, user has filled in
// at least one invalid value
}
});
}
const props = defineProps({
buttonOkLabel: {
type: String,
default: 'OK',
},
labelColor: {
type: String,
default: 'primary',
},
dialogLabel: {
type: String,
default: '',
},
width: {
type: String,
default: '300px',
},
});
function onSubmit() {
validate();
console.log('submit', props);
}
function getDatapoint(uuid: string) {
api
.post('/json_data', { get: [{ uuid: uuid, query: { depth: 1 } }] })
.then((resp) => {
if (resp.data.get) {
staticPrefix.value = resp.data.get[0].path;
if (staticPrefix.value !== '') staticPrefix.value += ':';
}
})
.catch((err) => NotifyResponse($q, err, 'error'));
}
defineExpose({ open });
</script>

View File

@@ -1,42 +1,61 @@
<template> <template>
<q-dialog v-model="internalShowDialog"> <DialogFrame ref="Dialog" :width="props.width">
<q-card :style="'width:' + props.width">
<q-card-section <q-card-section
v-if="props.dialogLabel" v-if="props.dialogLabel"
class="text-h6 text-center" class="text-bold text-left q-mb-none q-pb-none"
:class="'text-' + props.labelColor" :class="'text-' + props.labelColor"
>{{ props.dialogLabel }}</q-card-section >{{ props.dialogLabel + ': ' + props.datapoint?.path }}</q-card-section
> >
<q-card-section> <q-card-section>
<q-input v-model="inputValue"></q-input> <q-input
class="q-px-md q-ma-sm"
label="current value"
dense
filled
readonly
v-model="inputValue as string | number"
></q-input>
<q-input
class="q-px-md q-mx-sm"
label="new value"
dense
filled
@keyup.enter="write"
v-model="writeValue as string | number"
></q-input>
</q-card-section> </q-card-section>
<q-card-section v-if="props.text" class="text-center" style="white-space: pre-line">{{ <q-card-section v-if="props.text" class="text-center" style="white-space: pre-line">{{
props.text props.text
}}</q-card-section> }}</q-card-section>
<q-card-actions align="right" class="text-primary"> <q-card-actions align="left" class="text-primary">
<q-btn v-if="props.buttonCancelLabel" flat :label="props.buttonCancelLabel" v-close-popup> <q-btn v-if="props.buttonCancelLabel" flat :label="props.buttonCancelLabel" v-close-popup>
</q-btn> </q-btn>
<q-btn <q-btn
class="q-mb-xl q-ml-lg q-mt-none"
v-if="props.buttonOkLabel" v-if="props.buttonOkLabel"
flat color="primary"
:label="props.buttonOkLabel" :label="props.buttonOkLabel"
v-close-popup @click="write"
@click="closeDialog"
> >
</q-btn> </q-btn>
</q-card-actions> </q-card-actions>
</q-card> </DialogFrame>
</q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import type { Subscribe } from 'src/models/Subscribe';
import type { PropType } from 'vue';
import { setValues } from 'src/services/websocket';
import { NotifyResponse } from 'src/composables/notify';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const Dialog = ref();
const writeValue = ref();
const props = defineProps({ const props = defineProps({
showDialog: {
type: Boolean,
required: true,
},
buttonOkLabel: { buttonOkLabel: {
type: String, type: String,
default: 'OK', default: 'OK',
@@ -61,41 +80,47 @@ const props = defineProps({
type: String, type: String,
default: '300px', default: '300px',
}, },
value: { datapoint: Object as PropType<Subscribe>,
type: [String, Number],
},
}); });
const inputValue = ref(props.value); const inputValue = ref(props.datapoint?.value);
const emit = defineEmits(['update:showDialog', 'update:value', 'confirmed', 'cancel']); const open = (sub: Subscribe, type?: string) => {
const internalShowDialog = ref(props.showDialog); Dialog.value?.open();
switch (type) {
watch(inputValue, (val) => { case 'driver':
emit('update:value', val); console.log(9, sub.drivers);
}); writeValue.value = sub.drivers;
break;
default:
writeValue.value = sub.value;
}
};
watch( watch(
() => props.showDialog, () => props.datapoint?.value,
(newValue) => { (newVal) => {
console.log('watch showDialog', newValue); inputValue.value = newVal;
internalShowDialog.value = newValue;
}, },
); );
watch(internalShowDialog, (newValue) => {
console.log('watch internalShowDialog', newValue);
emit('update:showDialog', newValue);
if (!newValue) {
console.log('emit cancel');
emit('cancel');
} else {
console.log('emit confirmed');
emit('confirmed');
}
});
function closeDialog() { function write() {
internalShowDialog.value = false; setValues([{ uuid: props.datapoint?.uuid ?? '', value: writeValue.value ?? undefined }])
emit('update:showDialog', false); .then((resp) => {
if (resp?.set) {
resp.set.forEach((set) => {
inputValue.value = set.value;
});
} }
})
.catch((err) => NotifyResponse($q, err));
}
defineExpose({ open });
</script> </script>
<style scoped>
.outercard {
border-radius: 10px;
}
</style>

View File

@@ -69,43 +69,30 @@
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import LightSlider from './LightSlider.vue'; import LightSlider from './LightSlider.vue';
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import { subscribe, unsubscribe } from 'src/services/websocket'; import { unsubscribe, subscribeToPath } from 'src/services/websocket';
import SettingDialog from 'src/components/lights/SettingDomeLight.vue'; import SettingDialog from 'src/components/lights/SettingDomeLight.vue';
import { NotifyResponse } from 'src/composables/notify'; import { NotifyResponse } from 'src/composables/notify';
import { updateValue, buildTree, dbmData } from 'src/composables/dbm/dbmTree'; import { updateValue } from 'src/composables/dbm/dbmTree';
import { removeAllSubscriptions } from 'src/models/Subscriptions';
const $q = useQuasar(); const $q = useQuasar();
const settings = ref(false); const settings = ref(false);
const brightness = updateValue('LightBar:Brightness', $q); const brightness = updateValue('LightBar:Brightness', $q);
const state = updateValue('LightBar:State', $q); const state = updateValue('LightBar:State', $q);
onMounted(() => { onMounted(() => {
subscribe([ subscribeToPath($q, 'LightBar:.*');
{
path: 'LightBar:.*',
depth: 0,
},
])
.then((response) => {
if (response?.subscribe) {
dbmData.splice(0, dbmData.length, ...buildTree(response.subscribe));
} else {
NotifyResponse($q, response);
}
})
.catch((err) => {
NotifyResponse($q, err, 'error');
});
}); });
onUnmounted(() => { onUnmounted(() => {
unsubscribe([ unsubscribe([
{ {
path: '.*', path: 'LightBar',
depth: 0, depth: 0,
}, },
]).catch((err) => { ]).catch((err) => {
NotifyResponse($q, err, 'error'); NotifyResponse($q, err, 'error');
}); });
removeAllSubscriptions();
}); });
function changeState() { function changeState() {

View File

@@ -107,10 +107,11 @@ import { NotifyResponse } from 'src/composables/notify';
import { onBeforeUpdate, computed, onMounted, onUnmounted, ref } from 'vue'; import { onBeforeUpdate, computed, onMounted, onUnmounted, ref } from 'vue';
import { subscribeToPath, unsubscribe, setValues } from 'src/services/websocket'; import { subscribeToPath, unsubscribe, setValues } from 'src/services/websocket';
import { LocalStorage } from 'quasar'; import { LocalStorage } from 'quasar';
import { getSubscriptionsByPath, updateValue } from 'src/composables/dbm/dbmTree'; import { updateValue } from 'src/composables/dbm/dbmTree';
import DragPad from 'src/components/lights/DragPad.vue'; import DragPad from 'src/components/lights/DragPad.vue';
import SettingDialog from './SettingMovingHead.vue'; import SettingDialog from './SettingMovingHead.vue';
import type { Settings } from 'src/models/MovingHead'; import type { Settings } from 'src/models/MovingHead';
import { findSubscriptionByPath, removeAllSubscriptions } from 'src/models/Subscriptions';
const $q = useQuasar(); const $q = useQuasar();
const brightness = updateBrightnessValue('MovingHead:Brightness'); const brightness = updateBrightnessValue('MovingHead:Brightness');
@@ -141,31 +142,29 @@ onUnmounted(() => {
]).catch((err) => { ]).catch((err) => {
NotifyResponse($q, err, 'error'); NotifyResponse($q, err, 'error');
}); });
removeAllSubscriptions();
}); });
function changeState() { function changeState() {
console.log(55, brightness.value);
console.log(56, state.value);
if (brightness.value === 0) { if (brightness.value === 0) {
if (state.value === 0) { if (state.value === 0) {
brightness.value = 255; brightness.value = 255;
return; return;
} }
brightness.value = state.value; brightness.value = state.value;
console.log(57, brightness.value);
return; return;
} }
state.value = brightness.value; state.value = brightness.value;
console.log(58, state.value);
brightness.value = 0; brightness.value = 0;
} }
function updateBrightnessValue(path: string) { function updateBrightnessValue(path: string) {
return computed({ return computed({
get() { get() {
const sub = getSubscriptionsByPath(path); const sub = findSubscriptionByPath(path);
if (!sub) return 0;
if (!sub.value) return 0; if (!sub.value) return 0;
return Number(sub.value.value); return Number(sub.value);
}, },
set(val) { set(val) {
const setPaths = [{ path, value: val }]; const setPaths = [{ path, value: val }];

View File

@@ -1,110 +1,76 @@
import type { Subs, Subscribe } from 'src/models/Subscribe'; import { reactive, computed, type Ref } from 'vue';
import type { Ref } from 'vue';
import { nextTick, computed, reactive, ref } from 'vue';
import { setValues } from 'src/services/websocket';
import { NotifyResponse } from 'src/composables/notify';
import type { QVueGlobals } from 'quasar'; import type { QVueGlobals } from 'quasar';
import type { Subs } from 'src/models/Subscribe';
const Subscriptions = reactive<Record<string, Subscribe>>({}); import { NotifyResponse } from '../notify';
import { setValues } from 'src/services/websocket';
import { findSubscriptionByPath } from 'src/models/Subscriptions';
export const dbmData = reactive<TreeNode[]>([]); export const dbmData = reactive<TreeNode[]>([]);
//export const reactiveValues = new Map<string, Ref<string | number | boolean | null>>();
export interface TreeNode { export type TreeNode = {
path: string | undefined; path: string | undefined;
key?: string; // optional: useful for QTree's node-key key?: string; // optional: useful for QTree's node-key
value?: string | number | boolean | undefined;
lazy: boolean; lazy: boolean;
children?: TreeNode[]; children?: TreeNode[];
} };
export function buildTree(subs: Subs): TreeNode[] {
type TreeMap = { type TreeMap = {
[key: string]: { [key: string]: {
__children: TreeMap; __children: TreeMap;
uuid?: string; uuid?: string;
value?: string | undefined; value?: string | number | boolean | null;
lazy: boolean; lazy: boolean;
}; };
}; };
const root: TreeMap = {}; const root: TreeMap = {};
for (const item of subs) { export function buildTree(subs: Subs): TreeNode[] {
if (item.path) { for (const { path, uuid, value, hasChild } of subs) {
addNewSubscription(item); if (!path) continue;
}
const pathParts = item.path?.split(':') ?? []; const parts = path.split(':');
let current = root; let current = root;
for (let i = 0; i < pathParts.length; i++) { parts.forEach((part, idx) => {
const part = pathParts[i]; if (!part) return;
if (!part) continue;
if (!current[part]) { if (!current[part]) {
current[part] = { __children: {}, lazy: true }; current[part] = { __children: {}, lazy: true };
} }
// Optionally attach uuid only at the final part if (idx === parts.length - 1 && uuid) {
if (i === pathParts.length - 1 && item.uuid) { if (current[part].uuid === uuid) return;
current[part].uuid = item.uuid;
current[part].value = item.value !== undefined ? String(item.value) : ''; current[part].uuid = uuid;
current[part].lazy = item.hasChild ?? false; current[part].value = value?.value ?? null;
} current[part].lazy = hasChild ?? false;
current = current[part].__children;
}
} }
function convert(map: TreeMap): TreeNode[] { current = current[part].__children;
return reactive( });
Object.entries(map).map(([path, node]) => ({ }
path,
key: node.uuid ?? path, // `key` is used by QTree function mapToTree(map: TreeMap): TreeNode[] {
return Object.entries(map).map(([key, node]) => ({
path: key,
key: node.uuid ?? key,
value: node.value, value: node.value,
lazy: node.lazy, lazy: node.lazy,
children: convert(node.__children), children: mapToTree(node.__children),
})), }));
);
} }
return [ const newTree = [
{ {
path: 'DBM', path: 'DBM',
key: '00000000-0000-0000-0000-000000000000', key: '00000000-0000-0000-0000-000000000000',
lazy: true, lazy: true,
children: convert(root), children: mapToTree(root),
}, },
]; ];
} return dbmData.splice(0, dbmData.length, ...newTree);
export function getTreeElementByPath(path: string) {
const sub = dbmData.find((s) => s.path === path);
return ref(sub);
}
export function getSubscriptionsByUuid(uid: string) {
const sub = Object.values(Subscriptions).find((sub) => sub.uuid === uid);
return ref(sub);
}
export function addChildrentoTree(subs: Subs) {
const ZERO_UUID = '00000000-0000-0000-0000-000000000000';
const existingIds = new Set(Object.values(Subscriptions).map((sub) => sub.uuid));
const newSubs = subs
.filter((sub) => sub.uuid !== ZERO_UUID) // Skip UUIDs with all zeroes
.filter((sub) => !existingIds.has(sub.uuid));
for (const sub of newSubs) {
if (sub.path !== undefined) {
Subscriptions[sub.path] = sub;
} else {
console.warn('Skipping sub with undefined path', sub);
}
}
void nextTick(() => {
dbmData.splice(0, dbmData.length, ...buildTree(Object.values(Subscriptions)));
});
} }
export function removeSubtreeByParentKey(parentKey: string) { export function removeSubtreeByParentKey(parentKey: string) {
@@ -122,23 +88,9 @@ export function removeSubtreeByParentKey(parentKey: string) {
} }
return false; return false;
} }
removeChildrenAndMarkLazy(dbmData, parentKey); removeChildrenAndMarkLazy(dbmData, parentKey);
} }
export function getSubscriptionsByPath(path: string) {
return ref(Subscriptions[path]);
}
export function addNewSubscription(sub: Subscribe) {
if (!sub.path) return;
Subscriptions[sub.path] = sub;
}
export function getAllSubscriptions() {
return Object.values(Subscriptions);
}
export function updateValue( export function updateValue(
path1: string, path1: string,
$q: QVueGlobals, $q: QVueGlobals,
@@ -149,8 +101,8 @@ export function updateValue(
) { ) {
return computed({ return computed({
get() { get() {
const sub = getSubscriptionsByPath(toggle?.value && path2 ? path2 : path1); const sub = findSubscriptionByPath(toggle?.value && path2 ? path2 : path1);
return sub?.value ? Number(sub.value.value ?? 0) : 0; return sub?.value ? Number(sub.value ?? 0) : 0;
}, },
set(val) { set(val) {
const baseValue = val; const baseValue = val;

View File

@@ -1,12 +0,0 @@
import { ref } from 'vue';
import type { TreeNode } from './dbmTree';
export const contextMenuRef = ref();
export const contextMenuState = ref<TreeNode | undefined>();
export function openContextMenu(event: MouseEvent, node: undefined) {
event.preventDefault();
contextMenuState.value = node;
contextMenuRef.value?.show(event);
}

View File

@@ -3,6 +3,14 @@ export type Publish = {
uuid: string; uuid: string;
path: string; path: string;
type: string; type: string;
value: undefined; value: string | number | boolean | null;
}; };
export type Pubs = Publish[]; export type Pubs = Publish[];
import { updateSubscriptionValue } from './Subscriptions';
export function publishToSubscriptions(pubs: Pubs) {
pubs.forEach((pub) => {
updateSubscriptionValue(pub.uuid, pub.value);
});
}

View File

@@ -1,13 +1,13 @@
import type { Gets } from './Get'; import type { Gets } from './Get';
import type { Sets } from './Set'; import type { Sets } from './Set';
import type { Subs } from './Subscribe'; import type { RawSubs } from './Subscribe';
import type { Pubs } from './Publish'; import type { Pubs } from './Publish';
export type Response = { export type Response = {
get?: Gets; get?: Gets;
set?: Sets; set?: Sets;
subscribe?: Subs; subscribe?: RawSubs;
unsubscribe?: Subs; unsubscribe?: RawSubs;
publish?: Pubs; publish?: Pubs;
error?: boolean; error?: boolean;
message?: string; message?: string;

View File

@@ -1,6 +1,6 @@
export type Set = { export type Set = {
uuid?: string | undefined; uuid?: string | undefined;
path: string; path?: string;
type?: string; type?: string;
value: string | number | boolean | undefined; value: string | number | boolean | undefined;
create?: boolean; create?: boolean;

View File

@@ -1,10 +1,46 @@
// API type (from backend) import { ref } from 'vue';
import type { Ref } from 'vue';
export type Subscribe = { export type Subscribe = {
uuid?: string; uuid?: string;
path?: string; path?: string;
depth?: number; depth?: number;
value?: string | number | boolean; type?: string;
drivers?: object | undefined;
value?: Ref<string | number | boolean | null | undefined>;
hasChild?: boolean; hasChild?: boolean;
}; };
export type Subs = Subscribe[]; export type Subs = Subscribe[];
export type RawSubscribe = {
uuid?: string;
path?: string;
depth?: number;
value?: string | number | boolean | null;
hasChild?: boolean;
};
export type RawSubs = RawSubscribe[];
export function convertToSubscribe(raw: RawSubscribe): Subscribe {
return {
...raw,
value: ref(raw.value ?? null),
};
}
export function convertToSubscribes(rawList: RawSubs): Subs {
const subs = rawList.map(convertToSubscribe);
return subs as Subs;
}
export function convertToRaw(sub: Subscribe): RawSubscribe {
return {
...(sub.uuid !== undefined ? { uuid: sub.uuid } : {}),
...(sub.path !== undefined ? { path: sub.path } : {}),
...(sub.depth !== undefined ? { depth: sub.depth } : {}),
...(sub.value?.value !== undefined ? { value: sub.value.value } : {}),
...(sub.hasChild !== undefined ? { hasChild: sub.hasChild } : {}),
};
}

View File

@@ -0,0 +1,67 @@
import type { Ref } from 'vue';
import { reactive, ref } from 'vue';
import { convertToSubscribe } from 'src/models/Subscribe';
import type { Subs, Subscribe, RawSubs } from 'src/models/Subscribe';
const EMPTYUUID = '00000000-0000-0000-0000-000000000000';
export const Subscriptions = reactive<Record<string, Subscribe>>({});
export type TableSubscription = {
path: string;
value: Ref<string | number | boolean | null | undefined>;
};
export function getRows(): Subs {
return Object.values(Subscriptions).map((sub) => {
sub.path = sub.path?.split(':').pop() ?? '';
if (!sub.type) sub.type = 'None';
else sub.type = sub.type.toLowerCase();
return sub;
});
}
export function addSubscriptions(subs: RawSubs) {
subs.forEach((sub) => addSubscription(convertToSubscribe(sub)));
}
function addSubscription(sub: Subscribe) {
if (EMPTYUUID === sub.uuid) {
sub.path = 'DBM';
}
if (!sub.uuid) return;
Subscriptions[sub.uuid] = sub;
}
export function updateSubscription(sub: Subscribe) {
if (!sub.uuid) return;
Subscriptions[sub.uuid] = sub;
}
export function updateSubscriptionValue(
uuid: string,
value: string | number | boolean | null | undefined,
) {
if (!uuid) return;
if (!Subscriptions[uuid]) return;
Subscriptions[uuid].value = ref(value);
}
export function removeSubscriptions(subs: RawSubs) {
subs.forEach((sub) => {
removeSubscription(sub.path);
});
}
export function removeAllSubscriptions() {
Object.keys(Subscriptions).forEach((key) => delete Subscriptions[key]);
}
function removeSubscription(uuid: string | undefined) {
if (uuid === undefined) return;
if (!Subscriptions || Subscriptions[uuid] === undefined) return;
delete Subscriptions[uuid];
}
export function findSubscriptionByPath(path: string): Subscribe | undefined {
return Object.values(Subscriptions).find((sub) => sub.path === path);
}

View File

@@ -1,19 +1,13 @@
import type { Response } from 'src/models/Response'; import type { Response } from 'src/models/Response';
import type { Publish } from 'src/models/Publish'; import { publishToSubscriptions } from 'src/models/Publish';
import type { Request } from 'src/models/Request'; import type { Request } from 'src/models/Request';
import type { QVueGlobals } from 'quasar'; import type { QVueGlobals } from 'quasar';
import {
buildTree,
dbmData,
getSubscriptionsByPath,
getAllSubscriptions,
} from 'src/composables/dbm/dbmTree';
import { ref } from 'vue'; import { ref } from 'vue';
import type { Subs } from 'src/models/Subscribe'; import { NotifyResponse } from 'src/composables/notify';
import { type Subs } from 'src/models/Subscribe';
import type { Sets } from 'src/models/Set'; import type { Sets } from 'src/models/Set';
import type { PongMessage } from 'src/models/Pong'; import type { PongMessage } from 'src/models/Pong';
import { NotifyResponse } from 'src/composables/notify'; import { addSubscriptions } from 'src/models/Subscriptions';
const pendingResponses = new Map<string, (data: Response | undefined) => void>(); const pendingResponses = new Map<string, (data: Response | undefined) => void>();
//const lastKnownValues: Record<string, string> = reactive({}); //const lastKnownValues: Record<string, string> = reactive({});
@@ -91,13 +85,7 @@ export function initWebSocket(url: string, $q?: QVueGlobals) {
} }
if (message.publish) { if (message.publish) {
(message.publish as Publish[]).forEach((pub) => { publishToSubscriptions(message.publish);
const sub = getSubscriptionsByPath(pub.path);
if (sub.value && pub.value) {
sub.value.value = pub.value;
}
dbmData.splice(0, dbmData.length, ...buildTree(getAllSubscriptions())); // rebuild reactive tree
});
} }
} }
}; };
@@ -139,10 +127,6 @@ function waitForSocketConnection(): Promise<void> {
}); });
} }
export function subscribe(data: Subs): Promise<Response | undefined> {
return send({ subscribe: data });
}
export function subscribeToPath(q: QVueGlobals, path: string) { export function subscribeToPath(q: QVueGlobals, path: string) {
subscribe([ subscribe([
{ {
@@ -151,9 +135,8 @@ export function subscribeToPath(q: QVueGlobals, path: string) {
}, },
]) ])
.then((response) => { .then((response) => {
console.log(response);
if (response?.subscribe) { if (response?.subscribe) {
dbmData.splice(0, dbmData.length, ...buildTree(response.subscribe)); addSubscriptions(response.subscribe);
} else { } else {
NotifyResponse(q, response); NotifyResponse(q, response);
} }
@@ -163,6 +146,10 @@ export function subscribeToPath(q: QVueGlobals, path: string) {
}); });
} }
export function subscribe(data: Subs): Promise<Response | undefined> {
return send({ subscribe: data });
}
export function unsubscribe(data: Subs): Promise<Response | undefined> { export function unsubscribe(data: Subs): Promise<Response | undefined> {
return send({ unsubscribe: data }); return send({ unsubscribe: data });
} }

View File

@@ -0,0 +1,32 @@
<template>
<q-card>
<div class="text-primary q-ma-md">Datatypes *</div>
<div>
<div class="row q-gutter-sm q-ml-sm">
<q-card-section class="items-between">
<RadioButton class="q-ma-xs" v-model:opt="datatype" text="None" hint="none">
</RadioButton>
<RadioButton class="q-ma-xs" v-model:opt="datatype" text="String" hint="Text">
</RadioButton>
<RadioButton class="q-ma-xs" v-model:opt="datatype" text="Bool" hint="On / Off">
</RadioButton>
</q-card-section>
<RadioButton v-model:opt="datatype" text="Uint8" hint="0 - 255"> </RadioButton>
<RadioButton v-model:opt="datatype" text="Uint16" hint="0 - 65535"> </RadioButton>
<RadioButton v-model:opt="datatype" text="Uint32" hint="0 - 429496..."> </RadioButton>
<RadioButton v-model:opt="datatype" text="Int8" hint="-128 - 127"> </RadioButton>
<RadioButton v-model:opt="datatype" text="Int16" hint="-32768 - 3..."> </RadioButton>
<RadioButton v-model:opt="datatype" text="Int32" hint="-21474836..."> </RadioButton>
<RadioButton v-model:opt="datatype" text="Int32" hint="-2^63 - (2^...)"> </RadioButton>
<RadioButton v-model:opt="datatype" text="double" hint="1.7E 1/- 3..."> </RadioButton>
</div>
</div>
</q-card>
</template>
<script setup lang="ts">
import RadioButton from './RadioButton.vue';
import { ref } from 'vue';
const datatype = ref('none');
</script>

View File

@@ -0,0 +1,31 @@
<template>
<q-btn style="border-radius: 8px" no-caps
><q-radio v-model="opt" :val="props.text"></q-radio>
<div class="column items-start q-mx-md">
<div class="text-body1 text-black">{{ props.text }}</div>
<div class="text-caption text-grey">{{ props.hint }}</div>
</div>
</q-btn>
</template>
<script setup lang="ts">
import { watch, ref } from 'vue';
const opt = defineModel('opt');
const props = defineProps({
text: {
type: String,
required: true,
},
hint: {
type: String,
},
});
const localOption = ref('');
const emit = defineEmits(['update:option']);
watch(localOption, (val) => emit('update:option', val));
</script>

View File

@@ -0,0 +1,65 @@
<template>
<q-dialog
ref="dialogRef"
:maximized="minMaxState"
:full-width="minMaxState"
:no-focus="!minMaxState"
:no-refocus="!minMaxState"
:seamless="!minMaxState"
>
<q-card class="layout" :style="cardStyle" v-touch-pan.mouse.prevent.stop="handlePan">
<div class="row justify-end q-mx-sm q-mt-xs">
<q-btn dense flat :icon="minMaxIcon" size="md" @click="minMax()"></q-btn>
<q-btn dense flat icon="close" size="md" v-close-popup></q-btn>
</div>
<q-separator color="black" class="q-my-none" />
<slot />
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const dialogRef = ref();
const open = () => dialogRef.value?.show();
const minMaxIcon = ref('fullscreen');
const minMaxState = ref(false);
function minMax() {
if (minMaxState.value) {
minMaxIcon.value = 'fullscreen';
} else {
minMaxIcon.value = 'fullscreen_exit';
}
minMaxState.value = !minMaxState.value;
}
const props = defineProps({
width: {
type: String,
default: '300px',
},
});
const position = ref({ x: 0, y: 0 });
// This makes the dialog draggable
const handlePan = (details: { delta: { x: number; y: number } }) => {
position.value.x += details.delta.x;
position.value.y += details.delta.y;
};
const cardStyle = computed(() => ({
width: props.width,
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
}));
defineExpose({ open });
</script>
<style scoped>
.layout {
border-radius: 10px;
}
</style>