From 967a2039d02eeaf90de09e2668069b58d5d62bd1 Mon Sep 17 00:00:00 2001 From: mmatloch Date: Tue, 4 Jun 2024 16:33:41 +0200 Subject: [PATCH] feat(): use sensor data as context in widgets --- .../src/definitions/entities/widgetTypes.ts | 1 + .../frontend/src/definitions/localeTypes.ts | 1 + .../TextLine/TextLineFromDeviceContext.tsx | 41 ++++++++++++++---- .../TextLine/TextLineFromEventContext.tsx | 5 ++- .../widgets/components/WidgetTextLineForm.tsx | 1 + packages/frontend/src/locales/en.ts | 1 + packages/frontend/src/locales/pl.ts | 1 + .../src/definitions/deviceDefinitions.ts | 1 + packages/server/src/entities/widgetEntity.ts | 1 + packages/server/src/events/sdks/sdk.ts | 2 + packages/server/src/events/sdks/sysInfoSdk.ts | 5 +++ .../server/src/services/sensorDataService.ts | 25 ++++++++++- .../server/src/services/widgetsService.ts | 32 +++++++++++++- packages/static/greenhouse_sun.png | Bin 0 -> 3904 bytes packages/static/greenhouse_water.png | Bin 0 -> 3318 bytes 15 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 packages/server/src/events/sdks/sysInfoSdk.ts create mode 100644 packages/static/greenhouse_sun.png create mode 100644 packages/static/greenhouse_water.png diff --git a/packages/frontend/src/definitions/entities/widgetTypes.ts b/packages/frontend/src/definitions/entities/widgetTypes.ts index 8b200d5c..cf918c2a 100644 --- a/packages/frontend/src/definitions/entities/widgetTypes.ts +++ b/packages/frontend/src/definitions/entities/widgetTypes.ts @@ -5,6 +5,7 @@ import type { GenericEntity } from '../commonTypes'; export interface WidgetTextLine { deviceId: number | null; eventId: number | null; + useDeviceSensorData: boolean; value: string; id: string; styles: Record; diff --git a/packages/frontend/src/definitions/localeTypes.ts b/packages/frontend/src/definitions/localeTypes.ts index e5367f91..e95dfdcb 100644 --- a/packages/frontend/src/definitions/localeTypes.ts +++ b/packages/frontend/src/definitions/localeTypes.ts @@ -331,6 +331,7 @@ export interface Locale { icon: string; }; addTextLine: string; + useDeviceSensorData: string; addAction: string; removeAction: string; actionOnDefinition: string; diff --git a/packages/frontend/src/features/widgets/components/TextLine/TextLineFromDeviceContext.tsx b/packages/frontend/src/features/widgets/components/TextLine/TextLineFromDeviceContext.tsx index c8158de5..c1a987df 100644 --- a/packages/frontend/src/features/widgets/components/TextLine/TextLineFromDeviceContext.tsx +++ b/packages/frontend/src/features/widgets/components/TextLine/TextLineFromDeviceContext.tsx @@ -1,8 +1,10 @@ import DeviceAutocomplete from '@components/devices/DeviceAutocomplete'; import DeviceAutocompleteWrapper from '@components/devices/DeviceAutocompleteWrapper'; +import FormCheckbox from '@components/forms/FormCheckbox'; import { Device } from '@definitions/entities/deviceTypes'; import { useTextLinesForm } from '@features/widgets/hooks/useTextLinesForm'; import { useWidgetForm } from '@features/widgets/hooks/useWidgetForm'; +import { Stack } from '@mui/material'; import { useTranslation } from 'react-i18next'; interface Props { @@ -10,16 +12,19 @@ interface Props { } export const TextLineFromDeviceContext = ({ lineIndex }: Props) => { - const { t } = useTranslation(); + const { t } = useTranslation(['generic', 'widgets']); const { update } = useTextLinesForm(); const { watch } = useWidgetForm(); const textLines = watch('textLines'); - const handleDeviceSelect = (_e: unknown, device: Device) => { + const useDeviceSensorDataName = `textLines.${lineIndex}.useDeviceSensorData`; + + const handleDeviceSelect = (_e: unknown, device?: Device) => { update(lineIndex, { id: textLines[lineIndex].id, value: textLines[lineIndex].value, - deviceId: device._id, + deviceId: device?._id ?? null, + useDeviceSensorData: textLines[lineIndex].useDeviceSensorData, eventId: null, styles: textLines[lineIndex].styles, }); @@ -29,15 +34,33 @@ export const TextLineFromDeviceContext = ({ lineIndex }: Props) => { if (currentDeviceId) { return ( - + + + + ); } return ( - + + + + + ); }; diff --git a/packages/frontend/src/features/widgets/components/TextLine/TextLineFromEventContext.tsx b/packages/frontend/src/features/widgets/components/TextLine/TextLineFromEventContext.tsx index fe2eaa90..d4f27e15 100644 --- a/packages/frontend/src/features/widgets/components/TextLine/TextLineFromEventContext.tsx +++ b/packages/frontend/src/features/widgets/components/TextLine/TextLineFromEventContext.tsx @@ -15,12 +15,13 @@ export const TextLineFromEventContext = ({ lineIndex }: Props) => { const { watch } = useWidgetForm(); const textLines = watch('textLines'); - const handleEventSelect = (_e: unknown, event: Event) => { + const handleEventSelect = (_e: unknown, event?: Event) => { update(lineIndex, { id: textLines[lineIndex].id, value: textLines[lineIndex].value, - eventId: event._id, + eventId: event?._id ?? null, deviceId: null, + useDeviceSensorData: false, styles: textLines[lineIndex].styles, }); }; diff --git a/packages/frontend/src/features/widgets/components/WidgetTextLineForm.tsx b/packages/frontend/src/features/widgets/components/WidgetTextLineForm.tsx index 33ffd29c..317a12af 100644 --- a/packages/frontend/src/features/widgets/components/WidgetTextLineForm.tsx +++ b/packages/frontend/src/features/widgets/components/WidgetTextLineForm.tsx @@ -12,6 +12,7 @@ export const WidgetTextLineForm = () => { append({ value: '', deviceId: null, + useDeviceSensorData: false, eventId: null, id: window.crypto.randomUUID(), styles: {}, diff --git a/packages/frontend/src/locales/en.ts b/packages/frontend/src/locales/en.ts index bbf43760..fe363c90 100644 --- a/packages/frontend/src/locales/en.ts +++ b/packages/frontend/src/locales/en.ts @@ -389,6 +389,7 @@ export const EnglishLocale: Locale = { icon: 'home.png', }, addTextLine: 'Add text line', + useDeviceSensorData: 'Use Sensor Data as the context', addAction: 'Add action', removeAction: 'Remove action', actionOnDefinition: 'Switch on action definition', diff --git a/packages/frontend/src/locales/pl.ts b/packages/frontend/src/locales/pl.ts index 5a6afbf6..63e7ad8b 100644 --- a/packages/frontend/src/locales/pl.ts +++ b/packages/frontend/src/locales/pl.ts @@ -389,6 +389,7 @@ export const PolishLocale: Locale = { icon: 'home.png', }, addTextLine: 'Dodaj wiersz tekstu', + useDeviceSensorData: 'Użyj danych czujnika jako kontekstu', addAction: 'Dodaj akcję', removeAction: 'Usuń akcję', actionOnDefinition: 'Określenie akcji włącznika', diff --git a/packages/server/src/definitions/deviceDefinitions.ts b/packages/server/src/definitions/deviceDefinitions.ts index 34dd2705..b41c2dd3 100644 --- a/packages/server/src/definitions/deviceDefinitions.ts +++ b/packages/server/src/definitions/deviceDefinitions.ts @@ -20,6 +20,7 @@ export enum DevicePowerSource { export enum DeviceProtocol { Zigbee = 'ZIGBEE', + Virtual = 'VIRTUAL', } export enum DeviceState { diff --git a/packages/server/src/entities/widgetEntity.ts b/packages/server/src/entities/widgetEntity.ts index 9e35a98a..0025b154 100644 --- a/packages/server/src/entities/widgetEntity.ts +++ b/packages/server/src/entities/widgetEntity.ts @@ -28,6 +28,7 @@ const widgetTextLineSchema = Type.Object({ id: Type.String(), value: Type.String(), deviceId: Type.Union([Type.Null(), Type.Integer()]), + useDeviceSensorData: Type.Boolean(), eventId: Type.Union([Type.Null(), Type.Integer()]), styles: Type.Record(Type.String(), Type.Unknown(), { default: {}, diff --git a/packages/server/src/events/sdks/sdk.ts b/packages/server/src/events/sdks/sdk.ts index 10b01bc4..e2176ebc 100644 --- a/packages/server/src/events/sdks/sdk.ts +++ b/packages/server/src/events/sdks/sdk.ts @@ -1,5 +1,6 @@ import { createDevicesSdk } from './devicesSdk'; import { createEventsSdk } from './eventsSdk'; +import { createSysInfoSdk } from './sysInfoSdk'; export type EventRunSdk = Record; @@ -7,5 +8,6 @@ export const createEventRunSdk = (): EventRunSdk => { return { devices: createDevicesSdk(), events: createEventsSdk(), + sysInfo: createSysInfoSdk(), }; }; diff --git a/packages/server/src/events/sdks/sysInfoSdk.ts b/packages/server/src/events/sdks/sysInfoSdk.ts new file mode 100644 index 00000000..2ee3e059 --- /dev/null +++ b/packages/server/src/events/sdks/sysInfoSdk.ts @@ -0,0 +1,5 @@ +import systeminformation from 'systeminformation'; + +export const createSysInfoSdk = () => { + return systeminformation; +}; diff --git a/packages/server/src/services/sensorDataService.ts b/packages/server/src/services/sensorDataService.ts index 281e6807..3a6bca3e 100644 --- a/packages/server/src/services/sensorDataService.ts +++ b/packages/server/src/services/sensorDataService.ts @@ -1,9 +1,13 @@ +import { In } from 'typeorm'; + import type { SensorData, SensorDataDto } from '../entities/sensorDataEntity'; import { createSensorDataRepository } from '../repositories/sensorDataRepository'; import type { GenericService } from './genericService'; export interface SensorDataService - extends Pick, 'create' | 'search' | 'searchAndCount'> {} + extends Pick, 'create' | 'search' | 'searchAndCount'> { + getLatestForDevices: (deviceIds: number[]) => Promise; +} export const createSensorDataService = (): SensorDataService => { const repository = createSensorDataRepository(); @@ -22,9 +26,28 @@ export const createSensorDataService = (): SensorDataService => { return repository.findAndCount(query); }; + const getLatestForDevices: SensorDataService['getLatestForDevices'] = (deviceIds) => { + if (!deviceIds.length) { + return Promise.resolve([]); + } + + return repository + .createQueryBuilder() + .where({ + deviceId: In(deviceIds), + }) + .distinctOn(['"deviceId"']) + .orderBy({ + '"deviceId"': 'ASC', + '"_createdAt"': 'DESC', + }) + .getMany(); + }; + return { create, search, searchAndCount, + getLatestForDevices, }; }; diff --git a/packages/server/src/services/widgetsService.ts b/packages/server/src/services/widgetsService.ts index 8ec9c5eb..e93563ac 100644 --- a/packages/server/src/services/widgetsService.ts +++ b/packages/server/src/services/widgetsService.ts @@ -3,6 +3,7 @@ import { FindManyOptions, In } from 'typeorm'; import { Device } from '../entities/deviceEntity'; import { Event } from '../entities/eventEntity'; +import { SensorData } from '../entities/sensorDataEntity'; import { Widget, WidgetActionDto, WidgetActionEntry, WidgetDto, WidgetWithActionState } from '../entities/widgetEntity'; import { Errors } from '../errors'; import { eventTriggerInNewContext } from '../events/eventTriggerInNewContext'; @@ -13,6 +14,7 @@ import { WidgetProcessContext, createWidgetProcessor } from '../widgets/widgetPr import { createDevicesService } from './devicesService'; import { createEventsService } from './eventsService'; import type { GenericService } from './genericService'; +import { createSensorDataService } from './sensorDataService'; export interface WidgetsService extends Omit, 'search' | 'searchAndCount'> { @@ -26,6 +28,7 @@ export interface WidgetsService export const createWidgetsService = (): WidgetsService => { const repository = createWidgetsRepository(); const devicesService = createDevicesService(); + const sensorDataService = createSensorDataService(); const eventsService = createEventsService(); const create: WidgetsService['create'] = async (dto) => { @@ -75,7 +78,20 @@ export const createWidgetsService = (): WidgetsService => { const parseTextLines = async (widgets: Widget[] | WidgetDto[]) => { const deviceIds = widgets - .map((widget) => widget.textLines.map((textLine) => textLine.deviceId)) + .map((widget) => + widget.textLines + .filter((textLine) => textLine.deviceId && !textLine.useDeviceSensorData) + .map((textLine) => textLine.deviceId), + ) + .flat() + .filter(isNumber); + + const sensorDataDeviceIds = widgets + .map((widget) => + widget.textLines + .filter((textLine) => textLine.deviceId && textLine.useDeviceSensorData) + .map((textLine) => textLine.deviceId), + ) .flat() .filter(isNumber); @@ -90,14 +106,24 @@ export const createWidgetsService = (): WidgetsService => { }, }); + const sensorData = await sensorDataService.getLatestForDevices(sensorDataDeviceIds); + const events = await eventsService.search({ where: { _id: In(eventIds), }, }); - const getTextLineContext = (textLine: WidgetDto['textLines'][0]): Device | Event | Record => { + console.log(sensorData, sensorDataDeviceIds); + + const getTextLineContext = ( + textLine: WidgetDto['textLines'][0], + ): Device | Event | SensorData | Record => { if (textLine.deviceId) { + if (textLine.useDeviceSensorData) { + return sensorData.find((sensorData) => sensorData.deviceId === textLine.deviceId) || {}; + } + return devices.find((device) => device._id === textLine.deviceId) || {}; } @@ -112,6 +138,8 @@ export const createWidgetsService = (): WidgetsService => { widget.textLines.forEach((textLine) => { const context = getTextLineContext(textLine); + console.log(textLine.id, context); + textLine.value = parseWidgetText(textLine.value, context); }); }); diff --git a/packages/static/greenhouse_sun.png b/packages/static/greenhouse_sun.png new file mode 100644 index 0000000000000000000000000000000000000000..df6b1f97f31c9679c645cf706315a73cf836f481 GIT binary patch literal 3904 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 URk)?jO}+#e8<^|Y>bl1M3xCE7tN;K2 literal 0 HcmV?d00001