V2 integration (#293)

* feat: integrations and lifecycles

* refactor: prevent issues with start order
This commit is contained in:
Carlos Valente
2023-02-23 20:22:38 +01:00
committed by GitHub
parent d8a84ac88d
commit 70cc071425
61 changed files with 1510 additions and 687 deletions
+77 -50
View File
@@ -6,11 +6,11 @@ import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { config } from './config/config.js';
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
import { initSentry } from './modules/sentry.js';
import { currentDirectory, environment, isProduction, resolvedPath, uiPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { OSCSettings } from 'ontime-types';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
@@ -22,12 +22,9 @@ import { router as playbackRouter } from './routes/playbackRouter.js';
import { DataProvider } from './classes/data-provider/DataProvider.js';
import { socketProvider } from './classes/socket/SocketController.js';
import { eventTimer } from './services/TimerService.js';
import { promise } from './modules/loadDb.js';
import { TimerType } from 'ontime-types';
// TODO: apply code and remove, refs PR #290
const sharedType: TimerType = TimerType.CountDown;
console.log('WIP', sharedType);
import { dbLoadingProcess } from './modules/loadDb.js';
import { integrationService } from './services/integration-service/IntegrationService.js';
import { OscIntegration } from './services/integration-service/OscIntegration.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -77,6 +74,7 @@ app.use((error, response) => {
});
/*************** START SERVICES ***************/
/* Override config
* ----------------
*
@@ -84,42 +82,35 @@ app.use((error, response) => {
* It can be overridden here by the settings in the db
* It can also be overridden on call
*
* Start order
* ----------------
*
* The services need to be started in a certain order,
* the enum below enforces that
*/
(async () => {
try {
await promise;
} catch (error) {
console.log(error);
enum OntimeStartOrder {
Error,
InitDB,
InitServer,
InitIO,
}
let step = OntimeStartOrder.InitDB;
const checkStart = (currentState) => {
if (step !== currentState) {
step = OntimeStartOrder.Error;
throw new Error('Init order error: startDb > startServer > startOsc > startIntegrations');
} else {
if (step === 1 || step === 2) {
step = step + 1;
}
}
})();
};
const { osc } = DataProvider.getData();
const oscIP = osc?.targetIP || config.osc.targetIP;
const oscOutPort = osc?.portOut || config.osc.portOut;
const oscInPort = osc?.port || config.osc.port;
const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled;
const serverPort = 4001; // hardcoded for now
/**
* @description starts OSC server
* @description starts OSC server
* @param overrideConfig
* @return {Promise<void>}
*/
export const startOSCServer = async (overrideConfig = null) => {
if (!oscInEnabled) {
socketServer.info('RX', 'OSC Input Disabled');
return;
}
// Setup default port
const oscSettings = {
port: overrideConfig?.port || oscInPort,
};
// Start OSC Server
socketServer.info('RX', `Starting OSC Server on port: ${oscInPort}`);
initiateOSC(oscSettings);
export const startDb = async () => {
checkStart(OntimeStartOrder.InitDB);
await dbLoadingProcess;
};
// create HTTP server
@@ -130,32 +121,67 @@ const expressServer = http.createServer(app);
* @return {Promise<string>}
*/
export const startServer = async () => {
// Start server
checkStart(OntimeStartOrder.InitServer);
const serverPort = 4001; // hardcoded for now
const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer.listen(serverPort, '0.0.0.0');
// init socket controller
await socketServer.initServer(expressServer);
socketServer.initServer(expressServer);
socketServer.info('SERVER', 'Socket initialised');
socketServer.info('SERVER', returnMessage);
socketServer.startListener();
return returnMessage;
};
/**
* starts integrations
* @description starts OSC server
* @description starts OSC server
* @param overrideConfig
* @return {Promise<void>}
*/
export const startIntegrations = async (overrideConfig = null) => {
const { http } = DataProvider.getData();
export const startOSCServer = async (overrideConfig = null) => {
checkStart(OntimeStartOrder.InitIO);
// OSC Config
const oscConfig = {
ip: oscIP,
port: overrideConfig?.port || oscOutPort,
const { osc } = DataProvider.getData();
if (!osc.enabledIn) {
socketServer.info('RX', 'OSC Input Disabled');
return;
}
// Setup default port
const oscSettings = {
...osc,
portIn: overrideConfig?.port || osc.portIn,
};
// Start OSC Server
socketServer.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
initiateOSC(oscSettings);
};
/**
* starts integrations
*/
export const startIntegrations = async (config?: { osc: OSCSettings }) => {
checkStart(OntimeStartOrder.InitIO);
const { osc } = config ?? DataProvider.getData();
if (!osc) {
return 'OSC Invalid configuration';
}
const oscIntegration = new OscIntegration();
const { success, message } = oscIntegration.init(osc);
socketServer.info('RX', message);
if (success) {
integrationService.register(oscIntegration);
}
};
/**
@@ -170,6 +196,7 @@ export const shutdown = async (exitCode = 0) => {
shutdownOSCServer();
eventTimer.shutdown();
socketServer.shutdown();
integrationService.shutdown();
process.exit(exitCode);
};
@@ -3,6 +3,7 @@
* and adds logic specific to ontime data
*/
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
export class DataProvider {
static getData() {
@@ -39,6 +40,7 @@ export class DataProvider {
}
static async deleteEvent(eventId) {
// @ts-expect-error -- this will go away once we type db
data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId);
await this.persist();
}
@@ -49,6 +51,7 @@ export class DataProvider {
static async clearRundown() {
data.rundown = [];
// @ts-expect-error -- not sure how to type, this is library side
await db.write();
}
@@ -149,11 +152,12 @@ export class DataProvider {
}
static async persist() {
// @ts-expect-error -- not sure how to type, this is library side
await db.write();
}
static async mergeIntoData(newData) {
const mergedData = DataProvider.safeMerge(data, newData);
const mergedData = safeMerge(data, newData);
data.event = mergedData.event;
data.settings = mergedData.settings;
data.osc = mergedData.osc;
@@ -163,36 +167,4 @@ export class DataProvider {
data.rundown = mergedData.rundown;
await this.persist();
}
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
static safeMerge(existing, newData) {
const mergedData = { ...existing };
if (typeof newData?.rundown !== 'undefined') {
mergedData.rundown = newData.rundown;
}
if (typeof newData?.event !== 'undefined') {
mergedData.event = { ...newData.event };
}
if (typeof newData?.settings !== 'undefined') {
mergedData.settings = { ...newData.settings };
}
if (typeof newData?.osc !== 'undefined') {
mergedData.osc = { ...newData.osc };
}
if (typeof newData?.http !== 'undefined') {
mergedData.http = { ...newData.http };
}
if (typeof newData?.aliases !== 'undefined') {
mergedData.aliases = [...newData.aliases];
}
if (typeof newData?.userFields !== 'undefined') {
mergedData.userFields = { ...existing.userFields, ...newData.userFields };
}
return mergedData;
}
}
@@ -0,0 +1,39 @@
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
export function safeMerge(existing, newData) {
const { rundown, event, settings, osc, http, aliases, userFields } = newData || {};
return {
...existing,
rundown: rundown ?? existing.rundown,
event: { ...existing.event, ...event },
settings: { ...existing.settings, ...settings },
views: {
overrideStyles: false,
},
aliases: aliases ?? existing.aliases,
userFields: {
...existing.userFields,
...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => value !== null))),
},
osc: {
...existing.osc,
...osc,
subscriptions: {
...existing.osc?.subscriptions,
...(newData?.osc?.subscriptions || {}),
...(existing.osc?.subscriptions && newData?.osc?.subscriptions
? Object.keys(existing.osc.subscriptions).reduce((acc, key) => {
if (!(key in newData.osc.subscriptions)) {
acc[key] = existing.osc.subscriptions[key];
}
return acc;
}, {})
: {}),
},
},
http: { ...existing.http, ...http },
};
}
@@ -0,0 +1,302 @@
import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => {
const existing = {
rundown: [],
event: {
title: 'existing title',
publicUrl: 'existing public URL',
backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo',
endMessage: 'existing endMessage',
},
settings: {
app: 'ontime',
lock: true,
pinCode: null,
timeFormat: '24',
version: 2,
serverPort: 4001,
},
views: {
overrideStyles: false,
},
aliases: [],
userFields: {
user0: 'existing user0',
user1: 'existing user1',
},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
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,
},
},
},
http: {
enabled: true,
user: null,
pwd: null,
},
};
it('returns existing data if new data is not provided', () => {
const mergedData = safeMerge(existing, undefined);
expect(mergedData).toEqual(existing);
});
it('merges the rundown key', () => {
const newData = {
rundown: [{ name: 'item 1' }, { name: 'item 2' }],
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.rundown).toEqual(newData.rundown);
});
it('merges the event key', () => {
const newData = {
event: {
title: 'new title',
publicInfo: 'new public info',
},
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.event).toEqual({
title: 'new title',
publicUrl: 'existing public URL',
publicInfo: 'new public info',
backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo',
endMessage: 'existing endMessage',
});
});
it('merges the settings key', () => {
const newData = {
settings: {
serverPort: 3000,
lock: '1234',
},
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.settings).toEqual({
app: 'ontime',
version: 2,
serverPort: 3000,
lock: '1234',
pinCode: null,
timeFormat: '24',
});
});
it('merges the osc key', () => {
const newData = {
osc: {
portIn: 7777,
subscriptions: {
onStart: {
message: 'new message',
enabled: true,
},
},
},
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.osc).toEqual({
portIn: 7777,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: {
message: '',
enabled: false,
},
onStart: {
message: 'new message',
enabled: true,
},
onPause: {
message: '',
enabled: false,
},
onStop: {
message: '',
enabled: false,
},
onUpdate: {
message: '',
enabled: false,
},
onFinish: {
message: '',
enabled: false,
},
},
});
});
it('should merge the aliases key when present', () => {
const existingData = {
rundown: [],
event: {
title: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
endMessage: '',
},
settings: {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
},
views: {
overrideStyles: false,
},
aliases: [],
userFields: {
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
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,
},
},
},
http: {
user: null,
pwd: null,
messages: {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
},
enabled: true,
},
};
const newData = {
aliases: ['alias1', 'alias2'],
};
const mergedData = safeMerge(existingData, newData);
expect(mergedData.aliases).toEqual(newData.aliases);
});
it('merges userFields into existing object', () => {
const existing = {
userFields: {
user0: 'Alice',
user1: 'Bob',
},
};
const newData = {
userFields: {
user2: 'Charlie',
user3: 'David',
user4: null,
},
};
const expected = {
user0: 'Alice',
user1: 'Bob',
user2: 'Charlie',
user3: 'David',
};
const result = safeMerge(existing, newData);
expect(result.userFields).toEqual(expected);
});
});
@@ -1,4 +1,4 @@
import { DataProvider } from '../data-provider/DataProvider.js';
import { DataProvider } from '../data-provider/DataProvider.ts';
import { getRollTimers } from '../../services/rollUtils.js';
let instance;
@@ -1,53 +0,0 @@
import * as http from 'http';
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HTTPIntegration {
constructor() {
// nothing to do here
}
/**
* @description Initializes oscClient
* @param {object} httpConfig - Http configurations options
*/
init(httpConfig) {}
/**
* @description Sends http get request from predefined messages
* @param {string} path - complete http path
*/
async send(path) {
if (path == null) {
console.log('HTTP ERROR: Message undefined');
return;
}
const options = new URL(path);
let str = '';
const req = http.request(options, (res) => {
console.log(`statusCode: ${res.statusCode}`);
res.on('data', function (chunk) {
str += chunk;
});
res.on('end', function () {
console.log(str);
});
});
req.on('error', (error) => {
console.error(error);
});
req.end();
}
shutdown() {
/* Nothing to shutdown */
}
}
-169
View File
@@ -1,169 +0,0 @@
import { Client, Message } from 'node-osc';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OSCIntegration {
constructor() {
// OSC Client
this.ADDRESS = '/ontime';
this.oscClient = null;
}
/**
* @description Returns list of implemented messages
* @returns {object} implemented messages
*/
get implemented() {
return {
play: 'play',
pause: 'pause',
stop: 'stop',
previous: 'prev',
next: 'next',
reload: 'reload',
finished: 'finished',
time: 'time',
overtime: 'overtime',
title: 'title',
eventNumber: 'eventNumber',
presenter: 'presenter',
};
}
/**
* @description Initializes oscClient
* @param {object} oscConfig - oscClient configuration options
* @param {string} oscConfig.ip - oscClient object
* @param {number} oscConfig.port - OSC Destination Port
*/
init(oscConfig) {
const { ip, port } = oscConfig;
const validateType = typeof ip !== 'string' || typeof port !== 'number';
const validateNull = ip == null || port == null;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try {
this.oscClient = new Client(ip, port);
return {
success: true,
message: `Initialised OSC Client at ${ip}:${port}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
}
}
/**
* @description Sends osc from predefined messages
* @param {string} messageType - message to be sent
* @param {string} [payload] - optional payload required in some message types
*/
async send(messageType, payload) {
const reply = {
success: true,
message: 'OSC Message sent',
};
if (this.oscClient == null) {
reply.success = false;
reply.message = 'Client not initialised';
return reply;
}
if (messageType == null) {
reply.success = false;
reply.message = 'Message undefined';
return reply;
}
// only specify special cases
switch (payload) {
case 'overtime': {
// Whether timer is negative
this.oscClient.send(`${this.ADDRESS}/overtime`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
case 'title': {
if (payload != null && payload !== '') {
// Send Title of current event
this.oscClient.send(`${this.ADDRESS}/title`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'eventNumber': {
if (payload != null && payload !== '') {
// Send event number of current event
this.oscClient.send(`${this.ADDRESS}/eventNumber`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'presenter': {
if (payload != null && payload !== '') {
// Send timer data on current event
this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
default: {
// catch all for messages, allows to add new messages
// but should be used with the integrations definition
const message = new Message(`${this.ADDRESS}/${messageType}`);
if (payload != null) message.append(payload);
this.oscClient.send(message, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
}
return reply;
}
shutdown() {
// Shutdown client object
this.oscClient.close();
this.oscClient = null;
}
}
@@ -1,163 +0,0 @@
import { OSCIntegration } from '../Osc';
import { Server } from 'node-osc';
test('Class initialises correctly', () => {
const osc = new OSCIntegration();
expect(osc.ADDRESS).toBe('/ontime');
expect(osc.oscClient).toBe(null);
// defined objects
expect(osc.implemented.play).toBeDefined();
expect(osc.implemented.pause).toBeDefined();
expect(osc.implemented.stop).toBeDefined();
expect(osc.implemented.previous).toBeDefined();
expect(osc.implemented.next).toBeDefined();
expect(osc.implemented.reload).toBeDefined();
expect(osc.implemented.finished).toBeDefined();
expect(osc.implemented.time).toBeDefined();
expect(osc.implemented.overtime).toBeDefined();
expect(osc.implemented.title).toBeDefined();
expect(osc.implemented.eventNumber).toBeDefined();
expect(osc.implemented.presenter).toBeDefined();
// initialise client succeeds
const { ip, port } = { ip: '127.0.0.1', port: 12345 };
const init = osc.init({ ip, port });
expect(init.message).toBe(`Initialised OSC Client at ${ip}:${port}`);
expect(init.success).toBe(true);
expect(osc.oscClient).not.toBe(null);
// object shutdown as expected
osc.shutdown();
expect(osc.oscClient).toBe(null);
});
describe('OSC fails to initialise when incorrect data is given', () => {
it('IP of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 123, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('IP is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: null, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: 'test' });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: null });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
});
test('Test messages sending', async () => {
const testPort = 9999;
const testIP = 'localhost';
const testPayload = 'test';
const osc = new OSCIntegration();
const messages = [];
// prepare dummy server to receive messages
const oscServer = new Server(testPort, testIP);
oscServer.on('message', (m) => {
messages.push({ yay: m });
});
// try and send a message before initialising
const test = await osc.send('test');
expect(test.success).toBe(false);
expect(test.message).toBe('Client not initialised');
// initialise osc
osc.init({ ip: testIP, port: testPort });
// try and send unrecognised message
const test2 = await osc.send('test');
expect(test2.success).toBe(true);
// send play message
const playAddress = osc.implemented.play;
const playSent = await osc.send(playAddress);
expect(playSent.success).toBe(true);
// send pause message
const pauseAddress = osc.implemented.pause;
const pauseSent = await osc.send(pauseAddress);
expect(pauseSent.success).toBe(true);
// send stop message
const stopAddress = osc.implemented.stop;
const stopSent = await osc.send(stopAddress);
expect(stopSent.success).toBe(true);
// send previous message
const previousAddress = osc.implemented.previous;
const previousSent = await osc.send(previousAddress);
expect(previousSent.success).toBe(true);
// send next message
const nextAddress = osc.implemented.next;
const nextSent = await osc.send(nextAddress);
expect(nextSent.success).toBe(true);
// send reload message
const reloadAddress = osc.implemented.reload;
const reloadSent = await osc.send(reloadAddress);
expect(reloadSent.success).toBe(true);
// send finished message
const finishedAddress = osc.implemented.finished;
const finishedSent = await osc.send(finishedAddress);
expect(finishedSent.success).toBe(true);
// send time message
const timeAddress = osc.implemented.time;
const timeSent = await osc.send(timeAddress);
expect(timeSent.success).toBe(true);
// send overtime message
const overtimeAddress = osc.implemented.overtime;
const overtimeSent = await osc.send(overtimeAddress, testPayload);
expect(overtimeSent.success).toBe(true);
// send title message
const titleAddress = osc.implemented.title;
const titleSent = await osc.send(titleAddress, testPayload);
expect(titleSent.success).toBe(true);
// send eventNumber message
const eventNumberAddress = osc.implemented.eventNumber;
const eventNumberSent = await osc.send(eventNumberAddress, testPayload);
expect(eventNumberSent.success).toBe(true);
// send timer message
const presenterAddress = osc.implemented.presenter;
const presenterSent = await osc.send(presenterAddress, testPayload);
expect(presenterSent.success).toBe(true);
// cleanup
await osc.shutdown();
await oscServer.close();
// see messagesObject
// expect(messages.length).toBe(5);
});
@@ -7,7 +7,7 @@ import { messageManager } from '../message-manager/MessageManager.js';
import { PlaybackService } from '../../services/PlaybackService.js';
import { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js';
import { eventTimer, TimerService } from '../../services/TimerService.js';
import { eventTimer, TimerService } from '../../services/TimerService.ts';
import { EventLoader, eventLoader } from '../event-loader/EventLoader.js';
class SocketController {
-17
View File
@@ -1,25 +1,8 @@
export const config = {
timer: {
refresh: 1000,
},
server: {
port: 4001,
},
database: {
testdb: 'test-db',
directory: 'preloaded-db',
filename: 'db.json',
tablename: 'events',
},
osc: {
port: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
inputEnabled: true,
},
http: {
user: '',
pwd: '',
enabled: true,
},
};
@@ -1,4 +1,6 @@
import { Server } from 'node-osc';
import { OSCSettings } from 'ontime-types';
import { PlaybackService } from '../services/PlaybackService.js';
import { messageManager } from '../classes/message-manager/MessageManager.js';
import { socketProvider } from '../classes/socket/SocketController.js';
@@ -14,11 +16,10 @@ export const shutdownOSCServer = () => {
};
/**
* @description initialises OSC server
* @param {object} config
* Initialises OSC server
*/
export const initiateOSC = (config) => {
oscServer = new Server(config.port, '0.0.0.0');
export const initiateOSC = (config: OSCSettings) => {
oscServer = new Server(config.portIn, '0.0.0.0');
oscServer.on('error', console.error);
@@ -34,7 +35,7 @@ export const initiateOSC = (config) => {
// get first part before (ontime)
if (address !== 'ontime') {
console.error('RX', `OSC IN: Message address ${address} not recognised`);
console.error('RX', `OSC IN: Message address ${address} not recognised`, msg);
return;
}
@@ -123,10 +124,7 @@ export const initiateOSC = (config) => {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex) || eventIndex <= 0) {
socketProvider.error(
'RX',
`OSC IN: event index not recognised or out of range ${eventIndex}`
);
socketProvider.error('RX', `OSC IN: event index not recognised or out of range ${eventIndex}`);
} else {
PlaybackService.loadByIndex(eventIndex - 1);
}
@@ -1,6 +1,6 @@
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
// Create controller for GET request to 'event'
export const getEvent = async (req, res) => {
@@ -2,7 +2,7 @@ import fs from 'fs';
import { networkInterfaces } from 'os';
import { generateId } from 'ontime-utils';
import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/PlaybackService.js';
@@ -65,10 +65,11 @@ export const validateSettings = [
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('port').exists().isInt({ min: 0, max: 65353 }),
body('portOut').exists().isInt({ min: 0, max: 65353 }),
body('portIn').exists().isInt({ min: 1024, max: 65535 }),
body('portOut').exists().isInt({ min: 1024, max: 65535 }),
body('targetIP').exists().isIP(),
body('enabled').exists().isBoolean(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
@@ -1,4 +1,4 @@
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
import { failEmptyObjects } from '../utils/routerUtils.js';
import {
addEvent,
+3 -5
View File
@@ -1,16 +1,14 @@
// import { promise } from './modules/loadDb.js';
import { startOSCServer, startServer } from './app.js';
import { startDb, startIntegrations, startOSCServer, startServer } from './app.js';
async function startOntime() {
try {
// await promise;
await startDb();
// Start express server
const loaded = await startServer();
console.log(loaded);
// Start OSC Server (API)
await startOSCServer();
await startIntegrations();
} catch (error) {
console.log('Error starting Ontime');
console.log(error);
@@ -1,4 +1,6 @@
export const dbModel = {
import { DatabaseModel } from 'ontime-types';
export const dbModel: DatabaseModel = {
rundown: [],
event: {
title: '',
@@ -33,10 +35,37 @@ export const dbModel = {
user9: 'user9',
},
osc: {
port: 8888,
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabled: true,
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,
},
},
},
http: {
user: null,
@@ -1,6 +1,8 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { copyFileSync, existsSync } from 'fs';
import { DatabaseModel } from 'ontime-types';
import { ensureDirectory } from '../utils/fileManagement.js';
import { validateFile } from '../utils/parserUtils.js';
import { dbModel } from '../models/dataModel.js';
@@ -51,7 +53,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
async function loadDb() {
const dbInDisk = populateDb();
const adapter = new JSONFile(dbInDisk);
const adapter = new JSONFile<DatabaseModel>(dbInDisk);
const db = new Low(adapter);
const data = await parseDb(dbInDisk, db);
@@ -63,11 +65,11 @@ async function loadDb() {
}
export let db = {};
export let data = {};
export const promise = loadDb();
export let data = {} as DatabaseModel;
export const dbLoadingProcess = loadDb();
const init = async () => {
const dbProvider = await promise;
const dbProvider = await dbLoadingProcess;
db = dbProvider.db;
data = dbProvider.data;
};
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import { socketProvider } from '../classes/socket/SocketController.js';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer, TimerService } from './TimerService.js';
import { eventTimer, TimerService } from './TimerService.ts';
/**
* Service manages playback status of app
+2 -2
View File
@@ -1,5 +1,5 @@
import { generateId } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
import {
block as blockDef,
delay as delayDef,
@@ -7,7 +7,7 @@ import {
} from '../models/eventsDefinition.js';
import { MAX_EVENTS } from '../settings.js';
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer } from './TimerService.js';
import { eventTimer } from './TimerService.ts';
import { socketProvider } from '../classes/socket/SocketController.js';
/**
@@ -1,15 +1,40 @@
import { TimerLifeCycle } from 'ontime-types';
import { runtimeState } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { DAY_TO_MS } from '../utils/time.js';
import { integrationService } from './integration-service/IntegrationService.js';
export class TimerService {
private readonly _interval: NodeJS.Timer;
private playback: string;
private loadedTimerId: null;
private _pausedInterval: number;
private _pausedAt: number | null;
private _secondaryTarget: number | null;
timer: {
clock: number;
current: number | null;
elapsed: number | null;
expectedFinish: number | null;
addedTime: number;
startedAt: number | null;
finishedAt: number | null;
secondaryTimer: number | null;
selectedEventId: string | null;
duration: number | null;
};
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
*/
constructor(timerConfig) {
constructor(timerConfig?) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
}
@@ -45,7 +70,7 @@ export class TimerService {
return Math.max(
this.timer.startedAt + this.timer.duration + this._pausedInterval + this.timer.addedTime,
this.timer.startedAt
this.timer.startedAt,
);
}
@@ -64,6 +89,8 @@ export class TimerService {
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
};
this.loadedTimerId = null;
this._pausedInterval = 0;
@@ -138,6 +165,7 @@ export class TimerService {
_onLoad() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onLoad);
}
start() {
@@ -172,6 +200,7 @@ export class TimerService {
_onStart() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onStart);
}
pause() {
@@ -188,6 +217,7 @@ export class TimerService {
_onPause() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onPause);
}
stop() {
@@ -202,6 +232,7 @@ export class TimerService {
_onStop() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onStop);
}
/**
@@ -249,8 +280,7 @@ export class TimerService {
secondaryTimer: this.timer.secondaryTimer,
_secondaryTarget: this._secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } =
updateRoll(tempCurrentTimer);
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
@@ -272,11 +302,7 @@ export class TimerService {
}
this.timer.current =
this.timer.startedAt +
this.timer.duration +
this.timer.addedTime +
this._pausedInterval -
this.timer.clock;
this.timer.startedAt + this.timer.duration + this.timer.addedTime + this._pausedInterval - this.timer.clock;
this.timer.elapsed = this.timer.duration - this.timer.current;
if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) {
@@ -294,11 +320,13 @@ export class TimerService {
_onUpdate() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
_onFinish() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onFinish);
}
roll(currentEvent, nextEvent, timers) {
@@ -0,0 +1,24 @@
import { TimerLifeCycle, OscSubscription } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration {
subscriptions: OscSubscription;
init: (config: unknown) => OperationReturn;
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
emit: (...args: unknown[]) => unknown;
shutdown: () => void;
}
// either went well, or explain what failed
type OperationReturn = ReturnOnSuccess | ReturnOnError;
type ReturnOnSuccess = {
success: true;
message?: string;
};
type ReturnOnError = {
success: false;
message: string;
};
@@ -0,0 +1,34 @@
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { runtimeState } from '../../stores/EventStore.js';
class IntegrationService {
private integrations: IIntegration[];
constructor() {
this.integrations = [];
}
register(integrationService: IIntegration) {
this.integrations.push(integrationService);
}
unregister(integrationService: IIntegration) {
this.integrations = this.integrations.filter((int) => int !== integrationService);
}
dispatch(action: TimerLifeCycleKey) {
const state = runtimeState.poll();
this.integrations.forEach((integration) => {
integration.dispatch(action, state);
});
}
shutdown() {
console.log('Shutdown integrations');
this.integrations.forEach((integration) => {
integration.shutdown();
});
}
}
export const integrationService = new IntegrationService();
@@ -0,0 +1,118 @@
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 { isObject } from '../../utils/varUtils.js';
type Action = TimerLifeCycleKey | string;
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration {
protected oscClient: null | Client;
subscriptions: OscSubscription;
constructor() {
this.oscClient = null;
this.subscriptions = {};
}
/**
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions } = config;
this.initSubscriptions(subscriptions);
// runtime validation
const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number';
const validateNull = !targetIP || !portOut;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try {
this.oscClient = new Client(targetIP, portOut);
return {
success: true,
message: `OSC integration client connected to ${targetIP}:${portOut}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
}
}
initSubscriptions(subscriptionOptions: OscSubscription) {
this.subscriptions = { ...this.subscriptions, ...subscriptionOptions };
}
dispatch(action: Action, state?: object) {
if (!this.oscClient) {
return {
success: false,
message: 'Client not initialised',
};
}
if (!action) {
return {
success: false,
message: 'OSC called with no action',
};
}
// check subscriptions for action
const { enabled, message } = this.subscriptions?.[action] || {};
if (enabled) {
const parsedMessage = parseTemplate(message, state || {});
this.emit('address/', parsedMessage);
}
}
emit(path: string, payload?: ArgumentType) {
const message = new Message(path);
if (payload) {
try {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
} catch (error) {
console.log('OSC ERROR', error, payload);
}
}
this.oscClient.send(message, (error) => {
if (error) {
return {
success: false,
message: `error is here ${JSON.stringify(error)}`,
};
}
return {
success: true,
message: 'OSC Message sent',
};
});
}
shutdown() {
console.log('Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
}
}
}
@@ -0,0 +1,53 @@
import { parseTemplate } from './integrationUtils.js';
describe('integrationUtils', () => {
it('correctly parses a given string', () => {
const mockState = { test: 'this' };
const testString = 'That should replace {{test}}';
const expected = `That should replace ${mockState.test}`;
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
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 = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('correctly parses a string without templates', () => {
const testString = 'That should replace {test}';
const result = parseTemplate(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 = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('doesnt yet handle nested variables', () => {
const mockState = {
timer: {
time: '10',
},
enabled: 'is',
};
const testString = 'Timer {{enabled}} enabled with {{timer.time}}ms interval';
const expected = 'Timer is enabled with {{timer.time}}ms interval';
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
});
@@ -0,0 +1,18 @@
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
/**
* Parses a templated string
*/
export function parseTemplate(template: string, state: object): string {
let parsedTemplate = template;
let match;
while ((match = placeholderRegex.exec(template)) !== null) {
const variableName = match[1];
if (Object.hasOwn(state, variableName)) {
parsedTemplate = parsedTemplate.replace(match[0], state[variableName]);
}
}
return parsedTemplate;
}
@@ -0,0 +1,11 @@
import { isObject } from '../varUtils.js';
describe('isObject', () => {
const testCases = [1, 0, false, undefined, 'test', null, () => undefined, []];
testCases.forEach((test) => {
it(`recognises normal primitives ${test}`, () => {
const result = isObject(test);
expect(result).toBe(false);
});
});
});
@@ -1,4 +1,6 @@
import { generateId } from 'ontime-utils';
import { OSCSettings } from 'ontime-types';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { validateEvent } from './parser.js';
@@ -148,31 +150,27 @@ export const parseViews = (data, enforce) => {
/**
* Parse osc portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseOsc = (data, enforce) => {
let newOsc = {};
export const parseOsc = (
data: { osc?: Partial<OSCSettings> },
enforce: boolean,
): OSCSettings | Record<string, never> => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
const s = data.osc;
const osc = {};
if (s.port) osc.port = s.port;
if (s.portOut) osc.portOut = s.portOut;
if (s.targetIP) osc.targetIP = s.targetIP;
if (typeof s.enabled !== 'undefined') osc.enabled = s.enabled;
// write to db
newOsc = {
...dbModel.osc,
...osc,
const loadedConfig = data?.osc || {};
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,
};
} else if (enforce) {
newOsc = { ...dbModel.osc };
console.log(`Created OSC object in db`);
}
return newOsc;
return { ...dbModel.osc };
} else return {};
};
/**
@@ -185,18 +183,21 @@ export const parseHttp = (data, enforce) => {
const newHttp = {};
if ('http' in data) {
console.log('Found HTTP definition, importing...');
const h = data.osc;
const h = data.http;
const http = {};
// @ts-expect-error -- not yet
if (h.user) http.user = h.user;
// @ts-expect-error -- not yet
if (h.pwd) http.pwd = h.pwd;
// write to db
// @ts-expect-error -- not yet
newHttp.http = {
...dbModel.http,
...http,
};
} else if (enforce) {
// @ts-expect-error -- not yet
newHttp.http = { ...dbModel.http };
console.log(`Created http object in db`);
}
+3
View File
@@ -0,0 +1,3 @@
export function isObject(variable: unknown): boolean {
return typeof variable === 'object' && variable !== null && !Array.isArray(variable);
}