diff --git a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx index e24323ce4..9472c6f82 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx @@ -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) { {rowErrors?.args?.message} - { }); 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(); }); diff --git a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts new file mode 100644 index 000000000..919715de6 --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index c8cb84845..c725efd94 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -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); } }); diff --git a/apps/server/src/api-data/automation/automation.utils.ts b/apps/server/src/api-data/automation/automation.utils.ts index 439c8922f..649c74d3d 100644 --- a/apps/server/src/api-data/automation/automation.utils.ts +++ b/apps/server/src/api-data/automation/automation.utils.ts @@ -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(); + } + const matches = splitWhitespace(argsString); + + if (!matches) { + return new Array(); + } + + 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 }>; +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) }, +}; diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts index 0a8f5d989..2e9796a83 100644 --- a/apps/server/src/api-data/automation/automation.validation.ts +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -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(), diff --git a/apps/server/src/api-data/automation/clients/osc.client.ts b/apps/server/src/api-data/automation/clients/osc.client.ts index b455b7d9e..b3274debc 100644 --- a/apps/server/src/api-data/automation/clients/osc.client.ts +++ b/apps/server/src/api-data/automation/clients/osc.client.ts @@ -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; }