diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa120ef..1fa7177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install modules - run: yarn install + run: yarn install --frozen-lockfile - name: Build app env: VITE_APP_VERSION: ${{ steps.set_tag.outputs.tag }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14c2d62..9d672ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: with: node-version: '20.x' - name: Install modules - run: yarn install + run: yarn install --frozen-lockfile - name: Linting run: | yarn run lint diff --git a/cypress/integration/example.spec.ts b/cypress/integration/example.spec.ts index 3130c00..6e1bb87 100644 --- a/cypress/integration/example.spec.ts +++ b/cypress/integration/example.spec.ts @@ -1,8 +1,8 @@ // https://docs.cypress.io/api/introduction/api.html -describe("My First Test", () => { - it("visits the app root url", () => { - cy.visit("/"); - cy.contains("h1", "You did it!"); +describe('My First Test', () => { + it('visits the app root url', () => { + cy.visit('/'); + cy.contains('h1', 'You did it!'); }); }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index d076cec..37a498f 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -14,7 +14,7 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import "./commands"; +import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/eslint.config.ts b/eslint.config.ts index 5381806..3578bce 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -2,7 +2,6 @@ import { globalIgnores } from 'eslint/config' import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' import * as pluginVue from 'eslint-plugin-vue' import * as pluginVitest from '@vitest/eslint-plugin' -import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: // import { configureVueProject } from '@vue/eslint-config-typescript' @@ -24,5 +23,9 @@ export default defineConfigWithVueTs( ...pluginVitest.default.configs.recommended, files: ['src/**/__tests__/*'], }, - skipFormatting, + { + rules: { + 'quotes': ['error', 'single', { 'allowTemplateLiterals': true }], + }, + }, ) diff --git a/package.json b/package.json index cd5e468..a12241f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build": "vue-tsc --noEmit -p tsconfig.app.json --composite false && vite build", "test:unit": "vitest --environment jsdom", "test:e2e": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress open'", - "lint": "eslint . --fix", + "lint": "eslint .", "dev": "vite", "preview": "vite preview --port 5050", "test:e2e:ci": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress run'" @@ -29,6 +29,7 @@ "socket.io-client": "^4.8.1", "vue": "^3.5.0", "vue-chartjs": "^4.1.1", + "vue-color": "^3.3.3", "vue-fullscreen": "^3.1.2", "vue-router": "^4.5.1", "vue-socket.io": "^3.0.10", @@ -46,7 +47,6 @@ "@vitejs/plugin-vue-jsx": "^5.0.0", "@vitest/eslint-plugin": "^1.2.7", "@vue/compiler-sfc": "^3.5.17", - "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.5.1", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.7.0", @@ -57,7 +57,6 @@ "jiti": "^2.4.2", "jsdom": "^26.1.0", "npm-run-all2": "^8.0.4", - "prettier": "^3.5.3", "sass": "^1.89.2", "start-server-and-test": "^2.0.12", "typescript": "^5.8.3", diff --git a/src/App.vue b/src/App.vue index 1d46fd7..ca8f948 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,15 +1,15 @@ @@ -59,16 +57,20 @@ function isStringOrNumber(val: unknown): val is string | number { Type {{ deviceTypeModel }} - + Firmware {{ - formatFwVersion(device.fwVersion) + formatFwVersion(props.device.fwVersion) }} + + Signal strength + {{props.device.rssi}} + Connected since {{ - new Date(device.connectedSince).toISOString() + new Date(props.device.connectedSince).toISOString() }} diff --git a/src/components/ServerStatusOverlay.vue b/src/components/ServerStatusOverlay.vue index c77202b..cbdbdc0 100644 --- a/src/components/ServerStatusOverlay.vue +++ b/src/components/ServerStatusOverlay.vue @@ -1,8 +1,8 @@ diff --git a/src/components/automation/LogViewer.vue b/src/components/automation/LogViewer.vue index 01a570b..44378ca 100644 --- a/src/components/automation/LogViewer.vue +++ b/src/components/automation/LogViewer.vue @@ -1,6 +1,6 @@ - + diff --git a/src/components/icons/DeviceIcon.vue b/src/components/icons/DeviceIcon.vue index c844985..1b717fe 100644 --- a/src/components/icons/DeviceIcon.vue +++ b/src/components/icons/DeviceIcon.vue @@ -4,8 +4,8 @@ diff --git a/src/main.ts b/src/main.ts index 34073a1..765c532 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,14 @@ -import { createApp } from "vue"; -import App from "./App.vue"; -import router from "./router"; -import "vuetify/styles"; -import "@mdi/font/css/materialdesignicons.css"; -import { loadFonts } from "./plugins/webfontloader"; -import { vueSocketIOClient } from "./plugins/vueSocketIOClient.js"; -import { createPinia } from "pinia"; -import { vuetify } from "./plugins/vuetify"; -import VueFullscreen from "vue-fullscreen"; -import {useBackendStore} from "@/stores/backend"; +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; +import 'vuetify/styles'; +import '@mdi/font/css/materialdesignicons.css'; +import { loadFonts } from './plugins/webfontloader'; +import { vueSocketIOClient } from './plugins/vueSocketIOClient.js'; +import { createPinia } from 'pinia'; +import { vuetify } from './plugins/vuetify'; +import VueFullscreen from 'vue-fullscreen'; +import {useBackendStore} from '@/stores/backend'; loadFonts().catch(console.log); @@ -22,4 +22,4 @@ app .use(router) .use(vuetify) .use(VueFullscreen) - .mount("#app"); + .mount('#app'); diff --git a/src/model/MapRule.ts b/src/model/MapRule.ts index 103d768..4b005a6 100644 --- a/src/model/MapRule.ts +++ b/src/model/MapRule.ts @@ -1,6 +1,6 @@ -import Rule from "./Rule.js"; -import type Device from "./devices/Device"; -import type ValueMap from "./ValueMap.js"; +import Rule from './Rule.js'; +import type Device from './devices/Device'; +import type ValueMap from './ValueMap.js'; export default class MapRule extends Rule { public fromDevice: Device | null = null; diff --git a/src/model/devices/Device.ts b/src/model/devices/Device.ts index eff65d3..90d4b72 100644 --- a/src/model/devices/Device.ts +++ b/src/model/devices/Device.ts @@ -23,6 +23,10 @@ export interface IntDeviceAttribute extends DeviceAttribute { + uom?: string; +} + export interface IntRangeDeviceAttribute extends IntDeviceAttribute { min: number; max: number; @@ -49,3 +53,7 @@ export default interface Device { receiveUpdates: boolean; attributes: T; } + +export interface WirelessDevice extends Device { + rssi: number; +} diff --git a/src/model/devices/airotic/DeviceAirotic.ts b/src/model/devices/airotic/DeviceAirotic.ts new file mode 100644 index 0000000..5333554 --- /dev/null +++ b/src/model/devices/airotic/DeviceAirotic.ts @@ -0,0 +1,12 @@ +import type { BoolDeviceAttribute, FloatDeviceAttribute, StrDeviceAttribute, WirelessDevice } from '../Device'; + +export type DeviceAiroticAttributes = { + restColor: StrDeviceAttribute, + breathInColor: StrDeviceAttribute, + resetColors: BoolDeviceAttribute, + reboot: BoolDeviceAttribute, + breathsPerMin: FloatDeviceAttribute, + bpmTrend: StrDeviceAttribute<'up' | 'down' | 'stable' | undefined>, +}; + +export type DeviceAirotic = WirelessDevice; diff --git a/src/model/devices/estim2b/DeviceEstim2b.ts b/src/model/devices/estim2b/DeviceEstim2b.ts index c15933b..6a75365 100644 --- a/src/model/devices/estim2b/DeviceEstim2b.ts +++ b/src/model/devices/estim2b/DeviceEstim2b.ts @@ -1,10 +1,10 @@ -import type Device from "../Device"; +import type Device from '../Device'; import type { BoolDeviceAttribute, IntRangeDeviceAttribute, ListDeviceAttribute, StrDeviceAttribute -} from "../Device"; +} from '../Device'; export type PowerLevelDeviceEStim2bAttributes = { channelALevel: IntRangeDeviceAttribute, diff --git a/src/model/devices/slvctrl/DeviceAirValve.ts b/src/model/devices/slvctrl/DeviceAirValve.ts index e1fe178..219230b 100644 --- a/src/model/devices/slvctrl/DeviceAirValve.ts +++ b/src/model/devices/slvctrl/DeviceAirValve.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { IntRangeDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { IntRangeDeviceAttribute } from '../Device'; type DeviceAirValveAttributes = { flow: IntRangeDeviceAttribute; diff --git a/src/model/devices/slvctrl/DeviceDisplay.ts b/src/model/devices/slvctrl/DeviceDisplay.ts index c53a395..05f29c8 100644 --- a/src/model/devices/slvctrl/DeviceDisplay.ts +++ b/src/model/devices/slvctrl/DeviceDisplay.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { StrDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { StrDeviceAttribute } from '../Device'; type DeviceDisplayAttributes = { content: StrDeviceAttribute; diff --git a/src/model/devices/slvctrl/DeviceDistance.ts b/src/model/devices/slvctrl/DeviceDistance.ts index a375fb7..b07e697 100644 --- a/src/model/devices/slvctrl/DeviceDistance.ts +++ b/src/model/devices/slvctrl/DeviceDistance.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { IntDeviceAttribute, StrDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { IntDeviceAttribute, StrDeviceAttribute } from '../Device'; type DeviceDistanceAttributes = { distance: IntDeviceAttribute; diff --git a/src/model/devices/slvctrl/DeviceEt312.ts b/src/model/devices/slvctrl/DeviceEt312.ts index 7951a96..3b43fde 100644 --- a/src/model/devices/slvctrl/DeviceEt312.ts +++ b/src/model/devices/slvctrl/DeviceEt312.ts @@ -1,9 +1,9 @@ -import type Device from "../Device"; +import type Device from '../Device'; import type { BoolDeviceAttribute, IntRangeDeviceAttribute, ListDeviceAttribute -} from "../Device"; +} from '../Device'; type DeviceEt312Attributes = { connected: BoolDeviceAttribute; diff --git a/src/model/devices/slvctrl/DeviceNogasm.ts b/src/model/devices/slvctrl/DeviceNogasm.ts index 84ea83c..ed050cc 100644 --- a/src/model/devices/slvctrl/DeviceNogasm.ts +++ b/src/model/devices/slvctrl/DeviceNogasm.ts @@ -1,6 +1,6 @@ -import type Device from "../Device" -import type {IntRangeDeviceAttribute, ListDeviceAttribute} from "../Device"; -import type { IntDeviceAttribute } from "../Device"; +import type Device from '../Device' +import type {IntRangeDeviceAttribute, ListDeviceAttribute} from '../Device'; +import type { IntDeviceAttribute } from '../Device'; type DeviceNogasmAttributes = { timestampMs: IntDeviceAttribute; diff --git a/src/model/devices/slvctrl/DeviceStrikerMk2.ts b/src/model/devices/slvctrl/DeviceStrikerMk2.ts index b3b62c6..9c6260a 100644 --- a/src/model/devices/slvctrl/DeviceStrikerMk2.ts +++ b/src/model/devices/slvctrl/DeviceStrikerMk2.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { IntRangeDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { IntRangeDeviceAttribute } from '../Device'; type DeviceStrikerMk2Attributes = { speed: IntRangeDeviceAttribute; diff --git a/src/model/devices/virtual/VirtualDeviceDisplay.ts b/src/model/devices/virtual/VirtualDeviceDisplay.ts index e6fa0bc..f71645a 100644 --- a/src/model/devices/virtual/VirtualDeviceDisplay.ts +++ b/src/model/devices/virtual/VirtualDeviceDisplay.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { StrDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { StrDeviceAttribute } from '../Device'; type VirtualDeviceDisplayAttributes = { content: StrDeviceAttribute; diff --git a/src/model/devices/virtual/VirtualDevicePiperTts.ts b/src/model/devices/virtual/VirtualDevicePiperTts.ts index cadc5ff..a521699 100644 --- a/src/model/devices/virtual/VirtualDevicePiperTts.ts +++ b/src/model/devices/virtual/VirtualDevicePiperTts.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { BoolDeviceAttribute, StrDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { BoolDeviceAttribute, StrDeviceAttribute } from '../Device'; type VirtualDevicePiperTtsAttributes = { text: StrDeviceAttribute; diff --git a/src/model/devices/virtual/VirtualDeviceTts.ts b/src/model/devices/virtual/VirtualDeviceTts.ts index 1e6319c..f22ed6c 100644 --- a/src/model/devices/virtual/VirtualDeviceTts.ts +++ b/src/model/devices/virtual/VirtualDeviceTts.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { BoolDeviceAttribute, IntDeviceAttribute, StrDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { BoolDeviceAttribute, IntDeviceAttribute, StrDeviceAttribute } from '../Device'; type VirtualDeviceTtsAttributes = { text: StrDeviceAttribute; diff --git a/src/model/devices/virtual/VirtualRandomGenerator.ts b/src/model/devices/virtual/VirtualRandomGenerator.ts index eadfeed..39d7956 100644 --- a/src/model/devices/virtual/VirtualRandomGenerator.ts +++ b/src/model/devices/virtual/VirtualRandomGenerator.ts @@ -1,5 +1,5 @@ -import type Device from "../Device"; -import type { IntDeviceAttribute } from "../Device"; +import type Device from '../Device'; +import type { IntDeviceAttribute } from '../Device'; type VirtualDeviceRandomGeneratorAttributes = { value: IntDeviceAttribute; diff --git a/src/model/devices/zc95/DeviceZc95.ts b/src/model/devices/zc95/DeviceZc95.ts index b9c2c1b..7c0fac8 100644 --- a/src/model/devices/zc95/DeviceZc95.ts +++ b/src/model/devices/zc95/DeviceZc95.ts @@ -1,10 +1,10 @@ -import type Device from "../Device"; +import type { AllOrNone } from '@/types/utility'; +import type Device from '../Device'; import type { BoolDeviceAttribute, IntRangeDeviceAttribute, ListDeviceAttribute -} from "../Device"; -import type {AllOrNone} from "@/types"; +} from '../Device'; type RequiredDeviceZc95Attributes = { activePattern: ListDeviceAttribute; diff --git a/src/plugins/vueSocketIOClient.ts b/src/plugins/vueSocketIOClient.ts index d2c5f08..189f4b0 100644 --- a/src/plugins/vueSocketIOClient.ts +++ b/src/plugins/vueSocketIOClient.ts @@ -1,25 +1,27 @@ -import type { App, InjectionKey, Plugin } from "vue"; -import { inject } from "vue"; -import { - io, - type ManagerOptions, - Socket, - type SocketOptions, -} from "socket.io-client"; +import type { App, InjectionKey, Plugin } from 'vue'; +import { inject } from 'vue'; +import { io, type ManagerOptions, type SocketOptions } from 'socket.io-client'; +import type { AppSocket } from '@/types/socket'; interface SocketPluginOptions extends Partial { connection: string; } -const injectionKey: InjectionKey = Symbol("socketIO"); +const injectionKey: InjectionKey = Symbol('socketIO'); export const useSocketIO = () => inject(injectionKey, null); +export const useRequiredSocketIO = (): AppSocket => { + const socket = inject(injectionKey, null); + if (!socket) throw new Error('Socket not available'); + return socket; +}; + export const vueSocketIOClient: Plugin = { install: async (app: App, options: SocketPluginOptions): Promise => { - const socketIO = io(options.connection, options); + const socketIO: AppSocket = io(options.connection, options); - socketIO.on("connect", () => { + socketIO.on('connect', () => { console.log(`WebSocket connection established: ${socketIO.id}`); }); diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index 8aaff9a..9da8341 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -1,45 +1,45 @@ // Styles -import "@mdi/font/css/materialdesignicons.css"; -import "vuetify/styles"; +import '@mdi/font/css/materialdesignicons.css'; +import 'vuetify/styles'; // Vuetify -import { createVuetify } from "vuetify"; -import type { App, Plugin } from "vue"; -import * as components from "vuetify/components"; -import * as directives from "vuetify/directives"; -import { aliases, mdi } from "vuetify/iconsets/mdi"; +import { createVuetify } from 'vuetify'; +import type { App, Plugin } from 'vue'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import { aliases, mdi } from 'vuetify/iconsets/mdi'; const dark = { dark2: true, colors: { - primary: "#00BD7E", - "primary-darken-1": "#008055", - "primary-darken-2": "#004d33", - "primary-darken-3": "#094732", - secondary: "#03DAC6", - "secondary-darken-1": "#018786", - error: "#B00020", - info: "#2196F3", - success: "#4CAF50", - warning: "#FB8C00", - logo: "#eee", + primary: '#00BD7E', + 'primary-darken-1': '#008055', + 'primary-darken-2': '#004d33', + 'primary-darken-3': '#094732', + secondary: '#03DAC6', + 'secondary-darken-1': '#018786', + error: '#B00020', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + logo: '#eee', }, }; const light = { dark: false, colors: { - primary: "#00BD7E", - "primary-darken-1": "#008055", - "primary-darken-2": "#004d33", - "primary-darken-3": "#094732", - secondary: "#03DAC6", - "secondary-darken-1": "#018786", - error: "#B00020", - info: "#2196F3", - success: "#4CAF50", - warning: "#FB8C00", - logo: "#222", + primary: '#00BD7E', + 'primary-darken-1': '#008055', + 'primary-darken-2': '#004d33', + 'primary-darken-3': '#094732', + secondary: '#03DAC6', + 'secondary-darken-1': '#018786', + error: '#B00020', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + logo: '#222', }, }; @@ -49,14 +49,14 @@ export const vuetify: Plugin = { components, directives, icons: { - defaultSet: "mdi", + defaultSet: 'mdi', aliases, sets: { mdi, }, }, theme: { - defaultTheme: "dark", + defaultTheme: 'dark', themes: { dark, light, diff --git a/src/plugins/webfontloader.ts b/src/plugins/webfontloader.ts index e86aa7d..51caf7e 100644 --- a/src/plugins/webfontloader.ts +++ b/src/plugins/webfontloader.ts @@ -6,12 +6,12 @@ export async function loadFonts() { const webFontLoader = await import( - /* webpackChunkName: "webfontloader" */ "webfontloader" + /* webpackChunkName: "webfontloader" */ 'webfontloader' ); webFontLoader.load({ google: { - families: ["Roboto:100,300,400,500,700,900&display=swap"], + families: ['Roboto:100,300,400,500,700,900&display=swap'], }, }); } diff --git a/src/router/index.ts b/src/router/index.ts index 12bf0d8..54aaadf 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,62 +1,62 @@ -import { createRouter, createWebHistory } from "vue-router"; -import LayoutWithMenu from "../layouts/LayoutWithMenu.vue"; -import {useBackendStore} from "@/stores/backend"; +import { createRouter, createWebHistory } from 'vue-router'; +import LayoutWithMenu from '../layouts/LayoutWithMenu.vue'; +import {useBackendStore} from '@/stores/backend'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { - path: "/", - name: "welcome", + path: '/', + name: 'welcome', components: { - default: () => import("../views/WelcomeView.vue"), + default: () => import('../views/WelcomeView.vue'), layout: LayoutWithMenu, }, }, { - path: "/mission-control", - name: "mission-control", + path: '/mission-control', + name: 'mission-control', components: { - default: () => import("../views/MissionControlView.vue"), + default: () => import('../views/MissionControlView.vue'), layout: LayoutWithMenu, }, }, { - path: "/mission-control/device/:id", - component: () => import("../views/MissionControlSingleView.vue"), + path: '/mission-control/device/:id', + component: () => import('../views/MissionControlSingleView.vue'), }, { - path: "/automation", - name: "automation", + path: '/automation', + name: 'automation', components: { - default: () => import("../views/AutomationView.vue"), + default: () => import('../views/AutomationView.vue'), layout: LayoutWithMenu, }, }, { - path: "/devices", - name: "devices", + path: '/devices', + name: 'devices', // route level code-splitting // this generates a separate chunk (About.[hash].js) for this route // which is lazy-loaded when the route is visited. components: { - default: () => import("../views/DevicesView.vue"), + default: () => import('../views/DevicesView.vue'), layout: LayoutWithMenu, }, }, { - path: "/health", - name: "health", + path: '/health', + name: 'health', components: { - default: () => import("../views/SystemHealthView.vue"), + default: () => import('../views/SystemHealthView.vue'), layout: LayoutWithMenu, }, }, { - path: "/settings", - name: "serverSettings", + path: '/settings', + name: 'serverSettings', components: { - default: () => import("../views/SettingsView.vue"), + default: () => import('../views/SettingsView.vue'), layout: LayoutWithMenu, }, }, @@ -69,7 +69,7 @@ const router = createRouter({ path: '/:pathMatch(.*)*', name: 'not-found', components: { - default: () => import("../views/NotFoundView.vue"), + default: () => import('../views/NotFoundView.vue'), layout: LayoutWithMenu, }, }, diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts index ef2e3a1..8be38ab 100644 --- a/src/shims-vue.d.ts +++ b/src/shims-vue.d.ts @@ -1,9 +1,9 @@ -declare module "*.vue"; -declare module "*.js"; -import type { Socket } from "socket.io-client"; +declare module '*.vue'; +declare module '*.js'; +import type { AppSocket } from '@/types/socket'; -declare module "@vue/runtime-core" { +declare module '@vue/runtime-core' { export interface ComponentCustomProperties { - $socket: Socket; + $socket: AppSocket; } } diff --git a/src/shims-vuetify.d.ts b/src/shims-vuetify.d.ts index edeacab..0b620dd 100644 --- a/src/shims-vuetify.d.ts +++ b/src/shims-vuetify.d.ts @@ -1,3 +1,3 @@ -declare module "vuetify"; -declare module "vuetify/lib/components"; -declare module "vuetify/lib/directives"; +declare module 'vuetify'; +declare module 'vuetify/lib/components'; +declare module 'vuetify/lib/directives'; diff --git a/src/stores/app.ts b/src/stores/app.ts index 09a7612..4997b81 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,5 +1,5 @@ -import { defineStore } from "pinia"; -import { reactive } from "vue"; +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; export type AppState = { snackbar: { @@ -16,19 +16,19 @@ export type AppState = { getVersion(): string; }; -export const useAppStore = defineStore("app", (): AppState => { +export const useAppStore = defineStore('app', (): AppState => { // reactive state - const snackbar = reactive({ + const snackbar = reactive({ display: false, - text: "", - color: "primary", + text: '', + color: 'primary', timeout: 5000, }); // actions function displaySnackbar( text: string, - color: string = "primary", + color: string = 'primary', timeout: number = 5000 ): void { snackbar.text = text; diff --git a/src/stores/automation.ts b/src/stores/automation.ts index 6501ea9..d7d0b96 100644 --- a/src/stores/automation.ts +++ b/src/stores/automation.ts @@ -1,9 +1,11 @@ -import { defineStore } from "pinia"; -import { ref, computed } from "vue"; -import type AutomationScript from "../model/AutomationScript"; -import {apiFetch} from "@/utils/apiFetch"; +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import type AutomationScript from '../model/AutomationScript'; +import {apiFetch} from '@/utils/apiFetch'; -const defaultCode = `onStart(async () => { +const defaultCode = `// To access any device you can use the global variable "devices" -> devices.getById("{device uuid}") + +onStart(async () => { // This function will fire once when your script starts. // You can initialize variables or set device attributes here. }); @@ -13,19 +15,24 @@ onStop(async () => { // You can clean up any resources or reset device attributes here. }); -onEvent(async (event) => { - // This function will be called on any device event. Possible events are: - // - // - deviceConnected: A new device was connected to SlvCtrl+ - // - deviceDisconnected: A device was disconnected from SlvCtrl+ - // - deviceRefreshed: New data has been pulled from a device - // - // You can get information about the event and the device that invoked your script through the "event" - // variable. To access other devices you can use the global variable "devices" -> devices.getById("{device uuid}") +onEvent('deviceConnected', async (device: Device) => { + // This function will be called when a new device was connected to SlvCtrl+ +}); + +onEvent('deviceDisconnected', async (device: Device) => { + // This function will be called when a device was disconnected from SlvCtrl+ +}); + +onEvent('deviceRefreshed', async (device: Device) => { + // This function will be called when new data has been pulled from a device +}); + +onEvent('deviceNotification', async (device: Device, notification: DeviceNotification) => { + // This function will be called when a device sends a notification }); `; -export const useAutomationStore = defineStore("automation", () => { +export const useAutomationStore = defineStore('automation', () => { // State const scripts = ref([]); const currentScriptName = ref(null); @@ -58,7 +65,7 @@ export const useAutomationStore = defineStore("automation", () => { data.items.forEach((v: AutomationScript) => { scripts.value.push(v); }); - console.log("List of scripts updated"); + console.log('List of scripts updated'); return scripts.value; } @@ -76,11 +83,11 @@ export const useAutomationStore = defineStore("automation", () => { } async function saveScript(): Promise { - if (!currentScriptName.value) throw new Error("No script selected to save"); + if (!currentScriptName.value) throw new Error('No script selected to save'); const response = await apiFetch(`/automation/scripts/${currentScriptName.value}`, { - headers: { "Content-Type": "text/plain" }, - method: "POST", + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', body: currentCode.value, }); if (response.status !== 201) { @@ -90,8 +97,8 @@ export const useAutomationStore = defineStore("automation", () => { async function deleteScript(scriptName: string): Promise { const response = await apiFetch(`/automation/scripts/${scriptName}`, { - headers: { "Content-Type": "text/plain" }, - method: "DELETE", + headers: { 'Content-Type': 'text/plain' }, + method: 'DELETE', }); if (response.status !== 204) { throw new Error(`Could not delete script "${scriptName}": ${response.statusText}`); @@ -100,8 +107,8 @@ export const useAutomationStore = defineStore("automation", () => { async function runScript(): Promise { const response = await apiFetch(`/automation/run`, { - headers: { "Content-Type": "text/plain" }, - method: "POST", + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', body: currentCode.value, }); if (response.status !== 200) { diff --git a/src/stores/backend.ts b/src/stores/backend.ts index a8f04b4..a1628b4 100644 --- a/src/stores/backend.ts +++ b/src/stores/backend.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref } from "vue"; +import { ref } from 'vue'; export const useBackendStore = defineStore('backend', () => { @@ -29,7 +29,7 @@ export const useBackendStore = defineStore('backend', () => { localStorage.setItem(backendUrlLocalStoreName, url); backendUrl.value = url; } catch (error) { - console.error("Error setting backend URL:", error); + console.error('Error setting backend URL:', error); } if (!history.value.includes(url)) { @@ -42,7 +42,7 @@ export const useBackendStore = defineStore('backend', () => { try { localStorage.setItem(historyLocalStoreName, JSON.stringify(history.value)); } catch (error) { - console.error("Error saving backend history:", error); + console.error('Error saving backend history:', error); } } } @@ -54,7 +54,7 @@ export const useBackendStore = defineStore('backend', () => { wasServerEverOnline.value = false; isServerOnline.value = false; } catch (error) { - console.error("Error clearing backend URL:", error); + console.error('Error clearing backend URL:', error); } } @@ -69,7 +69,7 @@ export const useBackendStore = defineStore('backend', () => { try { localStorage.setItem(historyLocalStoreName, JSON.stringify(history.value)); } catch (error) { - console.error("Error saving backend history:", error); + console.error('Error saving backend history:', error); } } diff --git a/src/stores/deviceNotifications.ts b/src/stores/deviceNotifications.ts new file mode 100644 index 0000000..08bee77 --- /dev/null +++ b/src/stores/deviceNotifications.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type Device from '@/model/devices/Device'; + +export type DeviceNotification = { + type: string; + data: Record; +}; + +export type DeviceNotificationEvent = { + device: Device; + notification: DeviceNotification; + timestamp: number; +}; + +export const useDeviceNotificationsStore = defineStore('deviceNotifications', () => { + // Map of deviceId -> latest notification event + const latest = ref>({}); + + function dispatch(device: Device, notification: DeviceNotification): void { + latest.value[device.deviceId] = { device, notification, timestamp: Date.now() }; + } + + function getLatest(deviceId: string): DeviceNotificationEvent | undefined { + return latest.value[deviceId]; + } + + function remove(deviceId: string): void { + delete latest.value[deviceId]; + } + + function clear(): void { + latest.value = {}; + } + + return { + latest, + dispatch, + getLatest, + remove, + clear, + }; +}); diff --git a/src/stores/devices.ts b/src/stores/devices.ts index 25a3224..ff536f1 100644 --- a/src/stores/devices.ts +++ b/src/stores/devices.ts @@ -1,9 +1,10 @@ -import { defineStore } from "pinia"; -import { ref, reactive, computed } from "vue"; -import {apiFetch} from "@/utils/apiFetch"; -import type Device from "@/model/devices/Device"; +import { defineStore } from 'pinia'; +import { ref, reactive, computed } from 'vue'; +import {apiFetch} from '@/utils/apiFetch'; +import type Device from '@/model/devices/Device'; +import { deepMergeReactive } from '@/utils/utils'; -export const useDevicesStore = defineStore("devices", () => { +export const useDevicesStore = defineStore('devices', () => { // State: use reactive for objects, ref for primitives const devices = reactive<{ [key: string]: Device }>({}); const devicesLoaded = ref(false); @@ -40,10 +41,20 @@ export const useDevicesStore = defineStore("devices", () => { return; } - device.lastRefresh = updatedDevice.lastRefresh; + for (const key of Object.keys(device.attributes)) { + const incomingAttr = updatedDevice.attributes?.[key]; + if (incomingAttr && !('value' in incomingAttr)) { + const attr = device.attributes[key]; + if (attr) attr.value = undefined; + } + } + + deepMergeReactive(device, updatedDevice); + } - if ('attributes' in device && 'attributes' in updatedDevice) { - device.attributes = updatedDevice.attributes; + function clear() { + for (const key in devices) { + delete devices[key]; } } @@ -52,6 +63,7 @@ export const useDevicesStore = defineStore("devices", () => { devicesLoaded, deviceList, init, + clear, removeDevice, addDevice, updateDevice, diff --git a/src/stores/health.ts b/src/stores/health.ts index 464ecad..4ca877e 100644 --- a/src/stores/health.ts +++ b/src/stores/health.ts @@ -1,55 +1,22 @@ -import { defineStore } from "pinia"; -import { ref, reactive, type Ref } from "vue"; -import type { ChartData } from "chart.js"; -import ChartHelper from "../helper/ChartHelper"; -import type { Socket } from "socket.io-client"; - -interface SystemInfo { - process: { - memoryUsage: { - rss: number; - heapTotal: number; - heapUsed: number; - external: number; - }; - }; - system: { - cpu: { - usage: number; - model: string; - cores: string; - }; - memory: { - totalMemMb: number; - usedMemMb: number; - freeMemMb: number; - usedMemPercentage: number; - freeMemPercentage: number; - }; - os: { - name: string; - arch: string; - type: string; - }; - uptime: number; - hostname: string; - ip: string; - }; -} +import { defineStore } from 'pinia'; +import { ref, reactive, type Ref } from 'vue'; +import type { ChartData } from 'chart.js'; +import ChartHelper from '../helper/ChartHelper'; +import type { AppSocket, SystemInfo } from '@/types/socket'; type HealthChartData = { - processMemory: ChartData<"line">; - systemCpu: ChartData<"line">; - systemMemory: ChartData<"line">; + processMemory: ChartData<'line'>; + systemCpu: ChartData<'line'>; + systemMemory: ChartData<'line'>; } type HealthStore = { state: Ref; chartData: HealthChartData; - init: (socket: Socket) => void; + init: (socket: AppSocket) => void; }; -export const useHealthStore = defineStore("health", (): HealthStore => { +export const useHealthStore = defineStore('health', (): HealthStore => { // state refs/reactive const state = ref(undefined); @@ -58,43 +25,43 @@ export const useHealthStore = defineStore("health", (): HealthStore => { processMemory: { datasets: [ ChartHelper.createEmptyDataSet({ - label: "Resident Set Size", + label: 'Resident Set Size', color: { r: 0, g: 189, b: 126 }, tension: 0, }), ChartHelper.createEmptyDataSet({ - label: "Heap total", color: { r: 0, g: 189, b: 126 }, tension: 0, + label: 'Heap total', color: { r: 0, g: 189, b: 126 }, tension: 0, }), ChartHelper.createEmptyDataSet({ - label: "Heap used", + label: 'Heap used', color: { r: 204, g: 0, b: 0 }, tension: 0, }), ], - } as ChartData<"line">, + } as ChartData<'line'>, systemCpu: { datasets: [ ChartHelper.createEmptyDataSet({ - label: "Percentage", + label: 'Percentage', color: { r: 0, g: 189, b: 126 }, tension: 0, }), ], - } as ChartData<"line">, + } as ChartData<'line'>, systemMemory: { datasets: [ ChartHelper.createEmptyDataSet({ - label: "Percentage", + label: 'Percentage', color: { r: 0, g: 189, b: 126 }, tension: 0, }), ], - } as ChartData<"line">, + } as ChartData<'line'>, }); // actions - function init(socket: Socket): void { - socket.on("healthMetrics", (data: SystemInfo) => { + function init(socket: AppSocket): void { + socket.on('healthMetrics', (data) => { state.value = data; if (!chartData) { diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 9b4f0ba..5863e3e 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,6 +1,6 @@ -import { defineStore } from "pinia"; -import { ref } from "vue"; -import { apiFetch } from "@/utils/apiFetch"; +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { apiFetch } from '@/utils/apiFetch'; type ValidationErrors = { message: string; @@ -14,10 +14,10 @@ type ValidationError = { keyword: string; } -export const useSettingsStore = defineStore("serverSettings", () => { +export const useSettingsStore = defineStore('serverSettings', () => { // state as refs - const theme = ref("dark"); - const serverSettings = ref(""); + const theme = ref('dark'); + const serverSettings = ref(''); const validationErrors = ref(null); // actions @@ -27,8 +27,8 @@ export const useSettingsStore = defineStore("serverSettings", () => { async function saveServerSettings(): Promise { const response = await apiFetch(`/settings`, { - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - method: "PUT", + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + method: 'PUT', body: serverSettings.value, }); if (response.status !== 200) { diff --git a/src/types/socket.ts b/src/types/socket.ts new file mode 100644 index 0000000..123f96c --- /dev/null +++ b/src/types/socket.ts @@ -0,0 +1,52 @@ +import type { Socket } from 'socket.io-client'; +import type Device from '@/model/devices/Device'; +import type { DeviceNotification } from '@/stores/deviceNotifications'; + +export interface SystemInfo { + process: { + memoryUsage: { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + }; + }; + system: { + cpu: { + usage: number; + model: string; + cores: string; + }; + memory: { + totalMemMb: number; + usedMemMb: number; + freeMemMb: number; + usedMemPercentage: number; + freeMemPercentage: number; + }; + os: { + name: string; + arch: string; + type: string; + }; + uptime: number; + hostname: string; + ip: string; + }; +} + +export interface ServerToClientEvents { + deviceConnected: (device: Device) => void; + deviceDisconnected: (device: Device) => void; + deviceRefreshed: (device: Device) => void; + deviceNotification: (device: Device, notification: DeviceNotification) => void; + automationConsoleLog: (data: string) => void; + settingsChanged: () => void; + healthMetrics: (data: SystemInfo) => void; +} + +export interface ClientToServerEvents { + deviceUpdate: (payload: { deviceId: string; data: Record }) => void; +} + +export type AppSocket = Socket; diff --git a/src/types.d.ts b/src/types/utility.ts similarity index 100% rename from src/types.d.ts rename to src/types/utility.ts diff --git a/src/utils/apiFetch.ts b/src/utils/apiFetch.ts index 27444cf..7812302 100644 --- a/src/utils/apiFetch.ts +++ b/src/utils/apiFetch.ts @@ -1,5 +1,5 @@ import { useBackendStore } from '@/stores/backend'; -import { useAppStore } from "@/stores/app"; +import { useAppStore } from '@/stores/app'; export async function apiFetch(path: string, options?: RequestInit): Promise { const backendStore = useBackendStore(); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e44c372..6d06023 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,4 @@ -import type {IntRangeDeviceAttribute, ListDeviceAttribute} from "@/model/devices/Device"; +import type {BoolDeviceAttribute, FloatDeviceAttribute, IntDeviceAttribute, IntRangeDeviceAttribute, ListDeviceAttribute, StrDeviceAttribute} from '@/model/devices/Device'; type StringKey = Extract; @@ -6,6 +6,30 @@ export const getTypedKeys = (obj: T): (StringKey)[] => { return Object.keys(obj) as StringKey[]; } + +export const deepMergeReactive = (target: T, source: Partial): void => { + for (const key of getTypedKeys(source)) { + const sourceVal = source[key]; + const targetVal = target[key]; + + if (Array.isArray(sourceVal)) { + // Replace arrays entirely (not deep merge) + (target as { [K in keyof T]: T[K] })[key] = sourceVal as T[typeof key]; + } else if ( + sourceVal && + typeof sourceVal === 'object' && + !Array.isArray(sourceVal) && + targetVal && + typeof targetVal === 'object' && + !Array.isArray(targetVal) + ) { + deepMergeReactive(targetVal, sourceVal as Partial); + } else if (sourceVal !== undefined) { + (target as { [K in keyof T]: T[K] })[key] = sourceVal as T[typeof key]; + } + } +} + type RequiredKeys = { [K in keyof T]-?: Exclude }; @@ -28,15 +52,27 @@ export const isListDeviceAttribute = (obj: object): obj is ListDeviceAttribute { + return hasType(obj, 'str'); +} + +export const isIntDeviceAttribute = (obj: object): obj is IntDeviceAttribute => { + return hasType(obj, 'int'); +} + +export const isFloatDeviceAttribute = (obj: object): obj is FloatDeviceAttribute => { + return hasType(obj, 'float'); +} + +export const isBoolDeviceAttribute = (obj: object): obj is BoolDeviceAttribute => { + return hasType(obj, 'bool'); +} + export const hasType = (obj: unknown, expectedType: string): boolean => { return ( typeof obj === 'object' && obj !== null && - obj.hasOwnProperty('type') && - (obj as { type?: string }).type === expectedType + 'type' in obj && + obj.type === expectedType ); } - -export const hasProperty = (obj: O, prop: P): obj is O & Record => { - return obj != null && typeof obj === 'object' && prop in obj; -} diff --git a/src/views/AutomationView.vue b/src/views/AutomationView.vue index 70f840b..c6e7a4a 100644 --- a/src/views/AutomationView.vue +++ b/src/views/AutomationView.vue @@ -1,12 +1,12 @@