mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
V2 osc (#313)
* chore: upgrade relevant deps * fix: electron app to tray * chore: cleanup dictionary * feat: handle several messages in a event * ux: disable irrelevant buttons in browser * feat: parse and validate subscriptions * feat: create UI for OSC Integration * fix: cleanup logger behaviour * feat: allow OSC settings to be changed at runtime
This commit is contained in:
@@ -85,10 +85,14 @@ export class SocketServer implements IAdapter {
|
||||
|
||||
if (type === 'hello') {
|
||||
ws.send('hi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ontime-log') {
|
||||
console.log('attempted adding to log');
|
||||
if (payload.level && payload.origin && payload.text) {
|
||||
logger.emit(payload.level, payload.origin, payload.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -108,7 +112,7 @@ export class SocketServer implements IAdapter {
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
send(message: any) {
|
||||
send(message: unknown) {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
if (client !== this.wss && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
@@ -25,10 +25,10 @@ import { dbLoadingProcess } from './modules/loadDb.js';
|
||||
|
||||
// Services
|
||||
import { eventTimer } from './services/TimerService.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { OscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { eventLoader } from './classes/event-loader/EventLoader.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -179,7 +179,6 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
return 'OSC Invalid configuration';
|
||||
}
|
||||
|
||||
const oscIntegration = new OscIntegration();
|
||||
const { success, message } = oscIntegration.init(osc);
|
||||
logger.info('RX', message);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { mergeObject } from '../utils/parserUtils.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -42,7 +44,7 @@ export const dbDownload = async (req, res) => {
|
||||
* @param file
|
||||
* @param req
|
||||
* @param res
|
||||
* @param options
|
||||
* @param [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndParse = async (file, req, res, options) => {
|
||||
@@ -247,7 +249,7 @@ export const postViewSettings = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/osc'
|
||||
// Create controller for GET request to '/ontime/osc'
|
||||
// Returns -
|
||||
export const getOSC = async (req, res) => {
|
||||
const osc = DataProvider.getOsc();
|
||||
@@ -262,8 +264,14 @@ export const postOSC = async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await DataProvider.setOsc(req.body);
|
||||
res.send(req.body).status(200);
|
||||
const oscSettings = req.body;
|
||||
await DataProvider.setOsc(oscSettings);
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info('RX', message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { body, check, validationResult } from 'express-validator';
|
||||
import { validateOscSubscription } from '../utils/parserFunctions.js';
|
||||
|
||||
/**
|
||||
* @description Validates object for POST /ontime/views
|
||||
@@ -70,6 +71,9 @@ export const validateOSC = [
|
||||
body('targetIP').exists().isIP(),
|
||||
body('enabledIn').exists().isBoolean(),
|
||||
body('enabledOut').exists().isBoolean(),
|
||||
body('subscriptions')
|
||||
.isObject()
|
||||
.custom((value) => validateOscSubscription(value)),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
|
||||
@@ -41,30 +41,12 @@ export const dbModel: DatabaseModel = {
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStart: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onPause: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStop: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onUpdate: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onFinish: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ArgumentType, Client, Message } from 'node-osc';
|
||||
import { OSCSettings, OscSubscription } from 'ontime-types';
|
||||
|
||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||
import { parseTemplate } from './integrationUtils.js';
|
||||
import { parseTemplateNested } from './integrationUtils.js';
|
||||
import { isObject } from '../../utils/varUtils.js';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { validateOscSubscription } from '../../utils/parserFunctions.js';
|
||||
|
||||
type Action = TimerLifeCycleKey | string;
|
||||
|
||||
@@ -17,7 +19,7 @@ export class OscIntegration implements IIntegration {
|
||||
|
||||
constructor() {
|
||||
this.oscClient = null;
|
||||
this.subscriptions = {};
|
||||
this.subscriptions = dbModel.osc.subscriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ export class OscIntegration implements IIntegration {
|
||||
};
|
||||
}
|
||||
try {
|
||||
// this allows re-calling the init function during runtime
|
||||
this.oscClient?.close();
|
||||
this.oscClient = new Client(targetIP, portOut);
|
||||
return {
|
||||
success: true,
|
||||
@@ -54,7 +58,9 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
|
||||
initSubscriptions(subscriptionOptions: OscSubscription) {
|
||||
this.subscriptions = { ...this.subscriptions, ...subscriptionOptions };
|
||||
if (validateOscSubscription(subscriptionOptions)) {
|
||||
this.subscriptions = { ...subscriptionOptions };
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(action: Action, state?: object) {
|
||||
@@ -73,11 +79,15 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
|
||||
// check subscriptions for action
|
||||
const { enabled, message } = this.subscriptions?.[action] || {};
|
||||
if (enabled) {
|
||||
const parsedMessage = parseTemplate(message, state || {});
|
||||
this.emit('address/', parsedMessage);
|
||||
}
|
||||
const eventSubscriptions = this.subscriptions?.[action] || [];
|
||||
|
||||
eventSubscriptions.forEach((sub) => {
|
||||
const { enabled, message } = sub;
|
||||
if (enabled && message) {
|
||||
const parsedMessage = parseTemplateNested(message, state || {});
|
||||
this.emit(parsedMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit(path: string, payload?: ArgumentType) {
|
||||
@@ -98,7 +108,7 @@ export class OscIntegration implements IIntegration {
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `error is here ${JSON.stringify(error)}`,
|
||||
message: `Error sending message: ${JSON.stringify(error)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -116,3 +126,5 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oscIntegration = new OscIntegration();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseTemplate } from './integrationUtils.js';
|
||||
import { parseTemplate, parseTemplateNested } from './integrationUtils.js';
|
||||
|
||||
describe('integrationUtils', () => {
|
||||
describe('parseTemplate()', () => {
|
||||
it('correctly parses a given string', () => {
|
||||
const mockState = { test: 'this' };
|
||||
const testString = 'That should replace {{test}}';
|
||||
@@ -51,3 +51,46 @@ describe('integrationUtils', () => {
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,3 +16,22 @@ export function parseTemplate(template: string, state: object): string {
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a templated string to values in a nested object
|
||||
*/
|
||||
export function parseTemplateNested(template: string, state: object): string {
|
||||
let parsedTemplate = template;
|
||||
let match;
|
||||
while ((match = placeholderRegex.exec(template)) !== null) {
|
||||
const variableName = match[1];
|
||||
const variableParts = variableName.split('.');
|
||||
// iterate through variable parts, and look for the property in the state object
|
||||
const value = variableParts.reduce((obj, key) => obj && obj[key], state);
|
||||
if (value !== undefined) {
|
||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { validateOscSubscription } from '../parserFunctions.js';
|
||||
|
||||
test('validateOscSubscription()', () => {
|
||||
it('should return true when given a valid OscSubscription', () => {
|
||||
const validSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(validSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when given undefined', () => {
|
||||
const result = validateOscSubscription(undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given null', () => {
|
||||
const result = validateOscSubscription(null);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty object', () => {
|
||||
const result = validateOscSubscription({});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty array', () => {
|
||||
const result = validateOscSubscription([]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an object that is not an OscSubscription', () => {
|
||||
const invalidObject = { foo: 'bar' };
|
||||
|
||||
const result = validateOscSubscription(invalidObject);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with a missing property', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
// Missing onStop
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with an invalid property value', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the message field is empty', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: '', enabled: false }],
|
||||
onPause: [{ id: '3', message: '', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -976,7 +976,6 @@ const adjective = [
|
||||
'some',
|
||||
'spherical',
|
||||
'sophisticated',
|
||||
'sore',
|
||||
'sorrowful',
|
||||
'soulful',
|
||||
'soupy',
|
||||
@@ -1248,7 +1247,6 @@ const adjective = [
|
||||
'worrisome',
|
||||
'worse',
|
||||
'worst',
|
||||
'worthless',
|
||||
'worthwhile',
|
||||
'worthy',
|
||||
'wrathful',
|
||||
@@ -1473,7 +1471,6 @@ const object = [
|
||||
'studio',
|
||||
'topic',
|
||||
'collection',
|
||||
'depression',
|
||||
'imagination',
|
||||
'passion',
|
||||
'percentage',
|
||||
@@ -1514,7 +1511,6 @@ const object = [
|
||||
'steak',
|
||||
'union',
|
||||
'agreement',
|
||||
'cancer',
|
||||
'currency',
|
||||
'employment',
|
||||
'engineering',
|
||||
@@ -1527,7 +1523,6 @@ const object = [
|
||||
'republic',
|
||||
'seat',
|
||||
'tradition',
|
||||
'virus',
|
||||
'actor',
|
||||
'classroom',
|
||||
'delivery',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
import { OSCSettings, OscSubscription, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
@@ -148,6 +148,28 @@ export const parseViewSettings = (data, enforce) => {
|
||||
return newViews;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates subscription object
|
||||
* @param data
|
||||
*/
|
||||
export const validateOscSubscription = (data: OscSubscription) => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const timerKeys = Object.keys(TimerLifeCycle);
|
||||
for (const key of timerKeys) {
|
||||
if (!(key in data) || !Array.isArray(data[key])) {
|
||||
return false;
|
||||
}
|
||||
for (const subscription of data[key]) {
|
||||
if (!subscription.id || typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse osc portion of an entry
|
||||
*/
|
||||
@@ -159,13 +181,17 @@ export const parseOsc = (
|
||||
console.log('Found OSC definition, importing...');
|
||||
|
||||
const loadedConfig = data?.osc || {};
|
||||
const validatedSubscriptions = validateOscSubscription(loadedConfig.subscriptions)
|
||||
? loadedConfig.subscriptions
|
||||
: dbModel.osc.subscriptions;
|
||||
|
||||
return {
|
||||
portIn: loadedConfig.portIn ?? dbModel.osc.portIn,
|
||||
portOut: loadedConfig.portOut ?? dbModel.osc.portOut,
|
||||
targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP,
|
||||
enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn,
|
||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||
subscriptions: loadedConfig.subscriptions ?? dbModel.osc.subscriptions,
|
||||
subscriptions: validatedSubscriptions,
|
||||
};
|
||||
} else if (enforce) {
|
||||
console.log(`Created OSC object in db`);
|
||||
|
||||
Reference in New Issue
Block a user