feat: automation service

This commit is contained in:
Carlos Valente
2024-11-23 22:20:07 +01:00
committed by Carlos Valente
parent a8fe4c8e68
commit 05e61e7bf8
28 changed files with 1220 additions and 8 deletions
@@ -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 };
}
@@ -0,0 +1,9 @@
import { AutomationSettings } from 'ontime-types';
export const automationPlaceholderSettings: AutomationSettings = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
automations: [],
blueprints: {},
};
@@ -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();
});
});
@@ -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();
});
});
@@ -0,0 +1,20 @@
import { OSCOutput, HTTPOutput } from 'ontime-types';
export function makeOSCAction(action?: Partial<OSCOutput>): OSCOutput {
return {
type: 'osc',
targetIP: 'localhost',
targetPort: 3000,
address: 'test',
args: 'message',
...action,
};
}
export function makeHTTPAction(action?: Partial<HTTPOutput>): HTTPOutput {
return {
type: 'http',
url: 'localhost',
...action,
};
}
@@ -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<AutomationSettings>) {
res.json(automationDao.getAutomationSettings());
}
export function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
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<Automation | ErrorResponse>) {
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<Automation | ErrorResponse>) {
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<void | ErrorResponse>) {
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<AutomationBlueprint | ErrorResponse>) {
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<AutomationBlueprint | ErrorResponse>) {
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<void | ErrorResponse>) {
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<void | ErrorResponse>) {
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 });
}
}
@@ -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>): 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, 'id'>): 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<AutomationSettings>) {
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;
}
@@ -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<DatabaseModel>, 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;
}
@@ -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);
@@ -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<RuntimeStore>) {
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<RuntimeStore>) {
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<RuntimeStore>,
): 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<RuntimeStore>): boolean {
output.forEach((payload) => {
if (payload.type === 'osc') {
emitOSC();
return true;
}
if (payload.type === 'http') {
emitHTTP();
return true;
}
return false;
});
return true;
}
@@ -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';
}
@@ -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<unknown>): 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<unknown>): 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();
},
];
@@ -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;
}
@@ -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;
}
+12 -2
View File
@@ -18,8 +18,18 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
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);
+2
View File
@@ -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);
@@ -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<OSCSettings> {
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
db.data.http = { ...newData };
await persist();
return db.data.http;
}
function getAutomation(): Readonly<AutomationSettings> {
return db.data.automation;
}
async function setAutomation(newData: AutomationSettings): ReadonlyPromise<AutomationSettings> {
db.data.automation = { ...newData };
await persist();
return db.data.automation;
}
function getRundown(): Readonly<OntimeRundown> {
return db.data.rundown;
}
@@ -150,6 +164,7 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
db.data.project = mergedData.project;
db.data.settings = mergedData.settings;
db.data.viewSettings = mergedData.viewSettings;
db.data.automation = mergedData.automation;
db.data.osc = mergedData.osc;
db.data.http = mergedData.http;
db.data.urlPresets = mergedData.urlPresets;
@@ -13,6 +13,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
customFields = existing.customFields,
osc = {},
http = {},
automation = existing.automation,
} = newData;
return {
@@ -23,7 +24,6 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
viewSettings: { ...existing.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? existing.urlPresets,
customFields: customFields ?? existing.customFields,
osc: { ...existing.osc, ...osc },
http: { ...existing.http, ...http },
automation: { ...existing.automation, ...automation },
};
}
@@ -47,6 +47,13 @@ describe('safeMerge', () => {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
automations: [],
blueprints: {},
},
} as DatabaseModel;
it('returns existing data if new data is not provided', () => {
+7
View File
@@ -43,4 +43,11 @@ export const dbModel: DatabaseModel = {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: true,
enabledOscIn: true,
oscPortIn: 8888,
automations: [],
blueprints: {},
},
};
+7
View File
@@ -462,4 +462,11 @@ export const demoDb: DatabaseModel = {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: false,
enabledOscIn: true,
oscPortIn: 8888,
automations: [],
blueprints: {},
},
};
+17
View File
@@ -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<T extends object, K extends keyof any>(
value: T,
keys: K[],
): asserts value is T & Record<K, unknown> {
for (const key of keys) {
if (!(key in value)) {
throw new Error(`Key not found: ${String(key)}`);
}
}
}
+4
View File
@@ -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<DatabaseModel>): { data: Da
customFields,
osc: parseOsc(jsonData, makeEmitError('OSC')),
http: parseHttp(jsonData, makeEmitError('HTTP')),
automation: parseAutomationSettings(jsonData),
};
return { data, errors };
+1 -3
View File
@@ -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
@@ -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;
};
@@ -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<AutomationBlueprint, 'id'>;
export type NormalisedAutomationBlueprint = Record<BlueprintId, AutomationBlueprint>;
export type Automation = {
id: string;
title: string;
trigger: TimerLifeCycle;
blueprintId: BlueprintId;
};
export type AutomationDTO = Omit<Automation, 'id'>;
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;
};
@@ -11,3 +11,5 @@ export enum TimerLifeCycle {
}
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export const timerLifecycleValues = Object.keys(TimerLifeCycle);
+16 -1
View File
@@ -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';