mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 08:23:55 +00:00
feat: automation service
This commit is contained in:
committed by
Carlos Valente
parent
a8fe4c8e68
commit
05e61e7bf8
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -43,4 +43,11 @@ export const dbModel: DatabaseModel = {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
automation: {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -462,4 +462,11 @@ export const demoDb: DatabaseModel = {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
automation: {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
automations: [],
|
||||
blueprints: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user