From 05e61e7bf894ccd095836ef8bb8d6fc0e8cca462 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 23 Nov 2024 22:20:07 +0100 Subject: [PATCH] feat: automation service --- .../hooks-query/useAutomationSettings.ts | 34 +++ .../src/common/models/AutomationSettings.ts | 9 + .../__tests__/automation.dao.test.ts | 232 ++++++++++++++++++ .../__tests__/automation.service.test.ts | 92 +++++++ .../automation/__tests__/testUtils.ts | 20 ++ .../automation/automation.controller.ts | 118 +++++++++ .../src/api-data/automation/automation.dao.ts | 178 ++++++++++++++ .../api-data/automation/automation.parser.ts | 29 +++ .../api-data/automation/automation.router.ts | 37 +++ .../api-data/automation/automation.service.ts | 99 ++++++++ .../api-data/automation/automation.utils.ts | 11 + .../automation/automation.validation.ts | 177 +++++++++++++ .../automation/clients/http.client.ts | 18 ++ .../api-data/automation/clients/osc.client.ts | 18 ++ apps/server/src/api-data/db/db.controller.ts | 14 +- apps/server/src/api-data/index.ts | 2 + .../src/classes/data-provider/DataProvider.ts | 15 ++ .../data-provider/DataProvider.utils.ts | 4 +- .../__tests__/DataProvider.utils.test.ts | 7 + apps/server/src/models/dataModel.ts | 7 + apps/server/src/models/demoProject.ts | 7 + apps/server/src/utils/assert.ts | 17 ++ apps/server/src/utils/parser.ts | 4 + apps/server/src/utils/parserFunctions.ts | 4 +- .../types/src/definitions/DataModel.type.ts | 2 + .../src/definitions/core/Automation.type.ts | 54 ++++ .../definitions/core/TimerLifecycle.type.ts | 2 + packages/types/src/index.ts | 17 +- 28 files changed, 1220 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useAutomationSettings.ts create mode 100644 apps/client/src/common/models/AutomationSettings.ts create mode 100644 apps/server/src/api-data/automation/__tests__/automation.dao.test.ts create mode 100644 apps/server/src/api-data/automation/__tests__/automation.service.test.ts create mode 100644 apps/server/src/api-data/automation/__tests__/testUtils.ts create mode 100644 apps/server/src/api-data/automation/automation.controller.ts create mode 100644 apps/server/src/api-data/automation/automation.dao.ts create mode 100644 apps/server/src/api-data/automation/automation.parser.ts create mode 100644 apps/server/src/api-data/automation/automation.router.ts create mode 100644 apps/server/src/api-data/automation/automation.service.ts create mode 100644 apps/server/src/api-data/automation/automation.utils.ts create mode 100644 apps/server/src/api-data/automation/automation.validation.ts create mode 100644 apps/server/src/api-data/automation/clients/http.client.ts create mode 100644 apps/server/src/api-data/automation/clients/osc.client.ts create mode 100644 packages/types/src/definitions/core/Automation.type.ts diff --git a/apps/client/src/common/hooks-query/useAutomationSettings.ts b/apps/client/src/common/hooks-query/useAutomationSettings.ts new file mode 100644 index 000000000..60fffa64b --- /dev/null +++ b/apps/client/src/common/hooks-query/useAutomationSettings.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { editAutomationSettings, getAutomationSettings } from '../api/automation'; +import { AUTOMATION } from '../api/constants'; +import { logAxiosError } from '../api/utils'; +import { automationPlaceholderSettings } from '../models/AutomationSettings'; +import { ontimeQueryClient } from '../queryClient'; + +export default function useAutomationSettings() { + const { data, status, isFetching, isError, refetch } = useQuery({ + queryKey: AUTOMATION, + queryFn: getAutomationSettings, + placeholderData: (previousData, _previousQuery) => previousData, + retry: 5, + retryDelay: (attempt: number) => attempt * 2500, + refetchInterval: queryRefetchIntervalSlow, + networkMode: 'always', + }); + + return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; +} + +export function useAutomationSettingsMutation() { + const { isPending, mutateAsync } = useMutation({ + mutationFn: editAutomationSettings, + onError: (error) => logAxiosError('Error saving Automation settings', error), + onSuccess: (data) => { + ontimeQueryClient.setQueryData(AUTOMATION, data); + }, + onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }), + }); + return { isPending, mutateAsync }; +} diff --git a/apps/client/src/common/models/AutomationSettings.ts b/apps/client/src/common/models/AutomationSettings.ts new file mode 100644 index 000000000..99691df3d --- /dev/null +++ b/apps/client/src/common/models/AutomationSettings.ts @@ -0,0 +1,9 @@ +import { AutomationSettings } from 'ontime-types'; + +export const automationPlaceholderSettings: AutomationSettings = { + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 8888, + automations: [], + blueprints: {}, +}; diff --git a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts new file mode 100644 index 000000000..1f9c29798 --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts @@ -0,0 +1,232 @@ +import { AutomationBlueprint, AutomationBlueprintDTO, AutomationDTO, TimerLifeCycle } from 'ontime-types'; + +import { + addAutomation, + addBlueprint, + deleteAll, + deleteAllAutomations, + deleteAutomation, + deleteBlueprint, + editAutomation, + editBlueprint, + getAutomations, + getBlueprints, +} from '../automation.dao.js'; +import { makeOSCAction, makeHTTPAction } from './testUtils.js'; + +beforeAll(() => { + vi.mock('../../../classes/data-provider/DataProvider.js', () => { + // Create a small mock store + let automations = { + enabledAutomations: true, + enabledOscIn: true, + oscPortIn: 8888, + automations: [], + blueprints: {}, + }; + return { + getDataProvider: vi.fn().mockImplementation(() => { + return { + getAutomation: vi.fn().mockImplementation(() => automations), + setAutomation: vi.fn().mockImplementation((newData) => (automations = newData)), + }; + }), + }; + }); +}); + +afterAll(() => { + vi.clearAllMocks(); +}); + +describe('addAutomations()', () => { + beforeEach(() => { + deleteAllAutomations(); + }); + + it('should accept a valid automation', () => { + const testData: AutomationDTO = { + title: 'test', + trigger: TimerLifeCycle.onLoad, + blueprintId: 'test-blueprint-id', + }; + + const automation = addAutomation(testData); + expect(automation).toMatchObject(testData); + }); +}); + +describe('editAutomation()', () => { + beforeEach(() => { + deleteAllAutomations(); + addAutomation({ + title: 'test-osc', + trigger: TimerLifeCycle.onLoad, + blueprintId: 'test-osc-blueprint', + }); + addAutomation({ + title: 'test-http', + trigger: TimerLifeCycle.onFinish, + blueprintId: 'test-http-blueprint', + }); + }); + + it('should edit the contents of an automation', () => { + const automations = getAutomations(); + const firstAutomation = automations[0]; + expect(firstAutomation).toMatchObject({ id: expect.any(String), title: 'test-osc' }); + + const editedOSC = editAutomation(firstAutomation.id, { + title: 'edited-title', + trigger: TimerLifeCycle.onDanger, + blueprintId: 'test-osc-blueprint', + }); + + expect(editedOSC).toMatchObject({ + id: expect.any(String), + title: 'edited-title', + trigger: TimerLifeCycle.onDanger, + blueprintId: 'test-osc-blueprint', + }); + }); +}); + +describe('deleteAutomation()', () => { + beforeEach(() => { + deleteAllAutomations(); + addAutomation({ + title: 'test-osc', + trigger: TimerLifeCycle.onLoad, + blueprintId: 'test-osc-blueprint', + }); + addAutomation({ + title: 'test-http', + trigger: TimerLifeCycle.onFinish, + blueprintId: 'test-http-blueprint', + }); + }); + + it('should remove an automation from the list', () => { + const automations = getAutomations(); + expect(automations.length).toEqual(2); + const firstAutomation = automations[0]; + expect(firstAutomation).toMatchObject({ id: expect.any(String), title: 'test-osc' }); + + deleteAutomation(firstAutomation.id); + const removed = getAutomations(); + expect(removed.length).toEqual(1); + expect(removed[0].title).not.toEqual('test-osc'); + }); +}); + +describe('addBlueprint()', () => { + beforeEach(() => { + deleteAll(); + }); + + it('should accept a valid blueprint', () => { + const testData: AutomationBlueprintDTO = { + title: 'test', + filterRule: 'all', + filters: [], + outputs: [makeOSCAction(), makeHTTPAction()], + }; + + const blueprint = addBlueprint(testData); + const blueprints = getBlueprints(); + expect(blueprints[blueprint.id]).toMatchObject(testData); + }); +}); + +describe('editBlueprint()', () => { + // saving the ID of the added blueprint + let firstBlueprint: AutomationBlueprint; + beforeEach(() => { + deleteAll(); + firstBlueprint = addBlueprint({ + title: 'test-osc', + filterRule: 'all', + filters: [], + outputs: [], + }); + addBlueprint({ + title: 'test-http', + filterRule: 'all', + filters: [], + outputs: [], + }); + }); + + it('should edit the contents of a blueprint', () => { + const blueprints = getBlueprints(); + expect(Object.keys(blueprints).length).toEqual(2); + expect(blueprints[firstBlueprint.id]).toMatchObject({ + id: firstBlueprint.id, + title: 'test-osc', + filterRule: 'all', + filters: expect.any(Array), + outputs: expect.any(Array), + }); + + const editedOSC = editBlueprint(firstBlueprint.id, { + title: 'edited-title', + filterRule: 'any', + filters: [], + outputs: [], + }); + + expect(editedOSC).toMatchObject({ + id: firstBlueprint.id, + title: 'edited-title', + filterRule: 'any', + filters: expect.any(Array), + outputs: expect.any(Array), + }); + }); +}); + +describe('deleteBlueprint()', () => { + // saving the ID of the added blueprint + let firstBlueprint: AutomationBlueprint; + beforeEach(() => { + deleteAll(); + firstBlueprint = addBlueprint({ + title: 'test-osc', + filterRule: 'all', + filters: [], + outputs: [], + }); + }); + + it('should remove a blueprint from the list', () => { + const blueprints = getBlueprints(); + expect(Object.keys(blueprints).length).toEqual(1); + + deleteBlueprint(Object.keys(blueprints)[0]); + const removed = getBlueprints(); + expect(Object.keys(removed).length).toEqual(0); + }); + + it('should not remove a blueprint which is in use', () => { + const blueprints = getBlueprints(); + addAutomation({ + title: 'test-automation', + trigger: TimerLifeCycle.onLoad, + blueprintId: firstBlueprint.id, + }); + + const blueprintKeys = Object.keys(blueprints); + const blueprintId = blueprintKeys[0]; + expect(blueprintId).toEqual(firstBlueprint.id); + expect(blueprintKeys.length).toEqual(1); + expect(blueprints[blueprintId]).toMatchObject({ + id: blueprintId, + title: 'test-osc', + filterRule: 'all', + filters: expect.any(Array), + outputs: expect.any(Array), + }); + + expect(() => deleteBlueprint(blueprintId)).toThrowError(); + }); +}); diff --git a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts new file mode 100644 index 000000000..212bde5dc --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts @@ -0,0 +1,92 @@ +import { TimerLifeCycle } from 'ontime-types'; + +import { deleteAllAutomations, addAutomation, addBlueprint } from '../automation.dao.js'; +import { triggerAction } from '../automation.service.js'; + +import { makeOSCAction, makeHTTPAction } from './testUtils.js'; + +import * as oscClient from '../clients/osc.client.js'; +import * as httpClient from '../clients/http.client.js'; + +beforeAll(() => { + vi.mock('../../../classes/data-provider/DataProvider.js', () => { + // Create a small mock store + let automations = { + enabledAutomations: true, + enabledOscIn: true, + oscPortIn: 8888, + automations: [], + blueprints: {}, + }; + return { + getDataProvider: vi.fn().mockImplementation(() => { + return { + getAutomation: vi.fn().mockImplementation(() => automations), + setAutomation: vi.fn().mockImplementation((newData) => (automations = newData)), + }; + }), + }; + }); +}); + +afterAll(() => { + vi.clearAllMocks(); +}); + +describe('triggerAction()', () => { + let oscSpy = vi.spyOn(oscClient, 'emitOSC'); + let httpSpy = vi.spyOn(httpClient, 'emitHTTP'); + + beforeEach(() => { + oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {}); + httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {}); + + deleteAllAutomations(); + const oscBlueprint = addBlueprint({ + title: 'test-osc', + filterRule: 'all', + filters: [], + outputs: [makeOSCAction()], + }); + const httpBlueprint = addBlueprint({ + title: 'test-http', + filterRule: 'any', + filters: [], + outputs: [makeHTTPAction()], + }); + addAutomation({ + title: 'test-osc', + trigger: TimerLifeCycle.onLoad, + blueprintId: oscBlueprint.id, + }); + addAutomation({ + title: 'test-http', + trigger: TimerLifeCycle.onFinish, + blueprintId: httpBlueprint.id, + }); + }); + + it('should trigger automations for a given action', () => { + triggerAction(TimerLifeCycle.onLoad, {}); + expect(oscSpy).toHaveBeenCalledTimes(1); + expect(httpSpy).not.toBeCalled(); + oscSpy.mockReset(); + httpSpy.mockReset(); + + triggerAction(TimerLifeCycle.onStart, {}); + expect(oscClient.emitOSC).not.toBeCalled(); + expect(httpSpy).not.toBeCalled(); + oscSpy.mockReset(); + httpSpy.mockReset(); + + triggerAction(TimerLifeCycle.onFinish, {}); + expect(oscSpy).not.toBeCalled(); + expect(httpSpy).toHaveBeenCalledTimes(1); + oscSpy.mockReset(); + httpSpy.mockReset(); + + triggerAction(TimerLifeCycle.onStop, {}); + expect(oscSpy).not.toBeCalled(); + expect(httpSpy).not.toBeCalled(); + }); +}); diff --git a/apps/server/src/api-data/automation/__tests__/testUtils.ts b/apps/server/src/api-data/automation/__tests__/testUtils.ts new file mode 100644 index 000000000..b3951b288 --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/testUtils.ts @@ -0,0 +1,20 @@ +import { OSCOutput, HTTPOutput } from 'ontime-types'; + +export function makeOSCAction(action?: Partial): OSCOutput { + return { + type: 'osc', + targetIP: 'localhost', + targetPort: 3000, + address: 'test', + args: 'message', + ...action, + }; +} + +export function makeHTTPAction(action?: Partial): HTTPOutput { + return { + type: 'http', + url: 'localhost', + ...action, + }; +} diff --git a/apps/server/src/api-data/automation/automation.controller.ts b/apps/server/src/api-data/automation/automation.controller.ts new file mode 100644 index 000000000..54eea6cc7 --- /dev/null +++ b/apps/server/src/api-data/automation/automation.controller.ts @@ -0,0 +1,118 @@ +import { getErrorMessage } from 'ontime-utils'; +import { Automation, AutomationBlueprint, AutomationOutput, AutomationSettings, ErrorResponse } from 'ontime-types'; + +import type { Request, Response } from 'express'; + +import * as automationDao from './automation.dao.js'; +import * as automationService from './automation.service.js'; + +export function getAutomationSettings(_req: Request, res: Response) { + res.json(automationDao.getAutomationSettings()); +} + +export function postAutomationSettings(req: Request, res: Response) { + try { + // body payload is a patch object that must contain root properties + const automationSettings = automationDao.editAutomationSettings({ + enabledAutomations: req.body.enabledAutomations, + enabledOscIn: req.body.enabledOscIn, + oscPortIn: req.body.oscPortIn, + automations: req.body.automations ?? undefined, + blueprints: req.body.blueprints ?? undefined, + }); + res.status(200).send(automationSettings); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function postAutomation(req: Request, res: Response) { + try { + const automation = automationDao.addAutomation({ + title: req.body.title, + trigger: req.body.trigger, + blueprintId: req.body.blueprintId, + }); + res.status(201).send(automation); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function putAutomation(req: Request, res: Response) { + try { + // body payload is a patch object + const automation = automationDao.editAutomation(req.params.id, { + title: req.body.title ?? undefined, + trigger: req.body.trigger ?? undefined, + blueprintId: req.body.blueprintId ?? undefined, + }); + res.status(200).send(automation); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function deleteAutomation(req: Request, res: Response) { + try { + automationDao.deleteAutomation(req.params.id); + res.status(204).send(); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function postBlueprint(req: Request, res: Response) { + try { + const newBlueprint = automationDao.addBlueprint({ + title: req.body.title, + filterRule: req.body.filterRule, + filters: req.body.filters, + outputs: req.body.outputs, + }); + res.status(201).send(newBlueprint); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function editBlueprint(req: Request, res: Response) { + try { + const newBlueprint = automationDao.editBlueprint(req.params.id, { + title: req.body.title, + filterRule: req.body.filterRule, + filters: req.body.filters, + outputs: req.body.outputs, + }); + res.status(200).send(newBlueprint); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function deleteBlueprint(req: Request, res: Response) { + try { + automationDao.deleteBlueprint(req.params.id); + res.status(204).send(); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + +export function testOutput(req: Request, res: Response) { + try { + const payload = req.body as AutomationOutput; + automationService.testOutput(payload); + res.status(200).send(); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts new file mode 100644 index 000000000..1aa602561 --- /dev/null +++ b/apps/server/src/api-data/automation/automation.dao.ts @@ -0,0 +1,178 @@ +import type { + Automation, + AutomationBlueprint, + AutomationBlueprintDTO, + AutomationDTO, + AutomationSettings, + NormalisedAutomationBlueprint, +} from 'ontime-types'; +import { deleteAtIndex, generateId } from 'ontime-utils'; + +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; + +/** + * Gets a copy of the stored automation settings + */ +export function getAutomationSettings(): AutomationSettings { + return structuredClone(getDataProvider().getAutomation()); +} + +/** + * Gets a copy of the stored automations + */ +export function getAutomations(): Automation[] { + return getAutomationSettings().automations; +} + +/** + * Gets a copy of the stored blueprints + */ +export function getBlueprints(): NormalisedAutomationBlueprint { + return getAutomationSettings().blueprints; +} + +export function editAutomationSettings(settings: Partial): AutomationSettings { + saveChanges(settings); + return getAutomationSettings(); +} + +/** + * Adds a validated automation to the store + */ +export function addAutomation(newAutomation: AutomationDTO): Automation { + const automations = getAutomations(); + const id = getUniqueAutomationId(automations); + const automation = { ...newAutomation, id }; + automations.push(automation); + saveChanges({ automations }); + return automation; +} + +/** + * Patches an existing automation + */ +export function editAutomation(id: string, newAutomation: AutomationDTO): Automation { + const automations = getAutomations(); + const index = automations.findIndex((automation) => automation.id === id); + + if (index === -1) { + throw new Error(`Automation with id ${id} not found`); + } + + automations[index] = { ...automations[index], ...newAutomation }; + saveChanges({ automations }); + return automations[index]; +} + +/** + * Deletes an automation given its ID + */ +export function deleteAutomation(id: string): void { + let automations = getAutomations(); + const index = automations.findIndex((automation) => automation.id === id); + + if (index === -1) { + throw new Error(`Automation with id ${id} not found`); + } + + automations = deleteAtIndex(index, automations); + saveChanges({ automations }); +} + +/** + * Deletes all project automations + */ +export function deleteAllAutomations(): void { + saveChanges({ automations: [] }); +} + +/** + * Deletes all project automations and blueprints + * We do this together to avoid issues with missing references + */ +export function deleteAll(): void { + saveChanges({ automations: [], blueprints: {} }); +} + +/** + * Adds a validated blueprint to the store + */ +export function addBlueprint(newBlueprint: Omit): AutomationBlueprint { + const blueprints = getBlueprints(); + const id = getUniqueBlueprintId(blueprints); + blueprints[id] = { ...newBlueprint, id }; + saveChanges({ blueprints }); + return blueprints[id]; +} + +/** + * Updates an existing blueprint with a new entry + */ +export function editBlueprint(id: string, newBlueprint: AutomationBlueprintDTO): AutomationBlueprint { + const blueprints = getBlueprints(); + if (!Object.hasOwn(blueprints, id)) { + throw new Error(`Blueprint with id ${id} not found`); + } + + blueprints[id] = { ...newBlueprint, id }; + saveChanges({ blueprints }); + return blueprints[id]; +} + +/** + * Deletes a blueprint given its ID + */ +export function deleteBlueprint(id: string): void { + const blueprints = getBlueprints(); + // ignore request if blueprint does not exist + if (!Object.hasOwn(blueprints, id)) { + return; + } + // prevent deleting a blueprint that is in use + const automations = getAutomations(); + for (let i = 0; i < automations.length; i++) { + const automation = automations[i]; + if (automation.blueprintId === id) { + throw new Error(`Unable to delete blueprint used in automation ${automation.title}`); + } + } + delete blueprints[id]; + saveChanges({ blueprints }); +} + +/** + * Internal utility to patch the automation settings + */ +async function saveChanges(patch: Partial) { + const automation = getDataProvider().getAutomation(); + + // remove undefined keys from object, we probably want a better solution + Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {})); + await getDataProvider().setAutomation({ ...automation, ...patch }); +} + +/** + * Returns an ID guaranteed to be unique in an array + */ +function getUniqueAutomationId(automations: Automation[]): string { + let id = ''; + do { + id = generateId(); + } while (isInArray(id)); + + function isInArray(id: string): boolean { + return automations.some((automation) => automation.id === id); + } + return id; +} + +/** + * Returns an ID guaranteed to be unique in an objects keys + */ +function getUniqueBlueprintId(blueprints: NormalisedAutomationBlueprint): string { + let id = ''; + do { + id = generateId(); + } while (Object.hasOwn(blueprints, id)); + return id; +} diff --git a/apps/server/src/api-data/automation/automation.parser.ts b/apps/server/src/api-data/automation/automation.parser.ts new file mode 100644 index 000000000..c3d007da9 --- /dev/null +++ b/apps/server/src/api-data/automation/automation.parser.ts @@ -0,0 +1,29 @@ +import { DatabaseModel, AutomationSettings, Automation, NormalisedAutomationBlueprint } from 'ontime-types'; + +import { dbModel } from '../../models/dataModel.js'; +import type { ErrorEmitter } from '../../utils/parser.js'; + +export function parseAutomationSettings(data: Partial, emitError?: ErrorEmitter): AutomationSettings { + if (!data.automation) { + emitError?.('No data found to import'); + return { ...dbModel.automation }; + } + + return { + enabledAutomations: data.automation.enabledAutomations ?? dbModel.automation.enabledAutomations, + enabledOscIn: data.automation.enabledOscIn ?? dbModel.automation.enabledOscIn, + oscPortIn: data.automation.oscPortIn ?? dbModel.automation.oscPortIn, + automations: parseAutomations(data.automation.automations), + blueprints: parseBlueprints(data.automation.blueprints), + }; +} + +function parseAutomations(maybeAutomations: unknown): Automation[] { + if (!Array.isArray(maybeAutomations)) return []; + return maybeAutomations as Automation[]; +} + +function parseBlueprints(maybeBlueprint: unknown): NormalisedAutomationBlueprint { + if (typeof maybeBlueprint !== 'object' || maybeBlueprint === null) return {}; + return maybeBlueprint as NormalisedAutomationBlueprint; +} diff --git a/apps/server/src/api-data/automation/automation.router.ts b/apps/server/src/api-data/automation/automation.router.ts new file mode 100644 index 000000000..94ffe58a5 --- /dev/null +++ b/apps/server/src/api-data/automation/automation.router.ts @@ -0,0 +1,37 @@ +import express from 'express'; + +import { + deleteAutomation, + deleteBlueprint, + editBlueprint, + getAutomationSettings, + postAutomation, + postBlueprint, + putAutomation, + postAutomationSettings, + testOutput, +} from './automation.controller.js'; +import { + paramContainsId, + validateAutomation, + validateAutomationPatch, + validateAutomationSettings, + validateBlueprint, + validateBlueprintPatch, + validateTestPayload, +} from './automation.validation.js'; + +export const router = express.Router(); + +router.get('/', getAutomationSettings); +router.post('/', validateAutomationSettings, postAutomationSettings); + +router.post('/automation', validateAutomation, postAutomation); +router.put('/automation/:id', validateAutomationPatch, putAutomation); +router.delete('/automation/:id', paramContainsId, deleteAutomation); + +router.post('/blueprint', validateBlueprint, postBlueprint); +router.put('/blueprint/:id', validateBlueprintPatch, editBlueprint); +router.delete('/blueprint/:id', paramContainsId, deleteBlueprint); + +router.post('/test', validateTestPayload, testOutput); diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts new file mode 100644 index 000000000..b24b64e9b --- /dev/null +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -0,0 +1,99 @@ +import type { AutomationFilter, AutomationOutput, FilterRule, RuntimeStore, TimerLifeCycle } from 'ontime-types'; + +import { emitOSC } from './clients/osc.client.js'; +import { emitHTTP } from './clients/http.client.js'; +import { getAutomations, getBlueprints } from './automation.dao.js'; + +/** + * Exposes a method for triggering actions based on a TimerLifeCycle event + */ +export function triggerAction(event: TimerLifeCycle, state: Partial) { + const automations = getAutomations(); + const triggerAutomations = automations.filter((automation) => automation.trigger === event); + if (triggerAutomations.length === 0) { + return; + } + + const blueprints = getBlueprints(); + if (Object.keys(blueprints).length === 0) { + return; + } + + triggerAutomations.forEach((automation) => { + const blueprint = blueprints[automation.blueprintId]; + if (!blueprint) { + return; + } + const shouldSend = testConditions(blueprint.filters, blueprint.filterRule, state); + if (shouldSend) { + send(blueprint.outputs, state); + } + }); +} + +export function testOutput(payload: AutomationOutput, state: Partial) { + const success = send([payload], state); + if (!success) { + throw new Error('Failed to send output'); + } +} + +/** + * Checks whether the automation conditions are met + */ +export function testConditions( + filters: AutomationFilter[], + filterRule: FilterRule, + state: Partial, +): boolean { + if (filters.length === 0) { + return true; + } + + if (filterRule === 'all') { + return filters.every((filter) => evaluateCondition(filter)); + } + + return filters.some((filter) => evaluateCondition(filter)); + + function evaluateCondition(filter: AutomationFilter): boolean { + const { field, operator, value } = filter; + const fieldValue = state[field]; + + switch (operator) { + case 'equals': + return fieldValue === value; + case 'not_equals': + return fieldValue !== value; + case 'greater_than': + return fieldValue > value; + case 'less_than': + return fieldValue < value; + case 'contains': + return typeof fieldValue === 'string' && fieldValue.includes(value); + case 'not_contains': + return typeof fieldValue === 'string' && !fieldValue.includes(value); + default: + return false; + } + } +} + +/** + * Handles preparing and sending of the data + * Returns a boolean indicating whether a message was sent + */ +function send(output: AutomationOutput[], _state: Partial): boolean { + output.forEach((payload) => { + if (payload.type === 'osc') { + emitOSC(); + return true; + } + if (payload.type === 'http') { + emitHTTP(); + return true; + } + return false; + }); + return true; +} diff --git a/apps/server/src/api-data/automation/automation.utils.ts b/apps/server/src/api-data/automation/automation.utils.ts new file mode 100644 index 000000000..439c8922f --- /dev/null +++ b/apps/server/src/api-data/automation/automation.utils.ts @@ -0,0 +1,11 @@ +import { FilterRule } from 'ontime-types'; + +type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains'; + +export function isFilterOperator(value: string): value is FilterOperator { + return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains'].includes(value); +} + +export function isFilterRule(value: string): value is FilterRule { + return value === 'all' || value === 'any'; +} diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts new file mode 100644 index 000000000..56fa5fd21 --- /dev/null +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -0,0 +1,177 @@ +import { AutomationBlueprint, AutomationFilter, AutomationOutput, timerLifecycleValues } from 'ontime-types'; + +import { Request, Response, NextFunction } from 'express'; +import { body, param, validationResult } from 'express-validator'; + +import * as assert from '../../utils/assert.js'; + +import { isFilterOperator, isFilterRule } from './automation.utils.js'; + +export const paramContainsId = [ + param('id').exists(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateAutomationSettings = [ + body('enabledAutomations').exists().isBoolean(), + body('enabledOscIn').exists().isBoolean(), + body('oscPortIn').exists().isPort(), + body('automations').optional().isArray(), + body('automations.*.title').optional().isString().trim(), + body('automations.*.trigger').optional().isIn(timerLifecycleValues), + body('automations.*.blueprintId').optional().isString().trim(), + body('blueprints').optional().custom(parseBluePrint), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateAutomation = [ + body('title').exists().isString().trim(), + body('trigger').exists().isIn(timerLifecycleValues), + body('blueprintId').exists().isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateAutomationPatch = [ + param('id').exists(), + body('title').optional().isString().trim(), + body('trigger').optional().isIn(timerLifecycleValues), + body('blueprintId').optional().isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateBlueprint = [ + body().custom(parseBluePrint), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateBlueprintPatch = [ + param('id').exists(), + body().custom(parseBluePrint), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * Parses and validates a use given blueprint + */ +export function parseBluePrint(maybeBlueprint: unknown): AutomationBlueprint { + assert.isObject(maybeBlueprint); + assert.hasKeys(maybeBlueprint, ['title', 'filterRule', 'filters', 'outputs']); + + const { title, filterRule, filters, outputs } = maybeBlueprint; + assert.isString(title); + assert.isString(filterRule); + if (!isFilterRule(filterRule)) { + throw new Error(`Invalid blueprint: unknown filter rule ${filterRule}`); + } + assert.isArray(filters); + validateFilters(filters); + + assert.isArray(outputs); + validateOutput(outputs); + + return maybeBlueprint as AutomationBlueprint; +} + +function validateFilters(filters: Array): filters is AutomationFilter[] { + filters.forEach((condition) => { + assert.isObject(condition); + + assert.hasKeys(condition, ['field', 'operator', 'value']); + const { field, operator, value } = condition; + assert.isString(field); + assert.isString(operator); + assert.isString(value); + if (!isFilterOperator(operator)) { + throw new Error(`Invalid blueprint: unknown filter operator ${operator}`); + } + + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + throw new Error(`Invalid automation: unhandled filter type ${typeof value}`); + } + }); + return true; +} + +function validateOutput(output: Array): output is AutomationOutput[] { + output.forEach((payload) => { + assert.isObject(payload); + assert.hasKeys(payload, ['type']); + const { type } = payload; + assert.isString(type); + + if (type === 'osc') { + assert.hasKeys(payload, ['targetIP', 'targetPort', 'address', 'args']); + const { targetIP, targetPort, address, args } = payload; + assert.isString(targetIP); + assert.isNumber(targetPort); + assert.isString(address); + if (typeof args !== 'string' && typeof args !== 'number') { + throw new Error('Invalid automation'); + } + } else if (type === 'http') { + assert.hasKeys(payload, ['targetIP', 'address']); + const { targetIP, address } = payload; + assert.isString(targetIP); + assert.isString(address); + } else if (type === 'companion') { + assert.hasKeys(payload, ['targetIP', 'address', 'page', 'bank']); + const { targetIP, address, page, bank } = payload; + assert.isString(targetIP); + assert.isString(address); + assert.isNumber(page); + assert.isNumber(bank); + } else { + throw new Error('Invalid automation'); + } + }); + return true; +} + +export const validateTestPayload = [ + body('type').exists().isIn(['osc', 'http']), + + // validation for OSC message + body('targetIP').if(body('type').equals('osc')).isIP(), + body('targetPort').if(body('type').equals('osc')).isPort(), + body('address').if(body('type').equals('osc')).isString().trim(), + body('message').if(body('type').equals('osc')).isString().trim(), + + // validation for HTTP message + body('url').if(body('type').equals('http')).isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/api-data/automation/clients/http.client.ts b/apps/server/src/api-data/automation/clients/http.client.ts new file mode 100644 index 000000000..6028f8bf3 --- /dev/null +++ b/apps/server/src/api-data/automation/clients/http.client.ts @@ -0,0 +1,18 @@ +/** + * Expose possibility to send a message using HTTP protocol + */ +export function emitHTTP() { + console.log('HTTP emit not implemented'); + const payload = preparePayload(); + emit(payload); +} + +/** Parses the state and prepares payload to be emitted */ +function preparePayload() { + return; +} + +/** Emits message over transport */ +function emit(_payload) { + return; +} diff --git a/apps/server/src/api-data/automation/clients/osc.client.ts b/apps/server/src/api-data/automation/clients/osc.client.ts new file mode 100644 index 000000000..b455b7d9e --- /dev/null +++ b/apps/server/src/api-data/automation/clients/osc.client.ts @@ -0,0 +1,18 @@ +/** + * Expose possibility to send a message using OSC protocol + */ +export function emitOSC() { + console.log('OSC emit not implemented'); + const payload = preparePayload(); + emit(payload); +} + +/** Parses the state and prepares payload to be emitted */ +function preparePayload() { + return; +} + +/** Emits message over transport */ +function emit(_payload) { + return; +} diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 2c8fc13ed..8325170d7 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -18,8 +18,18 @@ import * as projectService from '../../services/project-service/ProjectService.j export async function patchPartialProjectFile(req: Request, res: Response) { try { - const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body; - const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http }; + const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http, automation } = req.body; + const patchDb: DatabaseModel = { + rundown, + project, + settings, + viewSettings, + urlPresets, + customFields, + osc, + http, + automation, + }; const newData = await projectService.patchCurrentProject(patchDb); diff --git a/apps/server/src/api-data/index.ts b/apps/server/src/api-data/index.ts index bb9417e04..9024aaff5 100644 --- a/apps/server/src/api-data/index.ts +++ b/apps/server/src/api-data/index.ts @@ -1,5 +1,6 @@ import express from 'express'; +import { router as automationsRouter } from './automation/automation.router.js'; import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js'; import { router as customFieldsRouter } from './custom-fields/customFields.router.js'; import { router as dbRouter } from './db/db.router.js'; @@ -15,6 +16,7 @@ import { router as viewSettingsRouter } from './view-settings/viewSettings.route export const appRouter = express.Router(); +appRouter.use('/automations', automationsRouter); appRouter.use('/custom-fields', customFieldsRouter); appRouter.use('/db', dbRouter); appRouter.use('/http', httpRouter); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 07ffe31d6..8e7b71b61 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -8,6 +8,7 @@ import { CustomFields, HttpSettings, URLPreset, + AutomationSettings, } from 'ontime-types'; import type { Low } from 'lowdb'; @@ -56,6 +57,8 @@ export function getDataProvider() { setViewSettings, setOsc, setHttp, + getAutomation, + setAutomation, getRundown, mergeIntoData, }; @@ -137,10 +140,21 @@ async function setOsc(newData: OSCSettings): ReadonlyPromise { async function setHttp(newData: HttpSettings): ReadonlyPromise { db.data.http = { ...newData }; + await persist(); return db.data.http; } +function getAutomation(): Readonly { + return db.data.automation; +} + +async function setAutomation(newData: AutomationSettings): ReadonlyPromise { + db.data.automation = { ...newData }; + await persist(); + return db.data.automation; +} + function getRundown(): Readonly { return db.data.rundown; } @@ -150,6 +164,7 @@ async function mergeIntoData(newData: Partial): ReadonlyPromise { enabledOut: false, subscriptions: [], }, + automation: { + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 8000, + automations: [], + blueprints: {}, + }, } as DatabaseModel; it('returns existing data if new data is not provided', () => { diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 02bb374f9..cdf19ada3 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -43,4 +43,11 @@ export const dbModel: DatabaseModel = { enabledOut: false, subscriptions: [], }, + automation: { + enabledAutomations: true, + enabledOscIn: true, + oscPortIn: 8888, + automations: [], + blueprints: {}, + }, }; diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index f1d8a9023..2b1053810 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -462,4 +462,11 @@ export const demoDb: DatabaseModel = { enabledOut: false, subscriptions: [], }, + automation: { + enabledAutomations: false, + enabledOscIn: true, + oscPortIn: 8888, + automations: [], + blueprints: {}, + }, }; diff --git a/apps/server/src/utils/assert.ts b/apps/server/src/utils/assert.ts index 47e68aa55..1a9b9fb2e 100644 --- a/apps/server/src/utils/assert.ts +++ b/apps/server/src/utils/assert.ts @@ -21,3 +21,20 @@ export function isObject(value: unknown): asserts value is object { throw new Error(`Unexpected payload type: ${String(value)}`); } } + +export function isArray(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Unexpected payload type: ${String(value)}`); + } +} + +export function hasKeys( + value: T, + keys: K[], +): asserts value is T & Record { + for (const key of keys) { + if (!(key in value)) { + throw new Error(`Key not found: ${String(key)}`); + } + } +} diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 68b14ca40..4521c62ee 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -22,8 +22,10 @@ import { TimeStrategy, } from 'ontime-types'; +import { parseAutomationSettings } from '../api-data/automation/automation.parser.js'; import { logger } from '../classes/Logger.js'; import { event as eventDef } from '../models/eventsDefinition.js'; + import { makeString } from './parserUtils.js'; import { parseHttp, @@ -36,6 +38,7 @@ import { } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; +export type ErrorEmitter = (message: string) => void; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; @@ -333,6 +336,7 @@ export function parseDatabaseModel(jsonData: Partial): { data: Da customFields, osc: parseOsc(jsonData, makeEmitError('OSC')), http: parseHttp(jsonData, makeEmitError('HTTP')), + automation: parseAutomationSettings(jsonData), }; return { data, errors }; diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 44afe3f95..0dcbab54d 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -24,9 +24,7 @@ import { customFieldLabelToKey, generateId, getErrorMessage, isAlphanumericWithS import { dbModel } from '../models/dataModel.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; -import { createEvent } from './parser.js'; - -type ErrorEmitter = (message: string) => void; +import { createEvent, type ErrorEmitter } from './parser.js'; /** * Parse rundown array of an entry diff --git a/packages/types/src/definitions/DataModel.type.ts b/packages/types/src/definitions/DataModel.type.ts index 010f1ffb3..63ecbc98e 100644 --- a/packages/types/src/definitions/DataModel.type.ts +++ b/packages/types/src/definitions/DataModel.type.ts @@ -1,4 +1,5 @@ import type { + AutomationSettings, CustomFields, HttpSettings, OntimeRundown, @@ -18,4 +19,5 @@ export type DatabaseModel = { customFields: CustomFields; osc: OSCSettings; http: HttpSettings; + automation: AutomationSettings; }; diff --git a/packages/types/src/definitions/core/Automation.type.ts b/packages/types/src/definitions/core/Automation.type.ts new file mode 100644 index 000000000..e5a355a37 --- /dev/null +++ b/packages/types/src/definitions/core/Automation.type.ts @@ -0,0 +1,54 @@ +import type { TimerLifeCycle } from './TimerLifecycle.type.js'; + +export type AutomationSettings = { + enabledAutomations: boolean; + enabledOscIn: boolean; + oscPortIn: number; + automations: Automation[]; + blueprints: NormalisedAutomationBlueprint; +}; + +type BlueprintId = string; +export type FilterRule = 'all' | 'any'; + +export type AutomationBlueprint = { + id: BlueprintId; + title: string; + filterRule: FilterRule; + filters: AutomationFilter[]; + outputs: AutomationOutput[]; +}; + +export type AutomationBlueprintDTO = Omit; + +export type NormalisedAutomationBlueprint = Record; + +export type Automation = { + id: string; + title: string; + trigger: TimerLifeCycle; + blueprintId: BlueprintId; +}; + +export type AutomationDTO = Omit; + +export type AutomationFilter = { + field: string; // this should be a key of a OntimeEvent + custom fields + operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains'; + value: string; // we use string but would coerce to the field value +}; + +export type AutomationOutput = OSCOutput | HTTPOutput; + +export type OSCOutput = { + type: 'osc'; + targetIP: string; + targetPort: number; + address: string; + args: string; +}; + +export type HTTPOutput = { + type: 'http'; + url: string; +}; diff --git a/packages/types/src/definitions/core/TimerLifecycle.type.ts b/packages/types/src/definitions/core/TimerLifecycle.type.ts index 0d48e0243..e5776f3aa 100644 --- a/packages/types/src/definitions/core/TimerLifecycle.type.ts +++ b/packages/types/src/definitions/core/TimerLifecycle.type.ts @@ -11,3 +11,5 @@ export enum TimerLifeCycle { } export type TimerLifeCycleKey = keyof typeof TimerLifeCycle; + +export const timerLifecycleValues = Object.keys(TimerLifeCycle); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0f3d3c34b..4a076a031 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,6 +16,21 @@ export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from '. export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; +// ---> Automations +export type { + AutomationSettings, + AutomationBlueprint, + AutomationBlueprintDTO, + Automation, + AutomationDTO, + AutomationFilter, + AutomationOutput, + FilterRule, + HTTPOutput, + NormalisedAutomationBlueprint, + OSCOutput, +} from './definitions/core/Automation.type.js'; + // ---> Project Data export type { ProjectData } from './definitions/core/ProjectData.type.js'; @@ -67,7 +82,7 @@ export type { // SERVER RUNTIME export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; export { Playback } from './definitions/runtime/Playback.type.js'; -export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js'; +export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js'; export type { TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js'; export type { Runtime } from './definitions/runtime/Runtime.type.js';