From f4f266dbd455b56f32cdf57f7fe7e652be370e63 Mon Sep 17 00:00:00 2001 From: arc-alex Date: Sun, 12 Jan 2025 13:48:48 +0100 Subject: [PATCH] refactor: migrate to OSC min --- apps/server/package.json | 2 +- apps/server/src/adapters/OscAdapter.ts | 54 ++++++++++----- .../__tests__/automation.utils.test.ts | 44 +++++++------ .../automation/automation.controller.ts | 6 ++ .../api-data/automation/automation.utils.ts | 66 ++++++++++--------- .../api-data/automation/clients/osc.client.ts | 28 +++++--- apps/server/src/app.ts | 9 ++- pnpm-lock.yaml | 59 ++++------------- 8 files changed, 143 insertions(+), 125 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 5b9262f7d..52fbeea3a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,8 +18,8 @@ "got": "^14.4.5", "lowdb": "^7.0.1", "multer": "^1.4.5-lts.1", - "node-osc": "^9.1.4", "ontime-utils": "workspace:*", + "osc-min": "^2.1.1", "sanitize-filename": "^1.6.3", "steno": "^4.0.2", "ws": "^8.18.0", diff --git a/apps/server/src/adapters/OscAdapter.ts b/apps/server/src/adapters/OscAdapter.ts index 67f5e5f11..b98780147 100644 --- a/apps/server/src/adapters/OscAdapter.ts +++ b/apps/server/src/adapters/OscAdapter.ts @@ -1,33 +1,51 @@ import { LogOrigin } from 'ontime-types'; -import { Server } from 'node-osc'; +import { fromBuffer } from 'osc-min'; +import * as dgram from 'node:dgram'; import { IAdapter } from './IAdapter.js'; import { logger } from '../classes/Logger.js'; import { integrationPayloadFromPath } from './utils/parse.js'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; +import { isOntimeCloud } from '../externals.js'; -export class OscServer implements IAdapter { - private readonly osc: Server; +class OscServer implements IAdapter { + private udpSocket: dgram.Socket | null = null; - constructor(portIn: number) { - this.osc = new Server(portIn, '0.0.0.0'); - - this.osc.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`)); - - this.osc.on('message', (msg) => { + init(port: number) { + if (isOntimeCloud) { + logger.warning(LogOrigin.Rx, 'OSC: Skip starting server in cloud environment'); + } + this.udpSocket?.close(); + logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`); + this.udpSocket = dgram.createSocket('udp4'); + this.udpSocket.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`)); + this.udpSocket.on('message', (buf: ArrayBuffer) => { // message should look like /ontime/{command}/{params?} {args} where // ontime: fixed message for app // command: command to be called // params: used to create a nested object to patch with // args: extra data, only used on some API entries - // split message - const [, address, command, ...params] = msg[0].split('/'); - const args = msg[1]; + /** + * TODO: remove this type casting when mergend in deleration file + * https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659 + */ + const msg = fromBuffer(buf); + if (msg.oscType === 'bundle') { + //TODO: manage bundles + logger.error(LogOrigin.Rx, `OSC IN: We don't take bundles`); + return; + } - // get first part before (ontime) - if (address !== 'ontime') { + const { address, args: oscArgs } = msg; + + // split message + const [, ontimeKey, command, ...params] = address.split('/'); + const args = oscArgs[0]?.value ?? undefined; //TODO: manage multiple args or mayeb we have no usecase + + // get first part (ontime) + if (ontimeKey !== 'ontime') { logger.error(LogOrigin.Rx, `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`); return; } @@ -50,9 +68,13 @@ export class OscServer implements IAdapter { logger.error(LogOrigin.Rx, `OSC IN: ${error}`); } }); + this.udpSocket.bind(port); } - shutdown() { - this.osc?.close(); + logger.info(LogOrigin.Rx, `OSC: Closing server`); + this.udpSocket?.close(); + this.udpSocket = null; } } + +export const oscServer = new OscServer(); 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 index 919715de6..6c7ad2b71 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts @@ -113,31 +113,31 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => { const payloads = [ { test: '"string with space and {{not.so.easy}}"', - expect: [{ type: 'string', value: 'string with space and data with space' }], + expect: { type: 'string', value: 'string with space and data with space' }, }, { test: '', - expect: [], + expect: undefined, }, { test: ' ', - expect: [], + expect: undefined, }, { test: '""', - expect: [{ type: 'string', value: '' }], + expect: { type: 'string', value: '' }, }, { test: '"string with space and {{not.so.empty}}"', - expect: [{ type: 'string', value: 'string with space and ' }], + 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' }], + 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' }], + expect: { type: 'string', value: 'string with space and 1234' }, }, { test: '"{{not.so.easy}}" 1', @@ -155,7 +155,7 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => { }, { test: '', - expect: [], + expect: undefined, }, ]; @@ -174,22 +174,28 @@ describe('test stringToOSCArgs()', () => { { type: 'string', value: 'test' }, { type: 'integer', value: 1111 }, { type: 'float', value: 0.1111 }, - { type: 'T', value: true }, - { type: 'F', value: false }, + { type: 'true' }, + { type: 'false' }, ]; expect(stringToOSCArgs(test)).toStrictEqual(expected); }); it('empty is nothing', () => { - expect(stringToOSCArgs(undefined)).toStrictEqual([]); + const test = undefined; + const expected = undefined; + expect(stringToOSCArgs(test)).toStrictEqual(expected); }); it('empty is nothing', () => { - expect(stringToOSCArgs('')).toStrictEqual([]); + const test = ''; + const expected = undefined; + expect(stringToOSCArgs(test)).toStrictEqual(expected); }); it('1 space is nothing', () => { - expect(stringToOSCArgs(' ')).toStrictEqual([]); + const test = ' '; + const expected = undefined; + expect(stringToOSCArgs(test)).toStrictEqual(expected); }); it('keep other types in strings', () => { @@ -210,8 +216,8 @@ describe('test stringToOSCArgs()', () => { { type: 'string', value: 'test space' }, { type: 'integer', value: 1111 }, { type: 'float', value: 0.1111 }, - { type: 'T', value: true }, - { type: 'F', value: false }, + { type: 'true' }, + { type: 'false' }, ]; expect(stringToOSCArgs(test)).toStrictEqual(expected); }); @@ -222,8 +228,8 @@ describe('test stringToOSCArgs()', () => { { type: 'string', value: 'test " space' }, { type: 'integer', value: 1111 }, { type: 'float', value: 0.1111 }, - { type: 'T', value: true }, - { type: 'F', value: false }, + { type: 'true' }, + { type: 'false' }, ]; expect(stringToOSCArgs(test)).toStrictEqual(expected); }); @@ -233,8 +239,8 @@ describe('test stringToOSCArgs()', () => { const expected = [ { type: 'integer', value: 1111 }, { type: 'float', value: 0.1111 }, - { type: 'T', value: true }, - { type: 'F', value: false }, + { type: 'true' }, + { type: 'false' }, ]; expect(stringToOSCArgs(test)).toStrictEqual(expected); }); diff --git a/apps/server/src/api-data/automation/automation.controller.ts b/apps/server/src/api-data/automation/automation.controller.ts index 54eea6cc7..1edec02fa 100644 --- a/apps/server/src/api-data/automation/automation.controller.ts +++ b/apps/server/src/api-data/automation/automation.controller.ts @@ -5,6 +5,7 @@ import type { Request, Response } from 'express'; import * as automationDao from './automation.dao.js'; import * as automationService from './automation.service.js'; +import { oscServer } from '../../adapters/OscAdapter.js'; export function getAutomationSettings(_req: Request, res: Response) { res.json(automationDao.getAutomationSettings()); @@ -20,6 +21,11 @@ export function postAutomationSettings(req: Request, res: Response(); +function toOscValue(argString: string): OscArgInput { + 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: 'true' }; + } + + if (argString === 'FALSE') { + // NOTE: Boolean false + return { type: 'false' }; + } + + // NOTE: string + return { type: 'string', value: argString }; +} + +export function stringToOSCArgs(argsString: string | undefined): OscArgInput | OscArgOrArrayInput[] | undefined { + if (typeof argsString === 'undefined' || argsString === '') return; + const matches = splitWhitespace(argsString); - if (!matches) { - return new Array(); + if (!matches) return; + + if (matches.length === 1) { + return toOscValue(matches[0]); } - 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 }; - }); + const parsedArguments: OscArgOrArrayInput[] = matches.map(toOscValue); return parsedArguments; } 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 b3274debc..f0c5b6205 100644 --- a/apps/server/src/api-data/automation/clients/osc.client.ts +++ b/apps/server/src/api-data/automation/clients/osc.client.ts @@ -1,11 +1,14 @@ import { LogOrigin, OSCOutput } from 'ontime-types'; -import { Client, Message } from 'node-osc'; +import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min'; +import * as dgram from 'node:dgram'; import { logger } from '../../../classes/Logger.js'; import { type RuntimeState } from '../../../stores/runtimeState.js'; import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; +const udpClient = dgram.createSocket('udp4'); + /** * Expose possibility to send a message using OSC protocol */ @@ -15,25 +18,30 @@ export function emitOSC(output: OSCOutput, state: RuntimeState) { } /** Parses the state and prepares payload to be emitted */ -function preparePayload(output: OSCOutput, state: RuntimeState): Message { +function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput { // 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; + const oscArguments = stringToOSCArgs(parsedArguments); + return { address: parsedAddress, args: oscArguments }; } /** Emits message over transport */ -function emit(targetIP: string, targetPort: number, message: Message) { - logger.info(LogOrigin.Rx, `Sending OSC: ${targetIP}:${targetPort}`); +function emit(targetIP: string, targetPort: number, packet: OscPacketInput) { + logger.info(LogOrigin.Tx, `Sending OSC: ${targetIP}:${targetPort}`); - const oscClient = new Client(targetIP, targetPort); - oscClient.send(message, () => { - oscClient.close(); + /** + * TODO: remove this type casting when change is merged + * https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659 + */ + const buffer = oscPacketToBuffer(packet) as unknown as Uint8Array; + udpClient.send(buffer, 0, buffer.byteLength, targetPort, targetIP, (error) => { + if (error) { + logger.warning(LogOrigin.Tx, `Failed sending OSC: ${error}`); + } }); return; } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index f1596ce96..9a7d9cf76 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -9,7 +9,7 @@ import cookieParser from 'cookie-parser'; // import utils import { publicDir, srcDir } from './setup/index.js'; -import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js'; +import { environment, isProduction, updateRouterPrefix } from './externals.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; @@ -37,13 +37,14 @@ import { populateDemo } from './setup/loadDemo.js'; import { getState } from './stores/runtimeState.js'; import { initRundown } from './services/rundown-service/RundownService.js'; import { initialiseProject } from './services/project-service/ProjectService.js'; +import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; +import { oscServer } from './adapters/OscAdapter.js'; // Utilities import { clearUploadfolder } from './utils/upload.js'; import { generateCrashReport } from './utils/generateCrashReport.js'; import { timerConfig } from './config/config.js'; import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js'; -import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; console.log('\n'); consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`); @@ -231,6 +232,10 @@ export const startServer = async ( */ export const startIntegrations = async () => { checkStart(OntimeStartOrder.InitIO); + const { enabledOscIn, oscPortIn } = getDataProvider().getAutomation(); + if (enabledOscIn) { + oscServer.init(oscPortIn); + } }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd29fcf9d..1d0cac592 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ catalogs: eslint: specifier: 8.56.0 version: 8.56.0 - eslint-config-prettier: - specifier: 9.1.0 - version: 9.1.0 eslint-plugin-prettier: specifier: 5.1.3 version: 5.1.3 @@ -254,7 +251,7 @@ importers: version: 31.2.0 electron-builder: specifier: ^24.13.3 - version: 24.13.3(electron-builder-squirrel-windows@24.13.3) + version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) eslint: specifier: 'catalog:' version: 8.56.0 @@ -309,12 +306,12 @@ importers: multer: specifier: ^1.4.5-lts.1 version: 1.4.5-lts.1 - node-osc: - specifier: ^9.1.4 - version: 9.1.4 ontime-utils: specifier: workspace:* version: link:../../packages/utils + osc-min: + specifier: ^2.1.1 + version: 2.1.1 sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -2406,9 +2403,6 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - binpack@0.1.0: - resolution: {integrity: sha512-KcSrsGiIKgklTWweVb9XnZPWO1/rGSsK3fwR7VnbDPbLKPlkvSKd/ZrJ1W712r6HzH5u0fa/AZCftATO09x8Aw==} - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -3912,10 +3906,6 @@ packages: encoding: optional: true - node-osc@9.1.4: - resolution: {integrity: sha512-ChkdOHmy2Ay6egrGsL9C7u0RqBQ+VIHjw+lk6yd59TuyzmqbaosCo53E1lvjh1xM97c+ByBhyH6i2dHOiBa2bw==} - engines: {node: ^18.17.0 || ^20.5.0 || >=22.0.0} - node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} @@ -3987,8 +3977,8 @@ packages: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} - osc-min@1.1.2: - resolution: {integrity: sha512-8DbiO8ME85R75stgNVCZtHxB9MNBBNcyy+isNBXrsFeinXGjwNAauvKVmGlfRas5VJWC/mhzIx7spR2gFvWxvg==} + osc-min@2.1.1: + resolution: {integrity: sha512-3Ai9vP8AAh1sCh5Zm8BUybfulv5OvqpTVa2BcUoTrLeD1Ss3cohIQpF1f18gx0CTbi9Dv4SvtdMb7y4g++7lYA==} engines: {node: '>=0.10.0'} p-cancelable@2.1.1: @@ -5004,18 +4994,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -7118,7 +7096,7 @@ snapshots: app-builder-bin@4.0.0: {} - app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3): + app-builder-lib@24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)): dependencies: '@develar/schema-utils': 2.6.5 '@electron/notarize': 2.2.1 @@ -7265,8 +7243,6 @@ snapshots: binary-extensions@2.2.0: {} - binpack@0.1.0: {} - bl@4.1.0: dependencies: buffer: 5.7.1 @@ -7689,7 +7665,7 @@ snapshots: dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) builder-util: 24.13.1 builder-util-runtime: 9.2.4 fs-extra: 10.1.0 @@ -7755,7 +7731,7 @@ snapshots: electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3): dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) archiver: 5.3.2 builder-util: 24.13.1 fs-extra: 10.1.0 @@ -7763,9 +7739,9 @@ snapshots: - dmg-builder - supports-color - electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): + electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)): dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) builder-util: 24.13.1 builder-util-runtime: 9.2.4 chalk: 4.1.2 @@ -8811,7 +8787,7 @@ snapshots: whatwg-encoding: 2.0.0 whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - ws: 8.13.0 + ws: 8.18.0 xml-name-validator: 4.0.0 transitivePeerDependencies: - bufferutil @@ -9052,10 +9028,6 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-osc@9.1.4: - dependencies: - osc-min: 1.1.2 - node-releases@2.0.14: {} normalize-path@3.0.0: {} @@ -9134,9 +9106,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - osc-min@1.1.2: - dependencies: - binpack: 0.1.0 + osc-min@2.1.1: {} p-cancelable@2.1.1: {} @@ -10175,8 +10145,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.13.0: - optional: true + ws@8.13.0: {} ws@8.18.0: {}