feat: send osc

This commit is contained in:
Carlos Valente
2025-01-11 15:24:14 +01:00
committed by Carlos Valente
parent ab4dd300ca
commit 6c4d80158c
7 changed files with 401 additions and 19 deletions
@@ -11,6 +11,7 @@ import {
isHTTPOutput,
isOSCOutput,
OntimeEvent,
OSCOutput,
} from 'ontime-types';
import { addBlueprint, editBlueprint, testOutput } from '../../../../common/api/automation';
@@ -342,7 +343,7 @@ export default function BlueprintForm(props: BlueprintFormProps) {
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted' isDisabled={!canTest} onClick={handleTestOSCOutput}>
<Button size='sm' variant='ontime-ghosted' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
@@ -1,7 +1,7 @@
import { TimerLifeCycle } from 'ontime-types';
import { deleteAllAutomations, addAutomation, addBlueprint } from '../automation.dao.js';
import { triggerAction } from '../automation.service.js';
import { triggerAutomations } from '../automation.service.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
@@ -67,25 +67,25 @@ describe('triggerAction()', () => {
});
it('should trigger automations for a given action', () => {
triggerAction(TimerLifeCycle.onLoad, {});
triggerAutomations(TimerLifeCycle.onLoad, {});
expect(oscSpy).toHaveBeenCalledTimes(1);
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAction(TimerLifeCycle.onStart, {});
triggerAutomations(TimerLifeCycle.onStart, {});
expect(oscClient.emitOSC).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAction(TimerLifeCycle.onFinish, {});
triggerAutomations(TimerLifeCycle.onFinish, {});
expect(oscSpy).not.toBeCalled();
expect(httpSpy).toHaveBeenCalledTimes(1);
oscSpy.mockReset();
httpSpy.mockReset();
triggerAction(TimerLifeCycle.onStop, {});
triggerAutomations(TimerLifeCycle.onStop, {});
expect(oscSpy).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
});
@@ -0,0 +1,241 @@
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
const store = { timer: 10 };
const templateString = '/test/{{timer}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/test/10');
});
it('parses string with a nested variable name', () => {
const store = { timer: { clock: 10 } };
const templateString = '/timer/{{timer.clock}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/timer/10');
});
it('parses string with multiple variables', () => {
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}';
const expected = `${mockState.test1} should replace ${mockState.test2}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('correctly parses a string without templates', () => {
const testString = 'That should replace {test}';
const result = parseTemplateNested(testString, {});
expect(result).toStrictEqual(testString);
});
it('handles scenarios with missing variables', () => {
// by failing to provide a value, we give visibility to
// potential issues in the given string
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
});
describe('parseNestedTemplate() -> resolveAliasData()', () => {
it('resolves data through callback', () => {
const data = {
not: {
so: {
easy: '3',
},
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{human.easy}}', data, aliases);
expect(easyParse).toBe('testing-3');
});
it('handles a mixed operation', () => {
const data = {
not: {
so: {
easy: '3',
},
},
other: {
value: 42,
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{other.value}} to {{human.easy}}', data, aliases);
expect(easyParse).toBe('42 to testing-3');
});
it('returns given key when not found', () => {
const data = {
not: {
so: {
easy: '3',
},
},
other: {
value: 5,
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{other.value}} to {{human.easy}} {{human.not.found}}', data, aliases);
expect(easyParse).toBe('5 to testing-3 {{human.not.found}}');
});
});
describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
it('specific osc requirements', () => {
const data = {
not: {
so: {
easy: 'data with space',
empty: '',
number: 1234,
stringNumber: '1234',
},
},
};
const payloads = [
{
test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }],
},
{
test: '',
expect: [],
},
{
test: ' ',
expect: [],
},
{
test: '""',
expect: [{ type: 'string', value: '' }],
},
{
test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }],
},
{
test: '"string with space and {{not.so.number}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"string with space and {{not.so.stringNumber}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"{{not.so.easy}}" 1',
expect: [
{ type: 'string', value: 'data with space' },
{ type: 'integer', value: 1 },
],
},
{
test: '"{{not.so.empty}}" 1',
expect: [
{ type: 'string', value: '' },
{ type: 'integer', value: 1 },
],
},
{
test: '',
expect: [],
},
];
payloads.forEach((payload) => {
const parsedPayload = parseTemplateNested(payload.test, data);
const parsedArguments = stringToOSCArgs(parsedPayload);
expect(parsedArguments).toStrictEqual(payload.expect);
});
});
});
describe('test stringToOSCArgs()', () => {
it('all types', () => {
const test = 'test 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
expect(stringToOSCArgs(undefined)).toStrictEqual([]);
});
it('empty is nothing', () => {
expect(stringToOSCArgs('')).toStrictEqual([]);
});
it('1 space is nothing', () => {
expect(stringToOSCArgs(' ')).toStrictEqual([]);
});
it('keep other types in strings', () => {
const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'string', value: '1111' },
{ type: 'string', value: '0.1111' },
{ type: 'string', value: 'TRUE' },
{ type: 'string', value: 'FALSE' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces in quoted strings', () => {
const test = '"test space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces escaped quotes', () => {
const test = '"test \\" space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test " space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('2 spaces', () => {
const test = '1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
@@ -9,9 +9,11 @@ import {
} from 'ontime-types';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomations, getBlueprints } from './automation.dao.js';
import { isOntimeCloud } from '../../externals.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
@@ -94,9 +96,10 @@ function send(output: AutomationOutput[], state?: RuntimeState) {
const stateSnapshot = state ?? getState();
output.forEach((payload) => {
if (isOSCOutput(payload)) {
emitOSC();
}
if (isHTTPOutput(payload)) {
if (!isOntimeCloud) {
emitOSC(payload, stateSnapshot);
}
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, stateSnapshot);
}
});
@@ -1,4 +1,6 @@
import { FilterRule } from 'ontime-types';
import { Argument } from 'node-osc';
import { FilterRule, MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils';
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains';
@@ -9,3 +11,113 @@ export function isFilterOperator(value: string): value is FilterOperator {
export function isFilterRule(value: string): value is FilterRule {
return value === 'all' || value === 'any';
}
export function stringToOSCArgs(argsString: string | undefined): Argument[] {
if (typeof argsString === 'undefined' || argsString === '') {
return new Array<Argument>();
}
const matches = splitWhitespace(argsString);
if (!matches) {
return new Array<Argument>();
}
const parsedArguments: Argument[] = matches.map((argString: string) => {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
if (!Number.isNaN(argAsNum)) {
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
}
if (argString.startsWith('"') && argString.endsWith('"')) {
// NOTE: "quoted string"
return { type: 'string', value: argString.substring(1, argString.length - 1) };
}
if (argString === 'TRUE') {
// NOTE: Boolean true
return { type: 'T', value: true };
}
if (argString === 'FALSE') {
// NOTE: Boolean false
return { type: 'F', value: false };
}
// NOTE: string
return { type: 'string', value: argString };
});
return parsedArguments;
}
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
/**
* Parses a templated string to values in a nested object
*/
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
let parsedTemplate = template;
const matches = Array.from(parsedTemplate.matchAll(placeholderRegex));
for (const match of matches) {
const variableName = match[1];
const variableParts = variableName.split('.');
let value: string | undefined = undefined;
if (variableParts[0] === 'human') {
const lookupKey = variableParts[1];
if (lookupKey in humanReadable) {
const newTemplate = `{{${humanReadable[lookupKey].key}}}`;
const parsed = parseTemplateNested(newTemplate, state, humanReadable);
value = humanReadable[lookupKey].cb(parsed);
} else {
value = undefined;
}
} else {
// iterate through variable parts, and look for the property in the state object
value = variableParts.reduce((obj, key) => obj?.[key], state);
}
if (value !== undefined) {
parsedTemplate = parsedTemplate.replace(match[0], value);
}
}
return parsedTemplate;
}
function formatDisplayFromString(value: string, hideZero = false): string {
let valueInNumber: MaybeNumber = null;
if (value !== 'null') {
const parsedValue = Number(value);
if (!Number.isNaN(parsedValue)) {
valueInNumber = parsedValue;
}
}
let formatted = millisToString(valueInNumber, { fallback: hideZero ? '00:00' : '00:00:00' });
if (hideZero) {
formatted = removeLeadingZero(formatted);
}
return formatted;
}
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
const quickAliases: AliasesDefinition = {
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
expectedEnd: {
key: 'timer.expectedFinish',
cb: (value: string) => formatDisplayFromString(value),
},
runningTimer: {
key: 'timer.current',
cb: (value: string) => formatDisplayFromString(value, true),
},
elapsedTime: {
key: 'timer.elapsed',
cb: (value: string) => formatDisplayFromString(value, true),
},
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
};
@@ -8,7 +8,7 @@ import {
} from 'ontime-types';
import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
import { body, oneOf, param, validationResult } from 'express-validator';
import * as assert from '../../utils/assert.js';
@@ -170,7 +170,11 @@ export const validateTestPayload = [
body('type').exists().isIn(['osc', 'http']),
// validation for OSC message
body('targetIP').if(body('type').equals('osc')).isIP(),
oneOf([
body('targetIP').if(body('type').equals('osc')).isIP(),
body('targetIP').if(body('type').equals('osc')).isFQDN(),
body('targetIP').if(body('type').equals('osc')).equals('localhost'),
]),
body('targetPort').if(body('type').equals('osc')).isPort(),
body('address').if(body('type').equals('osc')).isString().trim(),
body('args').if(body('type').equals('osc')).isString().trim(),
@@ -1,18 +1,39 @@
import { LogOrigin, OSCOutput } from 'ontime-types';
import { Client, Message } from 'node-osc';
import { logger } from '../../../classes/Logger.js';
import { type RuntimeState } from '../../../stores/runtimeState.js';
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
/**
* Expose possibility to send a message using OSC protocol
*/
export function emitOSC() {
console.log('OSC emit not implemented');
const payload = preparePayload();
emit(payload);
export function emitOSC(output: OSCOutput, state: RuntimeState) {
const message = preparePayload(output, state);
emit(output.targetIP, output.targetPort, message);
}
/** Parses the state and prepares payload to be emitted */
function preparePayload() {
return;
function preparePayload(output: OSCOutput, state: RuntimeState): Message {
// check for templates in the address
const parsedAddress = parseTemplateNested(output.address, state);
const message = new Message(parsedAddress);
// check for templates in the arguments
const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined;
// check we have the correct type
message.append(stringToOSCArgs(parsedArguments));
return message;
}
/** Emits message over transport */
function emit(_payload) {
function emit(targetIP: string, targetPort: number, message: Message) {
logger.info(LogOrigin.Rx, `Sending OSC: ${targetIP}:${targetPort}`);
const oscClient = new Client(targetIP, targetPort);
oscClient.send(message, () => {
oscClient.close();
});
return;
}