feat: ontime actions in automation

This commit is contained in:
Carlos Valente
2025-02-18 12:00:31 +01:00
committed by Carlos Valente
parent 636f78e21e
commit 5fb30c75c4
13 changed files with 611 additions and 71 deletions
@@ -0,0 +1,167 @@
import { parseOutput } from '../automation.validation.js';
describe('parseOutput', () => {
describe('handles OSC outputs', () => {
it('parses a valid payload', () => {
const payload = {
type: 'osc',
targetIP: 'localhost',
targetPort: 1234,
address: '/test',
args: 'test',
};
const result = parseOutput(payload);
expect(result).toStrictEqual(payload);
});
it('throws on a invalid payload', () => {
const payload = {
type: 'osc',
targetIP: 1234,
targetPort: 1234,
address: '/test',
args: 'test',
};
expect(() => parseOutput(payload)).toThrow();
});
});
describe('handles HTTP outputs', () => {
it('parses a valid payload', () => {
const payload = {
type: 'http',
url: 'http://asdasdas',
};
const result = parseOutput(payload);
expect(result).toStrictEqual(payload);
});
it('throws on a invalid payload', () => {
const payload = {
type: 'http',
};
expect(() => parseOutput(payload)).toThrow();
});
});
describe('handles Ontime outputs', () => {
it('parses a valid payload', () => {
const auxStart = {
type: 'ontime',
action: 'aux-start',
};
expect(parseOutput(auxStart)).toStrictEqual(auxStart);
const auxStop = {
type: 'ontime',
action: 'aux-stop',
};
expect(parseOutput(auxStop)).toStrictEqual(auxStop);
const auxPause = {
type: 'ontime',
action: 'aux-pause',
};
expect(parseOutput(auxPause)).toStrictEqual(auxPause);
});
it('removes extra properties', () => {
expect(
parseOutput({
type: 'ontime',
action: 'aux-start',
time: 10,
}),
).toStrictEqual({
type: 'ontime',
action: 'aux-start',
});
});
it('throws on a invalid payload', () => {
const payload = {
type: 'ontime',
action: 'not-exist',
};
expect(() => parseOutput(payload)).toThrow();
});
it('parses message-set', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: 'test',
visible: 'true',
}),
).toMatchObject({
text: 'test',
visible: true,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: '',
visible: 'false',
}),
).toMatchObject({
text: undefined,
visible: false,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: '',
visible: '',
}),
).toMatchObject({
text: undefined,
visible: undefined,
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-set',
text: 123,
visible: '',
}),
).toThrow();
});
it('parses message-secondary', () => {});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'test',
}),
).toMatchObject({
secondarySource: null,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: '',
}),
).toMatchObject({
secondarySource: null,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'aux',
}),
).toMatchObject({
secondarySource: 'aux',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'external',
}),
).toMatchObject({
secondarySource: 'external',
});
});
});
@@ -1,11 +1,13 @@
import { getErrorMessage } from 'ontime-utils';
import { Automation, AutomationOutput, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { oscServer } from '../../adapters/OscAdapter.js';
import { parseOutput } from './automation.validation.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.json(automationDao.getAutomationSettings());
@@ -114,8 +116,9 @@ export async function deleteAutomation(req: Request, res: Response<void | ErrorR
export function testOutput(req: Request, res: Response<void | ErrorResponse>) {
try {
const payload = req.body as AutomationOutput;
automationService.testOutput(payload);
const payload = req.body;
const parsed = parseOutput(payload);
automationService.testOutput(parsed);
res.status(200).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -1,6 +1,8 @@
import {
isHTTPOutput,
isOntimeAction,
isOSCOutput,
LogOrigin,
type AutomationFilter,
type AutomationOutput,
type FilterRule,
@@ -8,6 +10,7 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../externals.js';
@@ -15,6 +18,7 @@ import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
@@ -117,12 +121,14 @@ export function testConditions(
function send(output: AutomationOutput[], state?: RuntimeState) {
const stateSnapshot = state ?? getState();
output.forEach((payload) => {
if (isOSCOutput(payload)) {
if (!isOntimeCloud) {
emitOSC(payload, stateSnapshot);
}
if (isOSCOutput(payload) && !isOntimeCloud) {
emitOSC(payload, stateSnapshot);
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, stateSnapshot);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
});
}
@@ -1,4 +1,4 @@
import { FilterRule, MaybeNumber } from 'ontime-types';
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -12,6 +12,10 @@ export function isFilterRule(value: string): value is FilterRule {
return value === 'all' || value === 'any';
}
export function isOntimeActionAction(value: string): value is OntimeAction['action'] {
return ['aux-start', 'aux-stop', 'aux-pause', 'aux-set', 'message-set', 'message-secondary'].includes(value);
}
function toOscValue(argString: string): OscArgInput {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
@@ -3,16 +3,19 @@ import {
AutomationFilter,
AutomationOutput,
HTTPOutput,
OntimeAction,
OSCOutput,
SecondarySource,
timerLifecycleValues,
} from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express';
import { body, oneOf, param, validationResult } from 'express-validator';
import * as assert from '../../utils/assert.js';
import { isFilterOperator, isFilterRule } from './automation.utils.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
export const paramContainsId = [
param('id').exists(),
@@ -131,43 +134,13 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
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') {
validateOSCOutput(payload);
} else if (type === 'http') {
validateHttpOutput(payload);
} else {
throw new Error('Invalid automation');
}
parseOutput(payload);
});
return true;
}
function validateOSCOutput(payload: object): payload is OSCOutput {
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');
}
return true;
}
function validateHttpOutput(payload: object): payload is HTTPOutput {
assert.hasKeys(payload, ['url']);
const { url } = payload;
assert.isString(url);
return true;
}
export const validateTestPayload = [
body('type').exists().isIn(['osc', 'http']),
body('type').exists().isIn(['osc', 'http', 'ontime']),
// validation for OSC message
oneOf([
@@ -182,9 +155,143 @@ export const validateTestPayload = [
// validation for HTTP message
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
// validation for Ontime actions
body('action').if(body('type').equals('ontime')).isString().trim(),
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
body('secondarySource').if(body('type').equals('ontime')).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();
},
];
/**
* Sanitises an output object
* @Throws if the output is invalid
*/
export function parseOutput(maybeOutput: unknown): AutomationOutput {
assert.isObject(maybeOutput);
assert.hasKeys(maybeOutput, ['type']);
const { type } = maybeOutput;
assert.isString(type);
if (type === 'osc') {
return parseOSCOutput(maybeOutput);
} else if (type === 'http') {
return parseHTTPOutput(maybeOutput);
} else if (type === 'ontime') {
return parseOntimeAction(maybeOutput);
} else {
throw new Error('Invalid automation output');
}
}
function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.hasKeys(maybeOSCOutput, ['targetIP', 'targetPort', 'address', 'args']);
assert.isString(maybeOSCOutput.targetIP);
assert.isNumber(maybeOSCOutput.targetPort);
assert.isString(maybeOSCOutput.address);
assert.isString(maybeOSCOutput.args);
return {
type: 'osc',
targetIP: maybeOSCOutput.targetIP,
targetPort: maybeOSCOutput.targetPort,
address: maybeOSCOutput.address,
args: maybeOSCOutput.args,
};
}
function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
assert.hasKeys(maybeHTTPOutput, ['url']);
assert.isString(maybeHTTPOutput.url);
return {
type: 'http',
url: maybeHTTPOutput.url,
};
}
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
assert.hasKeys(maybeOntimeAction, ['action']);
assert.isString(maybeOntimeAction.action);
if (!isOntimeActionAction(maybeOntimeAction.action)) {
throw new Error('Invalid Ontime action');
}
// we know we have a valid action, deal with special cases
if (maybeOntimeAction.action === 'aux-set') {
assert.hasKeys(maybeOntimeAction, ['time']);
assert.isString(maybeOntimeAction.time);
return {
type: 'ontime',
action: 'aux-set',
time: parseUserTime(maybeOntimeAction.time),
};
}
if (maybeOntimeAction.action === 'message-set') {
assert.hasKeys(maybeOntimeAction, ['text', 'visible']);
assert.isString(maybeOntimeAction.text);
assert.isString(maybeOntimeAction.visible);
return {
type: 'ontime',
action: 'message-set',
text: indeterminateText(maybeOntimeAction.text),
visible: indeterminateBooleanString(maybeOntimeAction.visible),
};
}
if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
assert.isString(maybeOntimeAction.secondarySource);
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
};
}
return {
type: 'ontime',
action: maybeOntimeAction.action,
};
}
/**
* Helper function to parse a text which may be indeterminate
* "some text" -> string
* "" -> undefined
*/
function indeterminateText(value: string): string | undefined {
return value === '' ? undefined : value;
}
/**
* Helper function to parse boolean values in transit
* "true" -> true
* "false" -> false
* "" | "null" -> undefined
*/
function indeterminateBooleanString(value: string): boolean | undefined {
return value === '' ? undefined : value === 'true';
}
/**
* Helper function to validate the secondary source
*/
function chooseSecondarySource(value: string): SecondarySource {
if (value === 'aux') return 'aux';
if (value === 'external') return 'external';
return null;
}
@@ -0,0 +1,48 @@
import { LogOrigin, OntimeAction } from 'ontime-types';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/MessageService.js';
export function toOntimeAction(action: OntimeAction) {
switch (action.action) {
// Aux timer actions
case 'aux-start':
auxTimerService.start();
break;
case 'aux-stop':
auxTimerService.stop();
break;
case 'aux-pause':
auxTimerService.pause();
break;
case 'aux-set': {
auxTimerService.setTime(action.time);
break;
}
// Message actions
case 'message-set': {
messageService.patch({
timer: {
text: action.text,
visible: action.visible,
},
});
break;
}
case 'message-secondary': {
messageService.patch({
timer: {
secondarySource: action.secondarySource,
},
});
break;
}
default:
// @ts-expect-error -- this guard checks that we handled all the cases, but we still want to log just in case
logger.warning(LogOrigin.Tx, `Unknown action type: ${action.type}`);
break;
}
}