5 Commits

Author SHA1 Message Date
Adrian Zuercher
5b905bac24 lift version 2025-06-29 21:26:56 +02:00
Adrian Zuercher
e320156a47 add automatic localhost and local ip for allowOrigin 2025-06-29 21:26:16 +02:00
Adrian Zuercher
c58dbf34d0 add icon as asset, add refresh button 2025-06-29 20:27:18 +02:00
zuadi
472a446d3a Rename okDialog.vue to OkDialog.vue 2025-06-27 11:07:52 +02:00
Adrian Zuercher
38610471f3 fix scene load and add first version UpdateValueDialog 2025-06-27 11:02:27 +02:00
19 changed files with 646 additions and 393 deletions

View File

@@ -48,6 +48,10 @@ func main() {
Debug: *debug,
TerminalOut: true,
})
if err != nil {
logger.Error("main new logger", err.Error())
panic(err)
}
//new login manager
loginManager, err := login.NewLoginManager(".")
@@ -62,8 +66,20 @@ func main() {
// new server
s := server.NewServer()
//get local ip
origins := []string{"http://localhost:9000"}
origins = append(origins, "http://localhost:9500")
localIP, err := utils.GetLocalIP()
if err != nil {
logger.Error("main", fmt.Sprintf("get local ip : %s", err.Error()))
} else {
origins = append(origins, fmt.Sprintf("http://%s:9000", localIP))
origins = append(origins, fmt.Sprintf("http://%s:9500", localIP))
}
fmt.Println(123, origins)
s.Routes.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:9000"},
AllowOrigins: origins,
AllowMethods: []string{"POST", "GET", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type"},
AllowCredentials: true,
@@ -104,7 +120,6 @@ func main() {
logger.Error("main", fmt.Sprintf("starting browser error : %s", err.Error()))
}
}()
fmt.Println(3, *ip, *port)
// start http server
logger.Info("main", fmt.Sprintf("http listen on ip: %s port: %d", *ip, *port))
if err := s.ServeHttp(*ip, *port); err != nil {

View File

@@ -47,10 +47,23 @@ func (sh *ScenesHandler) SaveScene(c *gin.Context) {
}
if _, err := os.Stat(path.Join(sh.dir)); err != nil {
os.MkdirAll(sh.dir, 666)
err := os.MkdirAll(sh.dir, 0755)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
}
f, err := os.OpenFile(path.Join(sh.dir, scene.Name+".scene"), os.O_CREATE|os.O_TRUNC|os.O_RDWR, 666)
f, err := os.OpenFile(path.Join(sh.dir, scene.Name+".scene"), os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0644)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
defer f.Close()
_, err = f.Write(body)
if err != nil {
@@ -59,7 +72,6 @@ func (sh *ScenesHandler) SaveScene(c *gin.Context) {
})
return
}
defer f.Close()
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Scene '%s' saved", scene.Name),
@@ -155,6 +167,7 @@ func (sh *ScenesHandler) LoadScene(c *gin.Context) {
}
var scene models.Scene
err = json.Unmarshal(body, &scene)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -190,6 +203,13 @@ func (sh *ScenesHandler) LoadScene(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, scene)
break
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Errorf("scene '%s' not found", scene.Name),
})
return
}
c.JSON(http.StatusOK, scene)
}

22
backend/utils/ip.go Normal file
View File

@@ -0,0 +1,22 @@
package utils
import (
"fmt"
"net"
)
func GetLocalIP() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String(), nil
}
}
}
return "", fmt.Errorf("no local IP address found")
}

View File

@@ -3,6 +3,7 @@ package utils
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
@@ -27,6 +28,10 @@ func OpenBrowser(url string, logger *logging.Logger) error {
{"open", url}, // fallback
}
default: // Linux
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("XDG_SESSION_TYPE") != "wayland" {
return fmt.Errorf("os is running i headless mode do not start browser")
}
commands = [][]string{
{"chromium-browser", "--kiosk", url},
{"google-chrome", "--kiosk", url},
@@ -47,14 +52,14 @@ func OpenBrowser(url string, logger *logging.Logger) error {
return fmt.Errorf("could not open browser")
}
func FindAllFiles(rootDir, fileExtention string) (files []string, err error){
err = filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
func FindAllFiles(rootDir, fileExtention string) (files []string, err error) {
err = filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
} else if filepath.Ext(d.Name()) == fileExtention{
} else if filepath.Ext(d.Name()) == fileExtention {
files = append(files, path)
}
return err
})
return
return
}

12
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "lightcontrol",
"version": "0.0.5",
"version": "0.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lightcontrol",
"version": "0.0.5",
"version": "0.0.14",
"hasInstallScript": true,
"dependencies": {
"@quasar/extras": "^1.16.4",
"axios": "^1.9.0",
"axios": "^1.10.0",
"quasar": "^2.16.0",
"vue": "^3.4.18",
"vue-router": "^4.0.12"
@@ -2312,9 +2312,9 @@
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",

View File

@@ -1,6 +1,6 @@
{
"name": "lightcontrol",
"version": "0.0.14",
"version": "0.0.17",
"description": "A Tecamino App",
"productName": "Light Control",
"author": "A. Zuercher",
@@ -16,7 +16,7 @@
},
"dependencies": {
"@quasar/extras": "^1.16.4",
"axios": "^1.9.0",
"axios": "^1.10.0",
"quasar": "^2.16.0",
"vue": "^3.4.18",
"vue-router": "^4.0.12"

View File

@@ -4,8 +4,8 @@ import { initWebSocket } from 'src/services/websocket';
export default boot(({ app }) => {
const $q = app.config.globalProperties.$q as QVueGlobals;
const host = window.location.hostname; // gets current domain or IP
const port = 8100; // your WebSocket port
const host = window.location.hostname;
const port = 8100;
const randomId = Math.floor(Math.random() * 10001); // random number from 0 to 10000
const ws = initWebSocket(`ws://${host}:${port}/ws?id=q${randomId}`, $q);

View File

@@ -0,0 +1,13 @@
<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

@@ -11,14 +11,24 @@
row-key="path"
virtual-scroll
:rows-per-page-options="[0]"
/>
>
<template v-slot:body-cell-value="props">
<q-td :props="props" @click="openDialog(props.row)">
<span :class="['cursor-pointer', open ? 'text-green' : '']"> {{ props.row.value }}</span>
</q-td>
</template>
</q-table>
<Dialog dialogLabel="Update Value" :show-dialog="open" />
</div>
</template>
<script setup lang="ts">
import Dialog from 'src/components/dialog/UpdateValueDialog.vue';
import type { QTableProps } from 'quasar';
import type { Subs } from 'src/models/Subscribe';
import { computed } from 'vue';
import type { Subs, Subscribe } from 'src/models/Subscribe';
import { computed, ref } from 'vue';
const open = ref(false);
// we generate lots of rows here
const props = defineProps<{
@@ -35,5 +45,16 @@ const columns = [
field: 'value',
align: 'left',
},
{
name: 'test',
label: '',
field: 'test',
align: 'left',
},
] as QTableProps['columns'];
function openDialog(item: Subscribe) {
console.log(77, item);
open.value = true;
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<q-dialog v-model="internalShowDialog">
<q-card :style="'width:' + props.width">
<q-card-section
v-if="props.dialogLabel"
class="text-h6 text-center"
:class="'text-' + props.labelColor"
>{{ props.dialogLabel }}</q-card-section
>
<q-card-section>
<q-input v-model="inputValue"></q-input>
</q-card-section>
<q-card-section v-if="props.text" class="text-center" style="white-space: pre-line">{{
props.text
}}</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn v-if="props.buttonCancelLabel" flat :label="props.buttonCancelLabel" v-close-popup>
</q-btn>
<q-btn
v-if="props.buttonOkLabel"
flat
:label="props.buttonOkLabel"
v-close-popup
@click="closeDialog"
>
</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps({
showDialog: {
type: Boolean,
required: true,
},
buttonOkLabel: {
type: String,
default: 'OK',
},
labelColor: {
type: String,
default: 'primary',
},
dialogLabel: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
buttonCancelLabel: {
type: String,
default: '',
},
width: {
type: String,
default: '300px',
},
value: {
type: [String, Number],
},
});
const inputValue = ref(props.value);
const emit = defineEmits(['update:showDialog', 'update:value', 'confirmed', 'cancel']);
const internalShowDialog = ref(props.showDialog);
watch(inputValue, (val) => {
emit('update:value', val);
});
watch(
() => props.showDialog,
(newValue) => {
console.log('watch showDialog', newValue);
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() {
internalShowDialog.value = false;
emit('update:showDialog', false);
}
</script>

View File

@@ -104,17 +104,17 @@ select
import { useQuasar } from 'quasar';
import LightSlider from './LightSlider.vue';
import { NotifyResponse } from 'src/composables/notify';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { subscribe, unsubscribe, setValues } from 'src/services/websocket';
import { onBeforeUpdate, computed, onMounted, onUnmounted, ref } from 'vue';
import { subscribeToPath, unsubscribe, setValues } from 'src/services/websocket';
import { LocalStorage } from 'quasar';
import { getSubscriptionsByPath, buildTree, dbmData } from 'src/composables/dbm/dbmTree';
import { getSubscriptionsByPath, updateValue } from 'src/composables/dbm/dbmTree';
import DragPad from 'src/components/lights/DragPad.vue';
import SettingDialog from './SettingMovingHead.vue';
import type { Settings } from 'src/models/MovingHead';
const $q = useQuasar();
const brightness = updateBrightnessValue('MovingHead:Brightness');
const state = updateValue('MovingHead:State');
const state = updateValue('MovingHead:State', $q);
const settings = ref<Settings>({
show: false,
reversePan: false,
@@ -125,24 +125,11 @@ const settings = ref<Settings>({
onMounted(() => {
settings.value.reversePan = LocalStorage.getItem('reversePan') ?? false;
settings.value.reverseTilt = LocalStorage.getItem('reverseTilt') ?? false;
subscribeToPath($q, 'MovingHead:.*');
});
subscribe([
{
path: 'MovingHead:.*',
depth: 0,
},
])
.then((response) => {
console.log(response);
if (response?.subscribe) {
dbmData.splice(0, dbmData.length, ...buildTree(response.subscribe));
} else {
NotifyResponse($q, response);
}
})
.catch((err) => {
NotifyResponse($q, err, 'error');
});
onBeforeUpdate(() => {
subscribeToPath($q, 'MovingHead:.*');
});
onUnmounted(() => {
@@ -157,45 +144,28 @@ onUnmounted(() => {
});
function changeState() {
console.log(55, brightness.value);
console.log(56, state.value);
if (brightness.value === 0) {
if (state.value === 0) {
brightness.value = 255;
return;
}
brightness.value = state.value;
console.log(57, brightness.value);
return;
}
state.value = brightness.value;
console.log(58, state.value);
brightness.value = 0;
}
function updateValue(path: string, isDouble = false) {
return computed({
get() {
const sub = getSubscriptionsByPath(path);
const value = sub ? Number(sub.value ?? 0) : 0;
return isDouble ? value : value;
},
set(val) {
const setPaths = [{ path, value: val }];
if (isDouble) {
setPaths.push({ path: `${path}Fine`, value: val });
}
setValues(setPaths)
.then((response) => NotifyResponse($q, response))
.catch((err) => console.error(`Failed to update ${path.split(':')[1]}:`, err));
},
});
}
function updateBrightnessValue(path: string) {
return computed({
get() {
const sub = getSubscriptionsByPath(path);
const value = sub ? Number(sub.value ?? 0) : 0;
return value;
if (!sub.value) return 0;
return Number(sub.value.value);
},
set(val) {
const setPaths = [{ path, value: val }];

View File

@@ -0,0 +1,361 @@
<template>
<!-- new edit scene dialog-->
<q-dialog v-model="showDialog" persistent>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-primary text-h6">{{ dialogLabel }}</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
class="q-mb-md"
dense
v-model="newScene.name"
placeholder="Name"
autofocus
:rules="[(val) => !!val || 'Field is required']"
@keyup.enter="saveScene"
/>
<q-input
dense
v-model="newScene.description"
placeholder="Description"
autofocus
@keyup.enter="saveScene"
/>
<div class="q-py-md">
<div class="q-gutter-sm">
<q-checkbox v-model="newScene.movingHead" label="Moving Head" />
<q-checkbox v-model="newScene.lightBar" label="Light Bar" />
</div>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup />
<q-btn flat :label="dialogLabel" @click="saveScene()" />
</q-card-actions>
</q-card>
</q-dialog>
<Dialog
dialogLabel="Duplicate Scene"
:text="`Scene '${newScene.name}' exists already`"
:show-dialog="existsAlready"
v-on:update:show-dialog="existsAlready = $event"
/>
<q-list
bordered
v-if="scenes?.length > 0"
class="q-mx-auto"
style="max-width: 100%; max-width: 500px"
>
<q-btn
rounded
color="primary"
:class="['q-ma-md', 'text-bold', 'text-white']"
@click="openDialog('add')"
>Add New Scene</q-btn
>
<q-item class="row">
<q-item-section :class="['text-black', 'text-bold', 'col-5']">Name</q-item-section>
<q-item-section :class="['text-black', 'text-left', 'text-bold', 'text-left']"
>Description</q-item-section
>
</q-item>
<q-item
v-for="(item, index) in scenes"
:key="item.name"
bordered
style="border: 0.1px solid lightgray; border-radius: 5px; margin-bottom: 1px"
>
<q-item-section
@click="openDialog('load', item)"
:class="['text-black', 'text-left', 'cursor-pointer']"
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
>{{ item.name }}</q-item-section
>
<q-item-section
@click="openDialog('load', item)"
:class="['text-black', 'text-left', 'cursor-pointer']"
left
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
>{{ item.description }}</q-item-section
>
<q-item-section top side>
<div class="text-grey-8 q-gutter-xs">
<q-btn size="12px" flat dense round icon="delete" @click="removeScene(item.name)" />
<q-btn
size="12px"
flat
dense
round
icon="more_vert"
@click="openDialog('edit', item, index)"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<!-- Fallback if list is empty -->
<div v-else class="q-pa-md text-grey text-center">
<div>No scenes available</div>
<q-btn
rounded
color="primary"
:class="['q-ma-md', 'text-bold', 'text-white']"
@click="openDialog('add')"
>Add First Scene</q-btn
>
</div>
</template>
<script setup lang="ts">
import { NotifyDialog } from 'src/composables/notify';
import { onMounted, reactive, ref } from 'vue';
import { useQuasar } from 'quasar';
import type { Scene } from 'src/models/Scene';
import type { Set } from 'src/models/Set';
import axios from 'axios';
import { api } from 'boot/axios';
import { NotifyResponse } from 'src/composables/notify';
import Dialog from 'src/components/dialog/OkDialog.vue';
import { setValues } from 'src/services/websocket';
const $q = useQuasar();
const showDialog = ref(false);
const dialog = ref('');
const existsAlready = ref(false);
const editIndex = ref(-1);
const dialogLabel = ref('');
const newScene = reactive<Scene>({
name: '',
movingHead: false,
lightBar: false,
});
const scenes = ref<Scene[]>([]);
const host = window.location.hostname;
const port = 9500;
const baseURL = `http://${host}:${port}`;
const quasarApi = axios.create({
baseURL: baseURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
onMounted(() => {
quasarApi
.get('/api/loadScenes')
.then((resp) => {
if (resp.data) {
scenes.value = resp.data;
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
});
function removeScene(name: string) {
dialog.value = '';
NotifyDialog($q, 'Delete', 'Do you want to delete scene: ' + name, 'YES', 'NO')
.then((res) => {
if (res) {
scenes.value = scenes.value.filter((s) => s.name !== name);
quasarApi
.delete('/api/deleteScene', {
data: { name },
})
.then((res) => {
if (res.data) {
NotifyResponse($q, res.data, 'warning');
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
}
})
.catch((err) => NotifyResponse($q, err.resp, 'warning'));
}
function openDialog(dialogType: string, scene?: Scene, index?: number) {
switch (dialogType) {
case 'add':
dialog.value = 'add';
dialogLabel.value = 'Add Scene';
newScene.name = '';
newScene.movingHead = true;
newScene.lightBar = true;
showDialog.value = true;
break;
case 'edit':
if (!scene) return;
if (index === undefined) return;
dialog.value = 'edit';
dialogLabel.value = 'Update Scene';
editIndex.value = index;
Object.assign(newScene, JSON.parse(JSON.stringify(scene)));
showDialog.value = true;
break;
case 'load':
if (!scene) return;
dialog.value = 'load';
dialogLabel.value = 'Load Scene';
quasarApi
.post('/api/loadScene', scene)
.then((res) => {
if (res.data) {
Object.assign(newScene, JSON.parse(JSON.stringify(res.data)));
showDialog.value = true;
}
})
.catch((err) => NotifyResponse($q, err.response.data.error, 'error'));
break;
default:
showDialog.value = false;
break;
}
}
const saveScene = async () => {
const sendValues = [];
if (!newScene.name) {
return;
}
const exists = scenes.value.some(
(item, index) => item.name === newScene.name && index !== editIndex.value,
);
switch (dialog.value) {
case 'add':
if (exists) {
existsAlready.value = true;
return;
}
if (newScene.movingHead) {
sendValues.push({
path: 'MovingHead',
query: { depth: 0 },
});
}
if (newScene.lightBar) {
sendValues.push({
path: 'LightBar',
query: { depth: 0 },
});
}
if (sendValues.length > 0) {
try {
const res = await api.post('/json_data', { get: sendValues });
newScene.values = res.data.get;
} catch (err) {
NotifyResponse($q, err as Error, 'error');
}
} else {
newScene.values = [];
}
scenes.value = [...scenes.value, JSON.parse(JSON.stringify(newScene))];
// Sort alphabetically by scene name
scenes.value.sort((a, b) => a.name.localeCompare(b.name));
quasarApi
.post('/api/saveScene', JSON.stringify(newScene))
.then((res) => {
if (res.data) {
NotifyResponse($q, res.data);
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
scenes.value = [...scenes.value];
break;
case 'edit':
if (exists) {
existsAlready.value = true;
return;
}
if (newScene.movingHead) {
sendValues.push({
path: 'MovingHead',
query: { depth: 0 },
});
}
if (newScene.lightBar) {
sendValues.push({
path: 'LightBar',
query: { depth: 0 },
});
}
if (sendValues.length > 0) {
try {
const res = await api.post('/json_data', { get: sendValues });
newScene.values = res.data.get;
} catch (err) {
NotifyResponse($q, err as Error, 'error');
}
} else {
newScene.values = [];
}
scenes.value.splice(editIndex.value, 1, JSON.parse(JSON.stringify(newScene)));
scenes.value.sort((a, b) => a.name.localeCompare(b.name));
scenes.value = [...scenes.value];
quasarApi
.post('/api/saveScene', JSON.stringify(newScene))
.then((res) => {
if (res.data) {
NotifyResponse($q, res.data);
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
scenes.value = [...scenes.value];
break;
case 'load':
{
const setPaths = <Set[]>[];
if (newScene.movingHead) {
newScene.values?.forEach((element) => {
if (element.path && element.path.includes('MovingHead')) {
setPaths.push({ path: element.path, value: element.value });
}
});
}
if (newScene.lightBar) {
newScene.values?.forEach((element) => {
if (element.path && element.path.includes('LightBar')) {
setPaths.push({ path: element.path, value: element.value });
}
});
}
setValues(setPaths)
.then((response) => {
NotifyResponse($q, response);
})
.catch((err) => console.error(`Failed to load scene ${newScene.name}`, err));
}
break;
}
dialog.value = '';
showDialog.value = false;
};
</script>

View File

@@ -1,292 +0,0 @@
<template>
<!-- new edit scene dialog-->
<q-dialog v-model="showDialog" persistent>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-primary text-h6">{{ dialogLabel }}</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
class="q-mb-md"
dense
v-model="newScene.name"
placeholder="Name"
autofocus
:rules="[(val) => !!val || 'Field is required']"
@keyup.enter="saveScene"
/>
<q-input
dense
v-model="newScene.description"
placeholder="Description"
autofocus
@keyup.enter="saveScene"
/>
<div class="q-py-md">
<div class="q-gutter-sm">
<q-checkbox v-model="newScene.movingHead" label="Moving Head" />
<q-checkbox v-model="newScene.lightBar" label="Light Bar" />
</div>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup />
<q-btn flat :label="dialogLabel" @click="saveScene" />
</q-card-actions>
</q-card>
</q-dialog>
<Dialog
dialogLabel="Duplicate Scene"
:text="`Scene '${newScene.name}' exists already`"
:show-dialog="existsAlready"
v-on:update:show-dialog="existsAlready = $event"
/>
<q-list
bordered
v-if="items.length > 0"
class="q-mx-auto"
style="max-width: 100%; max-width: 500px"
>
<q-btn
rounded
color="primary"
:class="['q-ma-md', 'text-bold', 'text-white']"
@click="openAddDialog"
>Add New Scene</q-btn
>
<q-item class="row">
<q-item-section :class="['text-black', 'text-bold', 'col-5']">Name</q-item-section>
<q-item-section :class="['text-black', 'text-left', 'text-bold', 'text-left']"
>Description</q-item-section
>
</q-item>
<q-item
v-for="(item, index) in items"
:key="item.name"
bordered
style="border: 0.1px solid lightgray; border-radius: 5px; margin-bottom: 1px"
>
<q-item-section
@click="openLoadDialog(item.name)"
:class="['text-black', 'text-left', 'cursor-pointer']"
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
>{{ item.name }}</q-item-section
>
<q-item-section
@click="openLoadDialog(item.name)"
:class="['text-black', 'text-left', 'cursor-pointer']"
left
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
>{{ item.description }}</q-item-section
>
<q-item-section top side>
<div class="text-grey-8 q-gutter-xs">
<q-btn size="12px" flat dense round icon="delete" @click="removeScene(item.name)" />
<q-btn
size="12px"
flat
dense
round
icon="more_vert"
@click="openEditDialog(item, index)"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<!-- Fallback if list is empty -->
<div v-else class="q-pa-md text-grey text-center">
<div>No scenes available</div>
<q-btn
rounded
color="primary"
:class="['q-ma-md', 'text-bold', 'text-white']"
@click="openAddDialog"
>Add First Scene</q-btn
>
</div>
</template>
<script setup lang="ts">
import { NotifyDialog } from 'src/composables/notify';
import { onMounted, ref } from 'vue';
import { useQuasar } from 'quasar';
import type { Scene } from 'src/models/Scene';
import type { Set } from 'src/models/Set';
import axios from 'axios';
import { api } from 'boot/axios';
import { NotifyResponse } from 'src/composables/notify';
import Dialog from 'src/components/dialog/okDialog.vue';
import { setValues } from 'src/services/websocket';
const $q = useQuasar();
const showDialog = ref(false);
const isEdit = ref(false);
const isLoad = ref(false);
const existsAlready = ref(false);
const editIndex = ref(-1);
const dialogLabel = ref('');
const newScene = ref<Scene>({
name: '',
movingHead: false,
lightBar: false,
});
const items = ref<Scene[]>([]);
const quasarApi = axios.create({
baseURL: `http://localhost:9500`,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
onMounted(() => {
quasarApi
.get('/api/loadScenes')
.then((resp) => {
if (resp.data) {
items.value = resp.data as Scene[];
}
})
.catch((err) => NotifyResponse($q, err.response.data.error, 'error'));
});
function removeScene(name: string) {
NotifyDialog($q, 'Delete', 'Do you want to delete scene: ' + name, 'YES', 'NO')
.then((res) => {
if (res) {
items.value = items.value.filter((s) => s.name !== name);
quasarApi
.delete('/api/deleteScene', {
data: { name },
})
.then((res) => {
if (res.data) {
NotifyResponse($q, res.data, 'warning');
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
}
})
.catch((err) => NotifyResponse($q, err.resp, 'warning'));
}
function openAddDialog() {
isEdit.value = false;
dialogLabel.value = 'Add Scene';
newScene.value = {
name: '',
movingHead: true,
lightBar: true,
};
showDialog.value = true;
}
function openEditDialog(scene: Scene, index: number) {
isEdit.value = true;
dialogLabel.value = 'Update Scene';
console.log(76, scene);
newScene.value = { ...scene };
editIndex.value = index;
showDialog.value = true;
}
const saveScene = async () => {
if (!newScene.value.name) {
return;
}
const exists = items.value.some(
(item, index) => item.name === newScene.value.name && index !== editIndex.value,
);
if (exists) {
if (isLoad.value) {
console.log(44, 'load');
const setPaths = <Set[]>[];
newScene.value.values?.forEach((element) => {
setPaths.push({ path: element.path, value: element.value });
});
setValues(setPaths)
.then((response) => NotifyResponse($q, response))
.catch((err) => console.error(`Failed to load scene ${newScene.value.name}`, err));
isLoad.value = false;
showDialog.value = false;
return;
}
existsAlready.value = true;
return;
}
if (isEdit.value && editIndex.value !== -1) {
items.value[editIndex.value] = { ...newScene.value };
} else {
items.value.push(newScene.value);
}
// Sort alphabetically by scene name
items.value.sort((a, b) => a.name.localeCompare(b.name));
const sendValues = [];
if (newScene.value.movingHead) {
sendValues.push({
path: 'MovingHead',
query: { depth: 0 },
});
}
if (newScene.value.lightBar) {
sendValues.push({
path: 'LightBar',
query: { depth: 0 },
});
}
console.log(33, sendValues);
if (sendValues.length > 0) {
try {
const res = await api.post('/json_data', { get: sendValues });
newScene.value.values = res.data.get;
} catch (err) {
NotifyResponse($q, err as Error, 'error');
}
} else {
newScene.value.values = [];
}
quasarApi
.post('/api/saveScene', JSON.stringify(newScene.value))
.then((res) => {
if (res.data) {
NotifyResponse($q, res.data);
}
})
.catch((err) => {
NotifyResponse($q, err.response.data.error, 'error');
});
showDialog.value = false;
};
function openLoadDialog(name: string) {
isLoad.value = true;
quasarApi
.post('/api/loadScene', { name })
.then((res) => {
if (res.data) {
const scene = res.data as Scene;
newScene.value = scene;
newScene.value.name = name;
showDialog.value = true;
isEdit.value = true;
dialogLabel.value = 'Load Scene';
}
})
.catch((err) => NotifyResponse($q, err.response.data.error, 'error'));
}
</script>

View File

@@ -31,7 +31,7 @@ export function buildTree(subs: Subs): TreeNode[] {
for (const item of subs) {
if (item.path) {
Subscriptions[item.path] = item;
addNewSubscription(item);
}
const pathParts = item.path?.split(':') ?? [];
let current = root;
@@ -130,6 +130,11 @@ 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);
}
@@ -145,8 +150,7 @@ export function updateValue(
return computed({
get() {
const sub = getSubscriptionsByPath(toggle?.value && path2 ? path2 : path1);
const value = sub?.value ? Number(sub.value.value ?? 0) : 0;
return value;
return sub?.value ? Number(sub.value.value ?? 0) : 0;
},
set(val) {
const baseValue = val;

View File

@@ -3,15 +3,17 @@
<q-header elevated>
<q-toolbar>
<q-img
src="src/assets/LOGO_CF-ICON_color.svg"
:src="logo"
alt="Logo"
style="width: 40px; height: 40px; background-color: var(--q-primary)"
class="q-mr-sm"
/>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title> Light Control </q-toolbar-title>
<div>Version {{ version }}</div>
<q-btn dense icon="refresh" square class="q-px-md q-ml-md" @click="refresh" />
</q-toolbar>
</q-header>
@@ -35,6 +37,7 @@
</template>
<script setup lang="ts">
import logo from 'src/assets/LOGO_CF-ICON_color.svg';
import { ref } from 'vue';
import { version } from '../..//package.json';
@@ -47,4 +50,8 @@ function toggleLeftDrawer() {
function closeDrawer() {
leftDrawerOpen.value = false;
}
function refresh() {
window.location.reload();
}
</script>

View File

@@ -1,8 +1,8 @@
export type Publish = {
event: string;
uuid?: string;
path?: string;
type?: string;
value?: undefined;
uuid: string;
path: string;
type: string;
value: undefined;
};
export type Pubs = Publish[];

View File

@@ -7,7 +7,7 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', component: () => import('pages/MainPage.vue') },
{ path: '/data', component: () => import('pages/DataPage.vue') },
{ path: '/scenes', component: () => import('components/scenes/ScenesTab.vue') },
{ path: '/scenes', component: () => import('components/scenes/ScenesPage.vue') },
],
},

View File

@@ -3,18 +3,19 @@ import type { Publish } from 'src/models/Publish';
import type { Request } from 'src/models/Request';
import type { QVueGlobals } from 'quasar';
import {
getAllSubscriptions,
buildTree,
dbmData,
getSubscriptionsByUuid,
getSubscriptionsByPath,
getAllSubscriptions,
} from 'src/composables/dbm/dbmTree';
import { ref, reactive } from 'vue';
import { ref } from 'vue';
import type { Subs } from 'src/models/Subscribe';
import type { Sets } from 'src/models/Set';
import type { PongMessage } from 'src/models/Pong';
import { NotifyResponse } from 'src/composables/notify';
const pendingResponses = new Map<string, (data: Response | undefined) => void>();
const lastKnownValues: Record<string, string> = reactive({});
//const lastKnownValues: Record<string, string> = reactive({});
export let socket: WebSocket | null = null;
const isConnected = ref(false);
@@ -88,29 +89,14 @@ export function initWebSocket(url: string, $q?: QVueGlobals) {
pendingResponses.delete(id);
return;
}
if (message.publish) {
(message.publish as Publish[]).forEach((pub) => {
const uuid = pub.uuid;
const value = pub.value ?? '';
if (uuid === undefined) {
return;
}
const oldValue = lastKnownValues[uuid];
if (oldValue !== value) {
lastKnownValues[uuid] = value; // this is now reactive
if (pub.uuid) {
const existing = getSubscriptionsByUuid(pub.uuid);
if (existing.value) {
existing.value.value = value;
}
} else {
getAllSubscriptions().push({ value, uuid: uuid });
}
dbmData.splice(0, dbmData.length, ...buildTree(getAllSubscriptions())); // rebuild reactive tree
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
});
}
}
@@ -157,6 +143,26 @@ export function subscribe(data: Subs): Promise<Response | undefined> {
return send({ subscribe: data });
}
export function subscribeToPath(q: QVueGlobals, path: string) {
subscribe([
{
path: path,
depth: 0,
},
])
.then((response) => {
console.log(response);
if (response?.subscribe) {
dbmData.splice(0, dbmData.length, ...buildTree(response.subscribe));
} else {
NotifyResponse(q, response);
}
})
.catch((err) => {
NotifyResponse(q, err, 'error');
});
}
export function unsubscribe(data: Subs): Promise<Response | undefined> {
return send({ unsubscribe: data });
}