mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
feat: send osc
This commit is contained in:
committed by
Carlos Valente
parent
ab4dd300ca
commit
6c4d80158c
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user