diff --git a/apps/server/src/adapters/OscAdapter.ts b/apps/server/src/adapters/OscAdapter.ts index d1a3a3ead..ab7478789 100644 --- a/apps/server/src/adapters/OscAdapter.ts +++ b/apps/server/src/adapters/OscAdapter.ts @@ -15,13 +15,13 @@ export class OscServer implements IAdapter { this.osc.on('error', console.error); this.osc.on('message', (msg) => { - // message should look like /ontime/{path} {args} where + // message should look like /ontime/{path}/{params?} {args} where // ontime: fixed message for app // path: command to be called // args: extra data, only used on some API entries (delay, goto) // split message - const [, address, path] = msg[0].split('/'); + const [, address, path, ...params] = msg[0].split('/'); const args = msg[1]; // get first part before (ontime) @@ -37,7 +37,14 @@ export class OscServer implements IAdapter { } try { - const reply = dispatchFromAdapter(path, args, 'osc'); + const reply = dispatchFromAdapter( + path, + { + payload: args, + params, + }, + 'osc', + ); if (reply) { const { topic, payload } = reply; this.osc.emit(topic, payload); diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index b5668c784..53fe92b80 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -123,7 +123,13 @@ export class SocketServer implements IAdapter { // Protocol specific stuff handled above try { - const reply = dispatchFromAdapter(type, payload, 'ws'); + const reply = dispatchFromAdapter( + type, + { + payload, + }, + 'ws', + ); if (reply) { const { topic, payload } = reply; ws.send(topic, payload); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 10e61c50e..812ea0205 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -200,7 +200,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => { } const { success, message } = oscIntegration.init(osc); - logger.info(LogOrigin.Rx, message); + logger.info(LogOrigin.Tx, message); if (success) { integrationService.register(oscIntegration); diff --git a/apps/server/src/controllers/integrationController.config.ts b/apps/server/src/controllers/integrationController.config.ts new file mode 100644 index 000000000..aa3bc3cf0 --- /dev/null +++ b/apps/server/src/controllers/integrationController.config.ts @@ -0,0 +1,69 @@ +import { LogOrigin, OntimeEvent } from 'ontime-types'; +import { EventLoader } from '../classes/event-loader/EventLoader.js'; +import { editEvent } from '../services/rundown-service/RundownService.js'; +import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js'; +import { logger } from '../classes/Logger.js'; + +const whitelistedPayload = { + title: coerceString, + subtitle: coerceString, + presenter: coerceString, + note: coerceString, + cue: coerceString, + + duration: coerceNumber, + + isPublic: coerceBoolean, + skip: coerceBoolean, + + colour: coerceString, + user0: coerceString, + user1: coerceString, + user2: coerceString, + user3: coerceString, + user4: coerceString, + user5: coerceString, + user6: coerceString, + user7: coerceString, + user8: coerceString, + user9: coerceString, +}; + +export function parse(field: string, value: unknown) { + if (!whitelistedPayload.hasOwnProperty(field)) { + throw new Error(`Field ${field} not permitted`); + } + const parserFn = whitelistedPayload[field]; + return parserFn(value); +} + +/** + * Updates a property of the event with the given id + * @param {string} eventId + * @param {keyof OntimeEvent} propertyName + * @param {OntimeEvent[typeof propertyName]} newValue + */ +export function updateEvent( + eventId: string, + propertyName: keyof OntimeEvent, + newValue: OntimeEvent[typeof propertyName], +) { + const event = EventLoader.getEventWithId(eventId); + + if (event) { + let propertiesToUpdate = { [propertyName]: newValue }; + + // Handles the special case for duration + // needs to be converted to milliseconds + if (propertyName === 'duration') { + propertiesToUpdate.duration = (newValue as number) * 1000; + propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration; + } + + editEvent({ id: eventId, ...propertiesToUpdate }).then(() => { + logger.info(LogOrigin.Playback, `Updated ${propertyName} of event with ID ${eventId} to ${newValue}`); + }); + } else { + throw new Error(`Event with ID ${eventId} not found`); + } +} diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index a0331512c..7103dbb69 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -1,9 +1,26 @@ +import { LogOrigin, OntimeEvent } from 'ontime-types'; + import { messageService } from '../services/message-service/MessageService.js'; import { PlaybackService } from '../services/PlaybackService.js'; import { eventStore } from '../stores/EventStore.js'; +import { parse, updateEvent } from './integrationController.config.js'; +import { isKeyOfType } from 'ontime-types/src/utils/guards.js'; +import { event } from '../models/eventsDefinition.js'; -export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') { - switch (type.toLowerCase()) { +export function dispatchFromAdapter( + type: string, + args: { + payload: unknown; + params?: Array; + }, + source?: 'osc' | 'ws', +) { + const payload = args.payload; + const typeComponents = type.toLowerCase().split('/'); + const mainType = typeComponents[0]; + const params = args.params || []; + + switch (mainType) { case 'test-ontime': { return { topic: 'hello' }; } @@ -222,6 +239,23 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os return { topic: 'timer', payload: timer }; } + // ontime/change/{eventID}/{propertyName} + case 'change': { + if (params.length < 2) { + throw new Error(`To few parameters`); + } + if (payload === undefined) { + throw new Error(`Undefined payload`); + } + const eventID = params[0]; + const propertyName = params[1] as keyof OntimeEvent; + if (!isKeyOfType(propertyName, event)) { + throw new Error(`Cannot update unknown event property ${propertyName}`); + } + const parsedPayload = parse(propertyName, payload); + return updateEvent(eventID, propertyName, parsedPayload); + } + default: { throw new Error(`Unhandled message ${type}`); } diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 406940d4d..8a35ec09c 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -295,7 +295,7 @@ export const postOscSubscriptions = async (req, res) => { // TODO: this update could be more granular, checking that relevant data was changed const { message } = oscIntegration.init(oscSettings); - logger.info(LogOrigin.Rx, message); + logger.info(LogOrigin.Tx, message); res.send(oscSettings).status(200); } catch (error) { @@ -316,7 +316,7 @@ export const postOSC = async (req, res) => { // TODO: this update could be more granular, checking that relevant data was changed const { message } = oscIntegration.init(oscSettings); - logger.info(LogOrigin.Rx, message); + logger.info(LogOrigin.Tx, message); res.send(oscSettings).status(200); } catch (error) { diff --git a/apps/server/src/utils/coerceType.ts b/apps/server/src/utils/coerceType.ts new file mode 100644 index 000000000..ff0dd8563 --- /dev/null +++ b/apps/server/src/utils/coerceType.ts @@ -0,0 +1,42 @@ +/** + * @description Converts a value to a number if possible, throws otherwise + * @param {unknown} value - Value to be converted to a string. + * @returns {string} - The converted value as a string. + * @throws {Error} Throws an error if the value is null or undefined. + */ +export function coerceString(value: unknown): string { + if (value == null) { + throw new Error('Invalid value received'); + } + return String(value); +} + +/** + * @description Converts a value to a number if possible, throws otherwise + * @param {unknown} value - Value to be converted to a boolean. + * @returns {boolean} - The converted value as a boolean. + * @throws {Error} Throws an error if the value is null or undefined. + */ +export function coerceBoolean(value: unknown): boolean { + if (value == null) { + throw new Error('Invalid value received'); + } + return Boolean(value); +} + +/** + * @description Converts a value to a number if possible, throws otherwise + * @param {unknown} value - Value to be converted to a number. + * @returns {number} - The converted value as a number. + * @throws {Error} Throws an error if the value is null, undefined or not a valid number. + */ +export function coerceNumber(value: unknown): number { + if (value == null) { + throw new Error('Invalid value received'); + } + const parsedValue = Number(value); + if (isNaN(parsedValue)) { + throw new Error('Invalid value received'); + } + return parsedValue; +} diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index ca31381a5..74cb630e7 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -14,3 +14,9 @@ export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay { export function isOntimeBlock(event: MaybeEvent): event is OntimeBlock { return event?.type === SupportedEvent.Block; } + +type AnyKeys = keyof T; + +export function isKeyOfType(key: PropertyKey, obj: T): key is AnyKeys { + return key in obj; +}